Skip to content

Commit 5a3a7a7

Browse files
committed
WIP
1 parent 68c1375 commit 5a3a7a7

16 files changed

+2255
-6
lines changed

packages/editor/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@
3535
"source": "./src/behaviors/_exports/index.ts",
3636
"default": "./lib/behaviors/index.js"
3737
},
38+
"./renderers": {
39+
"source": "./src/renderers/_exports/index.ts",
40+
"default": "./lib/renderers/index.js"
41+
},
3842
"./plugins": {
3943
"source": "./src/plugins/_exports/index.ts",
4044
"default": "./lib/plugins/index.js"

packages/editor/src/editor.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type {EditorDom} from './editor/editor-dom'
66
import type {ExternalEditorEvent} from './editor/editor-machine'
77
import type {EditorSnapshot} from './editor/editor-snapshot'
88
import type {EditorEmittedEvent} from './editor/relay-machine'
9-
import type {ArrayDefinition, ArraySchemaType} from './types/sanity-types'
9+
import type {Renderer} from './renderers/renderer.types'
1010

1111
/**
1212
* @public
@@ -50,6 +50,28 @@ export type Editor = {
5050
* @beta
5151
*/
5252
registerBehavior: (config: {behavior: Behavior}) => () => void
53+
/**
54+
* @beta
55+
* Register a custom renderer for a specific node type.
56+
*
57+
* @example
58+
* ```tsx
59+
* // Block object renderer (type: 'block', name matches the object type)
60+
* const unregister = editor.registerRenderer({
61+
* renderer: defineRenderer<typeof schema>()({
62+
* type: 'block',
63+
* name: 'image',
64+
* render: ({attributes, children, node}) => (
65+
* <figure {...attributes}>
66+
* <img src={node.src} alt={node.alt} />
67+
* {children}
68+
* </figure>
69+
* ),
70+
* }),
71+
* })
72+
* ```
73+
*/
74+
registerRenderer: (config: {renderer: Renderer}) => () => void
5375
send: (event: EditorEvent) => void
5476
on: ActorRef<Snapshot<unknown>, EventObject, EditorEmittedEvent>['on']
5577
}

packages/editor/src/editor/create-editor.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import {debugWithName} from '../internal-utils/debug'
1111
import {compileType} from '../internal-utils/schema'
1212
import {corePriority} from '../priority/priority.core'
1313
import {createEditorPriority} from '../priority/priority.types'
14-
import type {EditableAPI} from '../types/editor'
15-
import type {PortableTextSlateEditor} from '../types/slate-editor'
14+
import type {Renderer, RendererConfig} from '../renderers/renderer.types'
15+
import type {EditableAPI, PortableTextSlateEditor} from '../types/editor'
1616
import {defaultKeyGenerator} from '../utils/key-generator'
1717
import {createEditableAPI} from './create-editable-api'
1818
import {createSlateEditor, type SlateEditor} from './create-slate-editor'
@@ -99,6 +99,31 @@ export function createInternalEditor(config: EditorConfig): {
9999
})
100100
}
101101
},
102+
registerRenderer: (rendererConfig: {renderer: Renderer}) => {
103+
const priority = createEditorPriority({
104+
name: 'custom',
105+
reference: {
106+
priority: corePriority,
107+
importance: 'higher',
108+
},
109+
})
110+
const rendererConfigWithPriority = {
111+
...rendererConfig,
112+
priority,
113+
} satisfies RendererConfig
114+
115+
editorActor.send({
116+
type: 'add renderer',
117+
rendererConfig: rendererConfigWithPriority,
118+
})
119+
120+
return () => {
121+
editorActor.send({
122+
type: 'remove renderer',
123+
rendererConfig: rendererConfigWithPriority,
124+
})
125+
}
126+
},
102127
send: (event) => {
103128
switch (event.type) {
104129
case 'update value':

packages/editor/src/editor/editor-machine.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {Converter} from '../converters/converter.types'
2222
import {debugWithName} from '../internal-utils/debug'
2323
import type {EventPosition} from '../internal-utils/event-position'
2424
import {sortByPriority} from '../priority/priority.sort'
25+
import {getRendererKey, type RendererConfig} from '../renderers/renderer.types'
2526
import type {NamespaceEvent, OmitFromUnion} from '../type-utils'
2627
import type {
2728
EditorSelection,
@@ -82,6 +83,14 @@ export type InternalEditorEvent =
8283
type: 'remove behavior'
8384
behaviorConfig: BehaviorConfig
8485
}
86+
| {
87+
type: 'add renderer'
88+
rendererConfig: RendererConfig
89+
}
90+
| {
91+
type: 'remove renderer'
92+
rendererConfig: RendererConfig
93+
}
8594
| {
8695
type: 'blur'
8796
editor: PortableTextSlateEditor
@@ -185,6 +194,7 @@ export const editorMachine = setup({
185194
keyGenerator: () => string
186195
pendingEvents: Array<InternalPatchEvent | MutationEvent>
187196
pendingIncomingPatchesEvents: Array<PatchesEvent>
197+
renderers: Map<string, RendererConfig[]>
188198
schema: EditorSchema
189199
initialReadOnly: boolean
190200
selection: EditorSelection
@@ -225,6 +235,44 @@ export const editorMachine = setup({
225235
return new Set([...context.behaviors])
226236
},
227237
}),
238+
'add renderer to context': assign({
239+
renderers: ({context, event}) => {
240+
assertEvent(event, 'add renderer')
241+
242+
const key = getRendererKey(
243+
event.rendererConfig.renderer.type,
244+
event.rendererConfig.renderer.name,
245+
)
246+
247+
const newRenderers = new Map(context.renderers)
248+
const existing = newRenderers.get(key) ?? []
249+
// Add to the array - renderers are evaluated in order, first match wins
250+
newRenderers.set(key, [...existing, event.rendererConfig])
251+
return newRenderers
252+
},
253+
}),
254+
'remove renderer from context': assign({
255+
renderers: ({context, event}) => {
256+
assertEvent(event, 'remove renderer')
257+
258+
const key = getRendererKey(
259+
event.rendererConfig.renderer.type,
260+
event.rendererConfig.renderer.name,
261+
)
262+
263+
const newRenderers = new Map(context.renderers)
264+
const existing = newRenderers.get(key)
265+
if (existing) {
266+
const filtered = existing.filter((r) => r !== event.rendererConfig)
267+
if (filtered.length === 0) {
268+
newRenderers.delete(key)
269+
} else {
270+
newRenderers.set(key, filtered)
271+
}
272+
}
273+
return newRenderers
274+
},
275+
}),
228276
'add slate editor to context': assign({
229277
slateEditor: ({context, event}) => {
230278
return event.type === 'add slate editor'
@@ -388,6 +436,7 @@ export const editorMachine = setup({
388436
keyGenerator: input.keyGenerator,
389437
pendingEvents: [],
390438
pendingIncomingPatchesEvents: [],
439+
renderers: new Map<string, RendererConfig[]>(),
391440
schema: input.schema,
392441
selection: null,
393442
initialReadOnly: input.readOnly ?? false,
@@ -396,6 +445,8 @@ export const editorMachine = setup({
396445
on: {
397446
'add behavior': {actions: 'add behavior to context'},
398447
'remove behavior': {actions: 'remove behavior from context'},
448+
'add renderer': {actions: 'add renderer to context'},
449+
'remove renderer': {actions: 'remove renderer from context'},
399450
'add slate editor': {actions: 'add slate editor to context'},
400451
'update selection': {
401452
actions: [

packages/editor/src/editor/render.block-object.tsx

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
import type {PortableTextObject} from '@portabletext/schema'
2-
import {useRef, useState, type ReactElement} from 'react'
2+
import {useContext, useRef, useState, type ReactElement} from 'react'
33
import {Range, type Element as SlateElement} from 'slate'
44
import {
55
useSelected,
66
useSlateSelector,
7+
useSlateStatic,
78
type RenderElementProps,
89
} from 'slate-react'
910
import type {EventPositionBlock} from '../internal-utils/event-position'
11+
import {findMatchingRenderer, useRenderers} from '../renderers/use-renderer'
1012
import type {
1113
BlockRenderProps,
1214
PortableTextMemberSchemaTypes,
1315
RenderBlockFunction,
1416
} from '../types/editor'
17+
import {EditorActorContext} from './editor-actor-context'
1518
import type {EditorSchema} from './editor-schema'
19+
import {getEditorSnapshot} from './editor-selector'
1620
import {RenderDefaultBlockObject} from './render.default-object'
1721
import {DropIndicator} from './render.drop-indicator'
1822
import {useCoreBlockElementBehaviors} from './use-core-block-element-behaviors'
@@ -30,6 +34,8 @@ export function RenderBlockObject(props: {
3034
const [dragPositionBlock, setDragPositionBlock] =
3135
useState<EventPositionBlock>()
3236
const blockObjectRef = useRef<HTMLDivElement>(null)
37+
const slateEditor = useSlateStatic()
38+
const editorActor = useContext(EditorActorContext)
3339
const selected = useSelected()
3440
const focused = useSlateSelector(
3541
(editor) =>
@@ -43,6 +49,16 @@ export function RenderBlockObject(props: {
4349
onSetDragPositionBlock: setDragPositionBlock,
4450
})
4551

52+
// Get registered renderers
53+
const registeredRenderers = useRenderers('block', props.element._type)
54+
55+
// Lazy snapshot getter - only compute when guards need it
56+
const getSnapshot = () =>
57+
getEditorSnapshot({
58+
editorActorSnapshot: editorActor.getSnapshot(),
59+
slateEditorInstance: slateEditor,
60+
})
61+
4662
const legacySchemaType = props.legacySchema.blockObjects.find(
4763
(schemaType) => schemaType.name === props.element._type,
4864
)
@@ -58,6 +74,54 @@ export function RenderBlockObject(props: {
5874
_type: props.element._type,
5975
}
6076

77+
// Find matching renderer (first whose guard passes)
78+
const match = findMatchingRenderer(
79+
registeredRenderers,
80+
blockObject,
81+
getSnapshot,
82+
)
83+
84+
// If there's a matching renderer, use it (full DOM control)
85+
if (match) {
86+
const renderDefault = () => (
87+
<div
88+
{...props.attributes}
89+
className="pt-block pt-object-block"
90+
data-block-key={props.element._key}
91+
data-block-name={props.element._type}
92+
data-block-type="object"
93+
>
94+
{dragPositionBlock === 'start' ? <DropIndicator /> : null}
95+
{props.children}
96+
<div
97+
ref={blockObjectRef}
98+
contentEditable={false}
99+
draggable={!props.readOnly}
100+
>
101+
<RenderDefaultBlockObject blockObject={blockObject} />
102+
</div>
103+
{dragPositionBlock === 'end' ? <DropIndicator /> : null}
104+
</div>
105+
)
106+
107+
const renderHidden = () => (
108+
<div {...props.attributes} style={{display: 'none'}}>
109+
{props.children}
110+
</div>
111+
)
112+
113+
return match.renderer.renderer.render(
114+
{
115+
attributes: props.attributes,
116+
children: props.children,
117+
node: blockObject,
118+
renderDefault,
119+
renderHidden,
120+
},
121+
match.guardResponse,
122+
)
123+
}
124+
61125
return (
62126
<div
63127
{...props.attributes}

packages/editor/src/editor/render.inline-object.tsx

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {useRef, type ReactElement} from 'react'
1+
import {useContext, useRef, type ReactElement} from 'react'
22
import {Range, type Element as SlateElement} from 'slate'
33
import {DOMEditor} from 'slate-dom'
44
import {
@@ -8,12 +8,15 @@ import {
88
type RenderElementProps,
99
} from 'slate-react'
1010
import {getPointBlock} from '../internal-utils/slate-utils'
11+
import {findMatchingRenderer, useRenderers} from '../renderers/use-renderer'
1112
import type {
1213
BlockChildRenderProps,
1314
PortableTextMemberSchemaTypes,
1415
RenderChildFunction,
1516
} from '../types/editor'
17+
import {EditorActorContext} from './editor-actor-context'
1618
import type {EditorSchema} from './editor-schema'
19+
import {getEditorSnapshot} from './editor-selector'
1720
import {RenderDefaultInlineObject} from './render.default-object'
1821

1922
export function RenderInlineObject(props: {
@@ -27,6 +30,7 @@ export function RenderInlineObject(props: {
2730
}) {
2831
const inlineObjectRef = useRef<HTMLElement>(null)
2932
const slateEditor = useSlateStatic()
33+
const editorActor = useContext(EditorActorContext)
3034
const selected = useSelected()
3135
const focused = useSlateSelector(
3236
(editor) =>
@@ -35,6 +39,16 @@ export function RenderInlineObject(props: {
3539
Range.isCollapsed(editor.selection),
3640
)
3741

42+
// Get registered renderers
43+
const registeredRenderers = useRenderers('inline', props.element._type)
44+
45+
// Lazy snapshot getter - only compute when guards need it
46+
const getSnapshot = () =>
47+
getEditorSnapshot({
48+
editorActorSnapshot: editorActor.getSnapshot(),
49+
slateEditorInstance: slateEditor,
50+
})
51+
3852
const legacySchemaType = props.legacySchema.inlineObjects.find(
3953
(inlineObject) => inlineObject.name === props.element._type,
4054
)
@@ -68,6 +82,49 @@ export function RenderInlineObject(props: {
6882
: {}),
6983
}
7084

85+
// Find matching renderer (first whose guard passes)
86+
const match = findMatchingRenderer(
87+
registeredRenderers,
88+
inlineObject,
89+
getSnapshot,
90+
)
91+
92+
// If there's a matching renderer, use it (full DOM control)
93+
if (match) {
94+
const renderDefault = () => (
95+
<span
96+
{...props.attributes}
97+
draggable={!props.readOnly}
98+
className="pt-inline-object"
99+
data-child-key={inlineObject._key}
100+
data-child-name={inlineObject._type}
101+
data-child-type="object"
102+
>
103+
{props.children}
104+
<span ref={inlineObjectRef} style={{display: 'inline-block'}}>
105+
<RenderDefaultInlineObject inlineObject={inlineObject} />
106+
</span>
107+
</span>
108+
)
109+
110+
const renderHidden = () => (
111+
<span {...props.attributes} style={{display: 'none'}}>
112+
{props.children}
113+
</span>
114+
)
115+
116+
return match.renderer.renderer.render(
117+
{
118+
attributes: props.attributes,
119+
children: props.children,
120+
node: inlineObject,
121+
renderDefault,
122+
renderHidden,
123+
},
124+
match.guardResponse,
125+
)
126+
}
127+
71128
return (
72129
<span
73130
{...props.attributes}

0 commit comments

Comments
 (0)