Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(editor): add store prop #214

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
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
22 changes: 8 additions & 14 deletions apps/playground/src/editor.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
BlockDecoratorRenderProps,
BlockStyleRenderProps,
Patch,
createEditor,
PortableTextEditable,
PortableTextEditor,
RenderAnnotationFunction,
Expand All @@ -12,13 +12,11 @@ import {
RenderPlaceholderFunction,
RenderStyleFunction,
} from '@portabletext/editor'
import {PortableTextBlock} from '@sanity/types'
import {useSelector} from '@xstate/react'
import {CopyIcon, ImageIcon, TrashIcon} from 'lucide-react'
import {useEffect, useMemo, useState} from 'react'
import {TooltipTrigger} from 'react-aria-components'
import {reverse} from 'remeda'
import {Subject} from 'rxjs'
import {Button} from './components/button'
import {ErrorBoundary} from './components/error-boundary'
import {ErrorScreen} from './components/error-screen'
Expand All @@ -42,6 +40,7 @@ import {SelectionPreview} from './selection-preview'
import {wait} from './wait'

export function Editor(props: {editorRef: EditorActorRef}) {
const editor = useMemo(() => createEditor(), [])
const showingPatchesPreview = useSelector(props.editorRef, (s) =>
s.matches({'patches preview': 'shown'}),
)
Expand All @@ -56,17 +55,11 @@ export function Editor(props: {editorRef: EditorActorRef}) {
const patchesReceived = useSelector(props.editorRef, (s) =>
reverse(s.context.patchesReceived),
)
const patches$ = useMemo(
() =>
new Subject<{
patches: Array<Patch>
snapshot: Array<PortableTextBlock> | undefined
}>(),
[],
)

useEffect(() => {
const subscription = props.editorRef.on('patches', (event) => {
patches$.next({
editor.send({
type: 'patches',
patches: event.patches,
snapshot: event.snapshot,
})
Expand All @@ -75,7 +68,8 @@ export function Editor(props: {editorRef: EditorActorRef}) {
return () => {
subscription.unsubscribe()
}
}, [patches$, props.editorRef])
}, [props.editorRef, editor])

const [loading, setLoading] = useState(false)

return (
Expand All @@ -89,8 +83,8 @@ export function Editor(props: {editorRef: EditorActorRef}) {
onError={console.error}
>
<PortableTextEditor
editor={editor}
value={value}
patches$={patches$}
onChange={(change) => {
if (change.type === 'mutation') {
props.editorRef.send(change)
Expand Down
3 changes: 2 additions & 1 deletion packages/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@
"is-hotkey-esm": "^1.0.0",
"lodash": "^4.17.21",
"slate": "0.103.0",
"slate-react": "0.110.1"
"slate-react": "0.110.1",
"xstate": "^5.18.2"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
Expand Down
54 changes: 54 additions & 0 deletions packages/editor/src/editor-machine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type {Patch} from '@portabletext/patches'
import type {PortableTextBlock} from '@sanity/types'
import {createActor, emit, setup, type ActorRefFrom} from 'xstate'

/**
* @alpha
*/
export type EditorActor = ActorRefFrom<typeof editorMachine>

/**
* @alpha
*/
export function createEditorActor() {
return createActor(editorMachine)
}

export {createEditorActor as createEditor}

/**
* @alpha
*/
export const editorMachine = setup({
types: {
events: {} as {
type: 'patches'
patches: Array<Patch>
snapshot: Array<PortableTextBlock> | undefined
},
emitted: {} as {
type: 'remote patches received'
patches: Array<Patch>
snapshot: Array<PortableTextBlock> | undefined
},
},
actions: {
'emit remote patches received': emit(({event}) => ({
type: 'remote patches received' as const,
patches: event.patches.filter((patch) => patch.origin !== 'local'),
snapshot: event.snapshot,
})),
},
guards: {
'has remote patches': ({event}) =>
event.patches.some((patch) => patch.origin !== 'local'),
},
}).createMachine({
id: 'editor',
on: {
patches: {
actions: ['emit remote patches received'],
guard: 'has remote patches',
},
},
})
51 changes: 36 additions & 15 deletions packages/editor/src/editor/PortableTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import type {
SpanSchemaType,
} from '@sanity/types'
import {Component, type MutableRefObject, type PropsWithChildren} from 'react'
import {Subject} from 'rxjs'
import {Subject, type Subscription} from 'rxjs'
import {createEditorActor, type EditorActor} from '../editor-machine'
import type {
EditableAPI,
EditableAPIDeleteOptions,
Expand Down Expand Up @@ -41,6 +42,11 @@ const debug = debugWithName('component:PortableTextEditor')
* @public
*/
export type PortableTextEditorProps = PropsWithChildren<{
/**
* Used to interact with and listen to events from the editor instance
*/
editor?: EditorActor

/**
* Function that gets called when the editor changes the value
*/
Expand Down Expand Up @@ -72,15 +78,11 @@ export type PortableTextEditorProps = PropsWithChildren<{
keyGenerator?: () => string

/**
* @deprecated Use `editor` instead.
* Observable of local and remote patches for the edited value.
*/
patches$?: PatchObservable

/**
* Backward compatibility (renamed to patches$).
*/
incomingPatches$?: PatchObservable

/**
* A ref to the editor instance
*/
Expand All @@ -105,26 +107,46 @@ export class PortableTextEditor extends Component<PortableTextEditorProps> {
*/
private editable?: EditableAPI

private editorActor: EditorActor

private patches$?: PatchObservable
private patchesSubscription?: Subscription

constructor(props: PortableTextEditorProps) {
super(props)

if (!props.schemaType) {
throw new Error('PortableTextEditor: missing "schemaType" property')
}

if (props.incomingPatches$) {
console.warn(
`The prop 'incomingPatches$' is deprecated and renamed to 'patches$'`,
)
}

this.change$.next({type: 'loading', isLoading: true})

this.schemaTypes = getPortableTextMemberSchemaTypes(
props.schemaType.hasOwnProperty('jsonType')
? props.schemaType
: compileType(props.schemaType),
)

// If no store is provided then we just create one
this.editorActor = props.editor ?? createEditorActor()

this.patches$ = props.patches$
}

componentDidMount(): void {
this.editorActor.start()

// For backwards compatibility, if `patches$` is used, we subscribe to it
// and send the patches to the store.
this.patchesSubscription = this.patches$?.subscribe(
({patches, snapshot}) => {
this.editorActor.send({type: 'patches', patches, snapshot})
},
)
}

componentWillUnmount(): void {
this.patchesSubscription?.unsubscribe()
}

componentDidUpdate(prevProps: PortableTextEditorProps) {
Expand Down Expand Up @@ -154,9 +176,8 @@ export class PortableTextEditor extends Component<PortableTextEditorProps> {
}

render() {
const {onChange, value, children, patches$, incomingPatches$} = this.props
const {onChange, value, children} = this.props
const {change$} = this
const _patches$ = incomingPatches$ || patches$ // Backward compatibility

const maxBlocks =
typeof this.props.maxBlocks === 'undefined'
Expand All @@ -167,9 +188,9 @@ export class PortableTextEditor extends Component<PortableTextEditorProps> {
const keyGenerator = this.props.keyGenerator || defaultKeyGenerator
return (
<SlateContainer
editorActor={this.editorActor}
keyGenerator={keyGenerator}
maxBlocks={maxBlocks}
patches$={_patches$}
portableTextEditor={this}
readOnly={readOnly}
>
Expand Down
13 changes: 7 additions & 6 deletions packages/editor/src/editor/components/SlateContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {useEffect, useMemo, useState, type PropsWithChildren} from 'react'
import {createEditor} from 'slate'
import {Slate, withReact} from 'slate-react'
import type {PatchObservable} from '../../types/editor'
import type {EditorActor} from '../../editor-machine'
import {debugWithName} from '../../utils/debug'
import {KEY_TO_SLATE_ELEMENT, KEY_TO_VALUE_ELEMENT} from '../../utils/weakMaps'
import {withPlugins} from '../plugins'
Expand All @@ -13,9 +13,9 @@ const debug = debugWithName('component:PortableTextEditor:SlateContainer')
* @internal
*/
export interface SlateContainerProps extends PropsWithChildren {
editorActor: EditorActor
keyGenerator: () => string
maxBlocks: number | undefined
patches$?: PatchObservable
portableTextEditor: PortableTextEditor
readOnly: boolean
}
Expand All @@ -25,16 +25,16 @@ export interface SlateContainerProps extends PropsWithChildren {
* @internal
*/
export function SlateContainer(props: SlateContainerProps) {
const {patches$, portableTextEditor, readOnly, maxBlocks, keyGenerator} =
const {portableTextEditor, readOnly, maxBlocks, keyGenerator, editorActor} =
props

// Create the slate instance, using `useState` ensures setup is only run once, initially
const [[slateEditor, subscribe]] = useState(() => {
debug('Creating new Slate editor instance')
const {editor, subscribe: _sub} = withPlugins(withReact(createEditor()), {
editorActor,
keyGenerator,
maxBlocks,
patches$,
portableTextEditor,
readOnly,
})
Expand All @@ -45,6 +45,7 @@ export function SlateContainer(props: SlateContainerProps) {

useEffect(() => {
const unsubscribe = subscribe()

return () => {
unsubscribe()
}
Expand All @@ -54,18 +55,18 @@ export function SlateContainer(props: SlateContainerProps) {
useEffect(() => {
debug('Re-initializing plugin chain')
withPlugins(slateEditor, {
editorActor,
keyGenerator,
maxBlocks,
patches$,
portableTextEditor,
readOnly,
})
}, [
editorActor,
keyGenerator,
portableTextEditor,
maxBlocks,
readOnly,
patches$,
slateEditor,
])

Expand Down
12 changes: 4 additions & 8 deletions packages/editor/src/editor/components/Synchronizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,15 +132,11 @@ export function Synchronizer(props: SynchronizerProps) {

// Notify about window online and offline status changes
useEffect(() => {
if (portableTextEditor.props.patches$) {
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
}
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
if (portableTextEditor.props.patches$) {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
})

Expand Down
24 changes: 11 additions & 13 deletions packages/editor/src/editor/plugins/createWithPatches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import {
type SetNodeOperation,
type SplitNodeOperation,
} from 'slate'
import type {EditorActor} from '../../editor-machine'
import type {
EditorChange,
PatchObservable,
PortableTextMemberSchemaTypes,
PortableTextSlateEditor,
} from '../../types/editor'
Expand Down Expand Up @@ -81,17 +81,17 @@ export interface PatchFunctions {
}

interface Options {
editorActor: EditorActor
change$: Subject<EditorChange>
keyGenerator: () => string
patches$?: PatchObservable
patchFunctions: PatchFunctions
readOnly: boolean
schemaTypes: PortableTextMemberSchemaTypes
}

export function createWithPatches({
change$,
patches$,
editorActor,
patchFunctions,
readOnly,
schemaTypes,
Expand Down Expand Up @@ -145,16 +145,14 @@ export function createWithPatches({
handleBufferedRemotePatches()
}

if (patches$) {
editor.subscriptions.push(() => {
debug('Subscribing to patches$')
const sub = patches$.subscribe(handlePatches)
return () => {
debug('Unsubscribing to patches$')
sub.unsubscribe()
}
})
}
editor.subscriptions.push(() => {
debug('Subscribing to patches')
const sub = editorActor.on('remote patches received', handlePatches)
return () => {
debug('Unsubscribing to patches')
sub.unsubscribe()
}
})

editor.apply = (operation: Operation): void | Editor => {
if (readOnly) {
Expand Down
Loading
Loading