Skip to content
Open
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
24 changes: 24 additions & 0 deletions .changeset/emit-change-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@portabletext/editor': minor
---

feat: emit `change` events for local and remote changes, with corrections marked

The editor now emits a `change` event, synchronously, every time its content changes. Each event carries the `operations` the editor applied to its content (the public `Operation` type), never the input it received:

```ts
editor.on('change', (event) => {
if (event.origin === 'local') {
// `event.patches` is the change as Sanity patches.
save(event.patches)
}
})
```

- `origin: 'local'`: what a user action did, including everything the action's Behaviors raised. Undo and redo are one action each. Local changes arrive in application order, and one user action can produce several when the editor fixes something mid-action. `patches` holds the change as Sanity patches, the same patches the `patch` event emits for the action, available as soon as the action finishes instead of on the `mutation` event's debounced schedule.
- `origin: 'local'` with `correction: true`: the editor's own fixes to the content, such as merging adjacent spans that share marks after a paste, or replacing a duplicate `_key`. A correction follows the change that caused it, with its own operations and patches. Fixes to content the editor received through `patches` or `update value` are corrections too. Registering or unregistering a node with `editor.registerNode` after load can make the editor fix existing content, and those fixes are corrections. A host that saves every local change's `patches` saves corrections as well. A correction to received content arrives even when the editor drops it from `mutation` because a newer value or a conflicting remote patch arrived first. A key or unused mark definition the editor repairs while validating an `update value` is not part of any `change`, and still reaches the host only through `patch` and `mutation`.
- `origin: 'remote'`: one event per `patches` message and per `update value` that changed the content. Remote changes carry no `patches`.

Every `change` of one action is delivered before any `change` that a listener sets off while receiving them. Loading the initial value emits no `change`, except a `correction` when the editor fixes the loaded value, and a `change` never has an empty `operations` array. A long initial value that the editor loads across several ticks counts as one load, so its fixes arrive as one `correction`. `ChangeEvent` is exported as a beta type. `EditorEmittedEvent` gains the `change` member, so exhaustive switches over `event.type` gain a case.

When the DOM selection leaves a read-only editor's content, the editor now deselects through the `select` event with `at: null`, so Behaviors listening to `select` see it.
134 changes: 134 additions & 0 deletions apps/playground/src/changes-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import {useActorRef, useSelector} from '@xstate/react'
import {ChevronRightIcon, GitCommitHorizontalIcon} from 'lucide-react'
import {useState} from 'react'
import {tv} from 'tailwind-variants'
import {highlightMachine} from './highlight-json-machine'
import type {ChangeFeedEntry} from './playground-machine'

const changeCardStyle = tv({
base: 'rounded-md border transition-colors duration-150',
variants: {
isNew: {
true: 'bg-emerald-50 dark:bg-emerald-900/30 border-emerald-200 dark:border-emerald-800',
false:
'bg-gray-50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700',
},
},
})

const badgeStyle = tv({
base: 'px-1.5 py-0.5 rounded text-xs font-medium',
variants: {
kind: {
local: 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300',
remote:
'bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300',
correction:
'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300',
editor: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300',
},
},
})

export function ChangesList(props: {
entries: Array<ChangeFeedEntry>
showEditorLabel: boolean
}) {
if (props.entries.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-center gap-2">
<GitCommitHorizontalIcon className="size-8 text-gray-300 dark:text-gray-600" />
<p className="text-sm text-gray-500 dark:text-gray-400 font-medium">
No changes yet
</p>
<p className="text-xs text-gray-400 dark:text-gray-500">
Start typing to see changes
</p>
</div>
)
}

const newestTimestamp = props.entries[0]?.timestamp

return (
<div className="flex flex-col gap-2 h-full overflow-y-auto">
{props.entries.map((entry) => (
<ChangeCard
key={entry.id}
entry={entry}
isNew={entry.timestamp === newestTimestamp}
showEditorLabel={props.showEditorLabel}
/>
))}
</div>
)
}

function ChangeCard(props: {
entry: ChangeFeedEntry
isNew: boolean
showEditorLabel: boolean
}) {
const {entry} = props
const [isExpanded, setIsExpanded] = useState(false)
const operationCount = entry.event.operations.length

return (
<div className={changeCardStyle({isNew: props.isNew})}>
<button
type="button"
className="w-full flex items-center gap-1.5 p-2 text-left cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<ChevronRightIcon
className={`size-3 text-gray-400 dark:text-gray-500 shrink-0 transition-transform duration-150 ${isExpanded ? 'rotate-90' : ''}`}
/>
{props.showEditorLabel ? (
<span className={badgeStyle({kind: 'editor'})}>{entry.editorId}</span>
) : null}
<span className={badgeStyle({kind: entry.event.origin})}>
{entry.event.origin}
</span>
{entry.event.origin === 'local' && entry.event.correction ? (
<span className={badgeStyle({kind: 'correction'})}>correction</span>
) : null}
<span className="text-xs text-gray-600 dark:text-gray-400">
{operationCount} {operationCount === 1 ? 'operation' : 'operations'}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500 ml-auto shrink-0">
{new Date(entry.timestamp).toLocaleTimeString()}
</span>
</button>
{isExpanded ? (
<div className="flex flex-col gap-2 px-2 pb-2">
<HighlightedJson label="Operations" data={entry.event.operations} />
{entry.event.origin === 'local' ? (
<HighlightedJson label="Patches" data={entry.event.patches} />
) : null}
</div>
) : null}
</div>
)
}

function HighlightedJson(props: {label: string; data: unknown}) {
const highlightRef = useActorRef(highlightMachine, {
input: {code: JSON.stringify(props.data), variant: 'ghost'},
})
const highlightedCode = useSelector(
highlightRef,
(s) => s.context.highlightedCode,
)

return (
<div className="rounded bg-gray-50 dark:bg-gray-900 overflow-hidden border border-gray-200 dark:border-gray-700">
<div className="px-2 py-1 border-b border-gray-200 dark:border-gray-700 text-xs font-medium text-gray-500 dark:text-gray-400">
{props.label}
</div>
<div
className="overflow-auto text-xs [&>pre]:p-2 [&>pre]:m-0"
dangerouslySetInnerHTML={{__html: highlightedCode ?? ''}}
/>
</div>
)
}
10 changes: 10 additions & 0 deletions apps/playground/src/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,16 @@ function EditorEventListener(props: {
}
}, [props.editorRef, editor, patchSubscriptionActive])

useEffect(() => {
const subscription = editor.on('change', (event) => {
props.editorRef.send(event)
})

return () => {
subscription.unsubscribe()
}
}, [editor, props.editorRef])

useEffect(() => {
const subscription = editor.on('*', props.on)

Expand Down
87 changes: 87 additions & 0 deletions apps/playground/src/inspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,18 @@ import {useActorRef, useSelector} from '@xstate/react'
import {
CheckIcon,
CopyIcon,
GitCommitHorizontalIcon,
HistoryIcon,
LayersIcon,
MessageSquareTextIcon,
PauseIcon,
PlayIcon,
TrashIcon,
} from 'lucide-react'
import {useEffect, useState} from 'react'
import {TooltipTrigger, type Key} from 'react-aria-components'
import {tv} from 'tailwind-variants'
import {ChangesList} from './changes-list'
import {highlightMachine} from './highlight-json-machine'
import {MarkdownLogo, PortableTextLogo, ReactLogo} from './logos'
import {PatchesList} from './patches-list'
Expand All @@ -29,11 +33,13 @@ import {Button} from './primitives/button'
import {Container} from './primitives/container'
import {Spinner} from './primitives/spinner'
import {Tab, TabList, TabPanel, Tabs} from './primitives/tabs'
import {ToggleButton} from './primitives/toggle-button'
import {Tooltip} from './primitives/tooltip'

type TabId =
| 'output'
| 'patches'
| 'changes'
| 'react-preview'
| 'markdown-preview'
| 'comments'
Expand Down Expand Up @@ -66,6 +72,12 @@ export function Inspector(props: {playgroundRef: PlaygroundActorRef}) {
<span className="hidden sm:inline">Patches</span>
</span>
</Tab>
<Tab id="changes">
<span className="flex items-center gap-1.5">
<GitCommitHorizontalIcon className="size-3" />
<span className="hidden sm:inline">Changes</span>
</span>
</Tab>
<Tab id="react-preview">
<span className="flex items-center gap-1.5">
<ReactLogo className="size-3" />
Expand Down Expand Up @@ -106,6 +118,12 @@ export function Inspector(props: {playgroundRef: PlaygroundActorRef}) {
</Container>
</TabPanel>

<TabPanel id="changes" className="flex-1 min-h-0">
<Container className="h-full overflow-clip">
<ChangesPanel playgroundRef={props.playgroundRef} />
</Container>
</TabPanel>

<TabPanel id="react-preview" className="flex-1 min-h-0">
<Container className="h-full overflow-clip">
<ReactPreview playgroundRef={props.playgroundRef} />
Expand Down Expand Up @@ -144,6 +162,12 @@ function TabActions(props: {
const isCopyingPatches = useSelector(playgroundRef, (s) =>
s.matches({'copying patches': 'copied'}),
)
const isCopyingChanges = useSelector(playgroundRef, (s) =>
s.matches({'copying changes': 'copied'}),
)
const isRecordingChanges = useSelector(playgroundRef, (s) =>
s.matches({'change recording': 'recording'}),
)
const isCopyingMarkdown = useSelector(playgroundRef, (s) =>
s.matches({'copying markdown': 'copied'}),
)
Expand Down Expand Up @@ -198,6 +222,56 @@ function TabActions(props: {
)
}

if (activeTab === 'changes') {
return (
<div className="flex items-center gap-1">
<TooltipTrigger>
<ToggleButton
variant="ghost"
size="sm"
isSelected={!isRecordingChanges}
onChange={() =>
playgroundRef.send({type: 'toggle change recording'})
}
>
{isRecordingChanges ? (
<PauseIcon className="size-3" />
) : (
<PlayIcon className="size-3" />
)}
</ToggleButton>
<Tooltip>
{isRecordingChanges ? 'Pause changes' : 'Resume changes'}
</Tooltip>
</TooltipTrigger>
<TooltipTrigger>
<Button
variant="ghost"
size="sm"
onPress={() => playgroundRef.send({type: 'copy changes'})}
>
{isCopyingChanges ? (
<CheckIcon className="size-3 text-green-600 dark:text-green-400" />
) : (
<CopyIcon className="size-3" />
)}
</Button>
<Tooltip>{isCopyingChanges ? 'Copied!' : 'Copy changes'}</Tooltip>
</TooltipTrigger>
<TooltipTrigger>
<Button
variant="ghost"
size="sm"
onPress={() => playgroundRef.send({type: 'clear changes'})}
>
<TrashIcon className="size-3" />
</Button>
<Tooltip>Clear changes</Tooltip>
</TooltipTrigger>
</div>
)
}

if (activeTab === 'markdown-preview') {
return (
<TooltipTrigger>
Expand Down Expand Up @@ -461,3 +535,16 @@ function PatchesPanel(props: {playgroundRef: PlaygroundActorRef}) {

return <PatchesList entries={patchFeed} showEditorLabel={editorCount > 1} />
}

function ChangesPanel(props: {playgroundRef: PlaygroundActorRef}) {
const changeFeed = useSelector(
props.playgroundRef,
(s) => s.context.changeFeed,
)
const editorCount = useSelector(
props.playgroundRef,
(s) => s.context.editors.length,
)

return <ChangesList entries={changeFeed} showEditorLabel={editorCount > 1} />
}
Loading
Loading