Skip to content

Commit

Permalink
fix(performance): allow PortableTextEditable to be compiled
Browse files Browse the repository at this point in the history
  • Loading branch information
stipsan committed Oct 31, 2024
1 parent 79edba8 commit d477380
Show file tree
Hide file tree
Showing 2 changed files with 104 additions and 82 deletions.
166 changes: 84 additions & 82 deletions packages/editor/src/editor/Editable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
Path,
Range as SlateRange,
Transforms,
type BaseEditor,
type BaseRange,
type NodeEntry,
type Operation,
Expand All @@ -38,6 +39,7 @@ import type {
EditorSelection,
OnCopyFn,
OnPasteFn,
PortableTextSlateEditor,
RangeDecoration,
RenderAnnotationFunction,
RenderBlockFunction,
Expand Down Expand Up @@ -69,6 +71,7 @@ import {usePortableTextEditor} from './hooks/usePortableTextEditor'
import {usePortableTextEditorReadOnlyStatus} from './hooks/usePortableTextReadOnly'
import {createWithHotkeys, createWithInsertData} from './plugins'
import {PortableTextEditor} from './PortableTextEditor'
import {withSyncRangeDecorations} from './withSyncRangeDecorations'

const debug = debugWithName('component:Editable')

Expand Down Expand Up @@ -163,27 +166,29 @@ export const PortableTextEditable = forwardRef<

const blockTypeName = schemaTypes.block.name

// React/UI-specific plugins
const withInsertData = useMemo(
() => createWithInsertData(editorActor, schemaTypes),
[editorActor, schemaTypes],
)
const withHotKeys = useMemo(
() => createWithHotkeys(portableTextEditor, hotkeys),
[hotkeys, portableTextEditor],
)

// Output a minimal React editor inside Editable when in readOnly mode.
// NOTE: make sure all the plugins used here can be safely run over again at any point.
// There will be a problem if they redefine editor methods and then calling the original method within themselves.
useMemo(() => {
// React/UI-specific plugins
const withInsertData = createWithInsertData(editorActor, schemaTypes)

if (readOnly) {
debug('Editable is in read only mode')
return withInsertData(slateEditor)
}
const withHotKeys = createWithHotkeys(portableTextEditor, hotkeys)

debug('Editable is in edit mode')
return withInsertData(withHotKeys(slateEditor))
}, [readOnly, slateEditor, withHotKeys, withInsertData])
}, [
editorActor,
hotkeys,
portableTextEditor,
readOnly,
slateEditor,
schemaTypes,
])

const renderElement = useCallback(
(eProps: RenderElementProps) => (
Expand Down Expand Up @@ -381,9 +386,6 @@ export const PortableTextEditable = forwardRef<
}
}, [hasInvalidValue, propsSelection, restoreSelectionFromProps])

// Store reference to original apply function (see below for usage in useEffect)
const originalApply = useMemo(() => slateEditor.apply, [slateEditor])

const [syncedRangeDecorations, setSyncedRangeDecorations] = useState(false)
useEffect(() => {
if (!syncedRangeDecorations) {
Expand All @@ -402,16 +404,9 @@ export const PortableTextEditable = forwardRef<

// Sync range decorations after an operation is applied
useEffect(() => {
slateEditor.apply = (op: Operation) => {
originalApply(op)
if (op.type !== 'set_selection') {
syncRangeDecorations(op)
}
}
return () => {
slateEditor.apply = originalApply
}
}, [originalApply, slateEditor, syncRangeDecorations])
const teardown = withSyncRangeDecorations(slateEditor, syncRangeDecorations)
return () => teardown()
}, [slateEditor, syncRangeDecorations])

// Handle from props onCopy function
const handleCopy = useCallback(
Expand Down Expand Up @@ -560,64 +555,7 @@ export const PortableTextEditable = forwardRef<
[onBeforeInput],
)

// This function will handle unexpected DOM changes inside the Editable rendering,
// and make sure that we can maintain a stable slateEditor.selection when that happens.
//
// For example, if this Editable is rendered inside something that might re-render
// this component (hidden contexts) while the user is still actively changing the
// contentEditable, this could interfere with the intermediate DOM selection,
// which again could be picked up by ReactEditor's event listeners.
// If that range is invalid at that point, the slate.editorSelection could be
// set either wrong, or invalid, to which slateEditor will throw exceptions
// that are impossible to recover properly from or result in a wrong selection.
//
// Also the other way around, when the ReactEditor will try to create a DOM Range
// from the current slateEditor.selection, it may throw unrecoverable errors
// if the current editor.selection is invalid according to the DOM.
// If this is the case, default to selecting the top of the document, if the
// user already had a selection.
const validateSelection = useCallback(() => {
if (!slateEditor.selection) {
return
}
const root = ReactEditor.findDocumentOrShadowRoot(slateEditor)
const {activeElement} = root
// Return if the editor isn't the active element
if (ref.current !== activeElement) {
return
}
const window = ReactEditor.getWindow(slateEditor)
const domSelection = window.getSelection()
if (!domSelection || domSelection.rangeCount === 0) {
return
}
const existingDOMRange = domSelection.getRangeAt(0)
try {
const newDOMRange = ReactEditor.toDOMRange(
slateEditor,
slateEditor.selection,
)
if (
newDOMRange.startOffset !== existingDOMRange.startOffset ||
newDOMRange.endOffset !== existingDOMRange.endOffset
) {
debug('DOM range out of sync, validating selection')
// Remove all ranges temporary
domSelection?.removeAllRanges()
// Set the correct range
domSelection.addRange(newDOMRange)
}
} catch {
debug(`Could not resolve selection, selecting top document`)
// Deselect the editor
Transforms.deselect(slateEditor)
// Select top document if there is a top block to select
if (slateEditor.children.length > 0) {
Transforms.select(slateEditor, [0, 0])
}
slateEditor.onChange()
}
}, [ref, slateEditor])
const validateSelection = useValidateSelection(ref, slateEditor)

// Observe mutations (child list and subtree) to this component's DOM,
// and make sure the editor selection is valid when that happens.
Expand Down Expand Up @@ -753,3 +691,67 @@ export const PortableTextEditable = forwardRef<
})

PortableTextEditable.displayName = 'ForwardRef(PortableTextEditable)'

function useValidateSelection(
ref: MutableRefObject<HTMLDivElement | null>,
slateEditor: BaseEditor & ReactEditor & PortableTextSlateEditor,
) {
// This function will handle unexpected DOM changes inside the Editable rendering,
// and make sure that we can maintain a stable slateEditor.selection when that happens.
//
// For example, if this Editable is rendered inside something that might re-render
// this component (hidden contexts) while the user is still actively changing the
// contentEditable, this could interfere with the intermediate DOM selection,
// which again could be picked up by ReactEditor's event listeners.
// If that range is invalid at that point, the slate.editorSelection could be
// set either wrong, or invalid, to which slateEditor will throw exceptions
// that are impossible to recover properly from or result in a wrong selection.
//
// Also the other way around, when the ReactEditor will try to create a DOM Range
// from the current slateEditor.selection, it may throw unrecoverable errors
// if the current editor.selection is invalid according to the DOM.
// If this is the case, default to selecting the top of the document, if the
// user already had a selection.
return useCallback(() => {
if (!slateEditor.selection) {
return
}
const root = ReactEditor.findDocumentOrShadowRoot(slateEditor)
const {activeElement} = root
// Return if the editor isn't the active element
if (ref.current !== activeElement) {
return
}
const window = ReactEditor.getWindow(slateEditor)
const domSelection = window.getSelection()
if (!domSelection || domSelection.rangeCount === 0) {
return
}
const existingDOMRange = domSelection.getRangeAt(0)
try {
const newDOMRange = ReactEditor.toDOMRange(
slateEditor,
slateEditor.selection,
)
if (
newDOMRange.startOffset !== existingDOMRange.startOffset ||
newDOMRange.endOffset !== existingDOMRange.endOffset
) {
debug('DOM range out of sync, validating selection')
// Remove all ranges temporary
domSelection?.removeAllRanges()
// Set the correct range
domSelection.addRange(newDOMRange)
}
} catch {
debug(`Could not resolve selection, selecting top document`)
// Deselect the editor
Transforms.deselect(slateEditor)
// Select top document if there is a top block to select
if (slateEditor.children.length > 0) {
Transforms.select(slateEditor, [0, 0])
}
slateEditor.onChange()
}
}, [ref, slateEditor])
}
20 changes: 20 additions & 0 deletions packages/editor/src/editor/withSyncRangeDecorations.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type {BaseEditor, Operation} from 'slate'
import type {ReactEditor} from 'slate-react'
import type {PortableTextSlateEditor} from '../types/editor'

// React Compiler considers `slateEditor` as immutable, and opts-out if we do this inline in a useEffect, doing it in a function moves it out of the scope, and opts-in again for the rest of the component.
export function withSyncRangeDecorations(
slateEditor: BaseEditor & ReactEditor & PortableTextSlateEditor,
syncRangeDecorations: (operation?: Operation) => void,
) {
const originalApply = slateEditor.apply
slateEditor.apply = (op: Operation) => {
originalApply(op)
if (op.type !== 'set_selection') {
syncRangeDecorations(op)
}
}
return () => {
slateEditor.apply = originalApply
}
}

0 comments on commit d477380

Please sign in to comment.