Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@portabletext/plugin-paste-link": "workspace:*",
"@portabletext/plugin-typeahead-picker": "workspace:*",
"@portabletext/plugin-typography": "workspace:*",
"@portabletext/plugin-yjs": "workspace:*",
"@portabletext/react": "^6.0.2",
"@portabletext/toolbar": "workspace:*",
"@xstate/react": "^6.0.0",
Expand All @@ -41,6 +42,7 @@
"remeda": "^2.32.0",
"shiki": "^3.17.0",
"xstate": "^5.25.0",
"yjs": "^13.6.29",
"zod": "^4.1.12"
},
"devDependencies": {
Expand Down
8 changes: 8 additions & 0 deletions apps/playground/src/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,12 @@ import {Tooltip} from './primitives/tooltip'
import {RangeDecorationButton} from './range-decoration-button'
import {SlashCommandPickerPlugin} from './slash-command-picker'
import {PortableTextToolbar} from './toolbar/portable-text-toolbar'
import {PlaygroundYjsPlugin} from './yjs-plugin'

export function Editor(props: {
editorRef: EditorActorRef
rangeDecorations: RangeDecoration[]
editorIndex?: number
}) {
const value = useSelector(props.editorRef, (s) => s.context.value)
const keyGenerator = useSelector(
Expand Down Expand Up @@ -150,6 +152,12 @@ export function Editor(props: {
</div>
) : null}
<Container className="flex flex-col overflow-clip">
{playgroundFeatureFlags.yjsMode ? (
<PlaygroundYjsPlugin
editorIndex={props.editorIndex ?? 0}
useLatency={playgroundFeatureFlags.yjsMode}
/>
) : null}
{featureFlags.emojiPickerPlugin ? <EmojiPickerPlugin /> : null}
{featureFlags.mentionPickerPlugin ? <MentionPickerPlugin /> : null}
{featureFlags.slashCommandPlugin ? (
Expand Down
3 changes: 2 additions & 1 deletion apps/playground/src/editors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,12 @@ export function Editors(props: {playgroundRef: PlaygroundActorRef}) {
<PlaygroundFeatureFlagsContext.Provider
value={playgroundFeatureFlags}
>
{editors.map((editor) => (
{editors.map((editor, index) => (
<Editor
key={editor.id}
editorRef={editor}
rangeDecorations={rangeDecorations}
editorIndex={index}
/>
))}
</PlaygroundFeatureFlagsContext.Provider>
Expand Down
4 changes: 4 additions & 0 deletions apps/playground/src/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ import {createContext} from 'react'

export type PlaygroundFeatureFlags = {
toolbar: boolean
yjsMode: boolean
yjsLatency: number
}

export const defaultPlaygroundFeatureFlags: PlaygroundFeatureFlags = {
toolbar: true,
yjsMode: false,
yjsLatency: 0,
}

export const PlaygroundFeatureFlagsContext =
Expand Down
13 changes: 13 additions & 0 deletions apps/playground/src/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
GithubIcon,
MonitorIcon,
MoonIcon,
NetworkIcon,
PanelRightIcon,
PlusIcon,
SunIcon,
Expand Down Expand Up @@ -110,6 +111,18 @@ export function Header(props: {playgroundRef: PlaygroundActorRef}) {
<WrenchIcon className="size-4" />
<span className="hidden sm:inline">Toolbar</span>
</Switch>
<Switch
isSelected={playgroundFeatureFlags.yjsMode}
onChange={() => {
props.playgroundRef.send({
type: 'toggle feature flag',
flag: 'yjsMode',
})
}}
>
<NetworkIcon className="size-4" />
<span className="hidden sm:inline">Yjs</span>
</Switch>
<Switch
isSelected={showInspector}
onChange={() => {
Expand Down
27 changes: 25 additions & 2 deletions apps/playground/src/inspector.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import {useActorRef, useSelector} from '@xstate/react'
import {CheckIcon, CopyIcon, HistoryIcon, TrashIcon} from 'lucide-react'
import {
CheckIcon,
CopyIcon,
GitBranchIcon,
HistoryIcon,
TrashIcon,
} from 'lucide-react'
import {useEffect, useState} from 'react'
import {TooltipTrigger, type Key} from 'react-aria-components'
import {highlightMachine} from './highlight-json-machine'
Expand All @@ -13,8 +19,14 @@ import {Container} from './primitives/container'
import {Spinner} from './primitives/spinner'
import {Tab, TabList, TabPanel, Tabs} from './primitives/tabs'
import {Tooltip} from './primitives/tooltip'
import {YjsTreeViewer} from './yjs-plugin'

type TabId = 'output' | 'patches' | 'react-preview' | 'markdown-preview'
type TabId =
| 'output'
| 'patches'
| 'react-preview'
| 'markdown-preview'
| 'yjs-tree'

export function Inspector(props: {playgroundRef: PlaygroundActorRef}) {
const [activeTab, setActiveTab] = useState<TabId>('output')
Expand Down Expand Up @@ -55,6 +67,12 @@ export function Inspector(props: {playgroundRef: PlaygroundActorRef}) {
<span className="hidden sm:inline">Markdown</span>
</span>
</Tab>
<Tab id="yjs-tree">
<span className="flex items-center gap-1.5">
<GitBranchIcon className="size-3" />
<span className="hidden sm:inline">Y.Doc</span>
</span>
</Tab>
</TabList>
<TabActions activeTab={activeTab} playgroundRef={props.playgroundRef} />
</div>
Expand Down Expand Up @@ -82,6 +100,11 @@ export function Inspector(props: {playgroundRef: PlaygroundActorRef}) {
<MarkdownPreview playgroundRef={props.playgroundRef} />
</Container>
</TabPanel>
<TabPanel id="yjs-tree" className="flex-1 min-h-0">
<Container className="h-full overflow-clip">
<YjsTreeViewer />
</Container>
</TabPanel>
</Tabs>
)
}
Expand Down
9 changes: 9 additions & 0 deletions apps/playground/src/playground-machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,11 @@ export const playgroundMachine = setup({
actions: {
'broadcast patches': ({context, event}) => {
assertEvent(event, 'editor.mutation')
// When Yjs mode is on, patches flow through the shared Y.Doc
// instead of being broadcast directly between editors
if (context.featureFlags.yjsMode) {
return
}
context.editors.forEach((editor) => {
editor.send({
type: 'patches',
Expand Down Expand Up @@ -336,6 +341,10 @@ export const playgroundMachine = setup({
patchFeed: [],
}),
'broadcast value': ({context}) => {
// When Yjs mode is on, value sync happens through the Y.Doc
if (context.featureFlags.yjsMode) {
return
}
const value = context.value
if (value !== null) {
context.editors.forEach((editor) => {
Expand Down
166 changes: 166 additions & 0 deletions apps/playground/src/yjs-plugin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import {useEditor} from '@portabletext/editor'
import {createYjsPlugin} from '@portabletext/plugin-yjs'
import {useContext, useEffect, useState} from 'react'
import * as Y from 'yjs'
import {PlaygroundFeatureFlagsContext} from './feature-flags'

// Shared Y.Doc — all editors connect to the same doc
const sharedYDoc = new Y.Doc()

/**
* Playground Yjs plugin component.
* Connects an editor to the shared Y.Doc for CRDT sync.
*/
export function PlaygroundYjsPlugin(props: {
editorIndex: number
useLatency?: boolean
}) {
const featureFlags = useContext(PlaygroundFeatureFlagsContext)
const editor = useEditor()

useEffect(() => {
if (!featureFlags.yjsMode) {
return
}

const localOrigin = `editor-${props.editorIndex}`

let yDoc: Y.Doc
let cleanup: (() => void) | undefined

if (props.useLatency && featureFlags.yjsLatency > 0) {
// Latency simulation: each editor gets its own Y.Doc synced with delay
yDoc = new Y.Doc()

const handleSharedUpdate = (update: Uint8Array, origin: unknown) => {
if (origin === localOrigin) {
return
}
setTimeout(() => {
Y.applyUpdate(yDoc, update)
}, featureFlags.yjsLatency)
}

const handleLocalUpdate = (update: Uint8Array, origin: unknown) => {
if (origin === localOrigin) {
setTimeout(() => {
Y.applyUpdate(sharedYDoc, update, localOrigin)
}, featureFlags.yjsLatency)
}
}

sharedYDoc.on('update', handleSharedUpdate)
yDoc.on('update', handleLocalUpdate)

cleanup = () => {
sharedYDoc.off('update', handleSharedUpdate)
yDoc.off('update', handleLocalUpdate)
}
} else {
yDoc = sharedYDoc
}

const plugin = createYjsPlugin({
editor,
yDoc,
localOrigin,
})

plugin.connect()

return () => {
plugin.disconnect()
cleanup?.()
}
}, [
editor,
featureFlags.yjsMode,
featureFlags.yjsLatency,
props.editorIndex,
props.useLatency,
])

return null
}

/**
* Y.Doc viewer for the inspector panel.
* Shows the XmlFragment structure: blocks as XmlText with attributes and delta.
*/
export function YjsTreeViewer() {
const [tree, setTree] = useState<string>('')

useEffect(() => {
const update = () => {
const root = sharedYDoc.getXmlFragment('content')
const lines: string[] = []
lines.push(`Y.Doc — XmlFragment "content" (${root.length} blocks)`)
lines.push('')

for (let i = 0; i < root.length; i++) {
const child = root.get(i)
if (child instanceof Y.XmlText) {
const attrs = child.getAttributes()
const key = attrs._key ?? '?'
const type = attrs._type ?? '?'
const style = attrs.style ?? ''
const listItem = attrs.listItem ? ` [${attrs.listItem}]` : ''

lines.push(`[${i}] ${type} _key="${key}" style="${style}"${listItem}`)

// Show delta (spans)
const delta = child.toDelta() as Array<{
insert: string | Y.XmlText
attributes?: Record<string, unknown>
}>
for (const entry of delta) {
if (typeof entry.insert === 'string') {
const spanKey = entry.attributes?._key ?? '?'
const marks = entry.attributes?.marks ?? '[]'
const text =
entry.insert.length > 40
? `${entry.insert.slice(0, 40)}…`
: entry.insert
lines.push(` span _key="${spanKey}" marks=${marks}`)
lines.push(` "${text}"`)
}
}

// Show markDefs if present
const markDefs = attrs.markDefs
if (markDefs && markDefs !== '[]') {
lines.push(` markDefs: ${markDefs}`)
}

lines.push('')
}
}

setTree(lines.join('\n'))
}

update()
sharedYDoc.on('update', update)
return () => {
sharedYDoc.off('update', update)
}
}, [])

return (
<pre className="text-xs font-mono whitespace-pre-wrap overflow-auto h-full p-2">
{tree || 'Y.Doc is empty. Enable Yjs mode and start typing.'}
</pre>
)
}

/**
* Reset the shared Y.Doc
*/
export function resetSharedYDoc() {
const root = sharedYDoc.getXmlFragment('content')
sharedYDoc.transact(() => {
if (root.length > 0) {
root.delete(0, root.length)
}
})
}
11 changes: 11 additions & 0 deletions packages/plugin-yjs/biome.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.2/schema.json",
"extends": ["../../biome.json"],
"linter": {
"rules": {
"complexity": {
"useLiteralKeys": "off"
}
}
}
}
Loading
Loading