diff --git a/.changeset/brown-suns-smile.md b/.changeset/brown-suns-smile.md new file mode 100644 index 0000000000..b774e1da07 --- /dev/null +++ b/.changeset/brown-suns-smile.md @@ -0,0 +1,6 @@ +--- +'slate-react': minor +'slate-dom': minor +--- + +Split out slate-dom package diff --git a/config/rollup/rollup.config.js b/config/rollup/rollup.config.js index 0e70a4c3ef..ad11d945f1 100644 --- a/config/rollup/rollup.config.js +++ b/config/rollup/rollup.config.js @@ -12,6 +12,7 @@ import { startCase } from 'lodash' import Core from '../../packages/slate/package.json' import History from '../../packages/slate-history/package.json' import Hyperscript from '../../packages/slate-hyperscript/package.json' +import DOM from '../../packages/slate-dom/package.json' import React from '../../packages/slate-react/package.json' /** @@ -203,5 +204,6 @@ export default [ ...factory(Core), ...factory(History), ...factory(Hyperscript), + ...factory(DOM), ...factory(React), ] diff --git a/packages/slate-dom/package.json b/packages/slate-dom/package.json new file mode 100644 index 0000000000..0cfebf7763 --- /dev/null +++ b/packages/slate-dom/package.json @@ -0,0 +1,64 @@ +{ + "name": "slate-dom", + "description": "Tools for building completely customizable richtext editors with React.", + "version": "0.110.2", + "license": "MIT", + "repository": "git://github.com/ianstormtaylor/slate.git", + "main": "dist/index.js", + "module": "dist/index.es.js", + "types": "dist/index.d.ts", + "umd": "dist/slate-dom.js", + "umdMin": "dist/slate-dom.min.js", + "sideEffects": false, + "files": [ + "dist/" + ], + "dependencies": { + "@juggle/resize-observer": "^3.4.0", + "direction": "^1.0.4", + "is-hotkey": "^0.2.0", + "is-plain-object": "^5.0.0", + "lodash": "^4.17.21", + "scroll-into-view-if-needed": "^3.1.0", + "tiny-invariant": "1.3.1" + }, + "devDependencies": { + "@babel/runtime": "^7.23.2", + "@types/is-hotkey": "^0.1.8", + "@types/jest": "29.5.6", + "@types/jsdom": "^21.1.4", + "@types/lodash": "^4.14.200", + "@types/resize-observer-browser": "^0.1.8", + "slate": "^0.110.2", + "slate-hyperscript": "^0.100.0", + "source-map-loader": "^4.0.1" + }, + "peerDependencies": { + "slate": ">=0.99.0" + }, + "umdGlobals": { + "slate": "Slate" + }, + "keywords": [ + "canvas", + "contenteditable", + "docs", + "document", + "edit", + "editor", + "editable", + "html", + "immutable", + "markdown", + "medium", + "paper", + "react", + "rich", + "richtext", + "richtext", + "slate", + "text", + "wysiwyg", + "wysiwym" + ] +} diff --git a/packages/slate-dom/src/custom-types.ts b/packages/slate-dom/src/custom-types.ts new file mode 100644 index 0000000000..c3cd94773b --- /dev/null +++ b/packages/slate-dom/src/custom-types.ts @@ -0,0 +1,45 @@ +import { BaseRange, BaseText } from 'slate' +import { DOMEditor } from './plugin/dom-editor' + +declare module 'slate' { + interface CustomTypes { + Editor: DOMEditor + Text: BaseText & { + placeholder?: string + onPlaceholderResize?: (node: HTMLElement | null) => void + // FIXME: is unknown correct here? + [key: string]: unknown + } + Range: BaseRange & { + placeholder?: string + onPlaceholderResize?: (node: HTMLElement | null) => void + // FIXME: is unknown correct here? + [key: string]: unknown + } + } +} + +declare global { + interface Window { + MSStream: boolean + } + interface DocumentOrShadowRoot { + getSelection(): Selection | null + } + + interface CaretPosition { + readonly offsetNode: Node + readonly offset: number + getClientRect(): DOMRect | null + } + + interface Document { + caretPositionFromPoint(x: number, y: number): CaretPosition | null + } + + interface Node { + getRootNode(options?: GetRootNodeOptions): Document | ShadowRoot + } +} + +export {} diff --git a/packages/slate-dom/src/index.ts b/packages/slate-dom/src/index.ts new file mode 100644 index 0000000000..4742d7bc4d --- /dev/null +++ b/packages/slate-dom/src/index.ts @@ -0,0 +1,89 @@ +// Plugin +export { DOMEditor, type DOMEditorInterface } from './plugin/dom-editor' +export { withDOM } from './plugin/with-dom' + +// Utils +export { TRIPLE_CLICK } from './utils/constants' + +export { + applyStringDiff, + mergeStringDiffs, + normalizePoint, + normalizeRange, + normalizeStringDiff, + StringDiff, + targetRange, + TextDiff, + verifyDiffState, +} from './utils/diff-text' + +export { + DOMElement, + DOMNode, + DOMPoint, + DOMRange, + DOMSelection, + DOMStaticRange, + DOMText, + getActiveElement, + getDefaultView, + getSelection, + hasShadowRoot, + isAfter, + isBefore, + isDOMElement, + isDOMNode, + isDOMSelection, + isPlainTextOnlyPaste, + isTrackedMutation, + normalizeDOMPoint, +} from './utils/dom' + +export { + CAN_USE_DOM, + HAS_BEFORE_INPUT_SUPPORT, + IS_ANDROID, + IS_CHROME, + IS_FIREFOX, + IS_FIREFOX_LEGACY, + IS_IOS, + IS_WEBKIT, + IS_UC_MOBILE, + IS_WECHATBROWSER, +} from './utils/environment' + +export { default as Hotkeys } from './utils/hotkeys' + +export { Key } from './utils/key' + +export { + isElementDecorationsEqual, + isTextDecorationsEqual, +} from './utils/range-list' + +export { + EDITOR_TO_ELEMENT, + EDITOR_TO_FORCE_RENDER, + EDITOR_TO_KEY_TO_ELEMENT, + EDITOR_TO_ON_CHANGE, + EDITOR_TO_PENDING_ACTION, + EDITOR_TO_PENDING_DIFFS, + EDITOR_TO_PENDING_INSERTION_MARKS, + EDITOR_TO_PENDING_SELECTION, + EDITOR_TO_PLACEHOLDER_ELEMENT, + EDITOR_TO_SCHEDULE_FLUSH, + EDITOR_TO_USER_MARKS, + EDITOR_TO_USER_SELECTION, + EDITOR_TO_WINDOW, + ELEMENT_TO_NODE, + IS_COMPOSING, + IS_FOCUSED, + IS_NODE_MAP_DIRTY, + IS_READ_ONLY, + MARK_PLACEHOLDER_SYMBOL, + NODE_TO_ELEMENT, + NODE_TO_INDEX, + NODE_TO_KEY, + NODE_TO_PARENT, + PLACEHOLDER_SYMBOL, +} from './utils/weak-maps' diff --git a/packages/slate-dom/src/plugin/dom-editor.ts b/packages/slate-dom/src/plugin/dom-editor.ts new file mode 100644 index 0000000000..a44160b285 --- /dev/null +++ b/packages/slate-dom/src/plugin/dom-editor.ts @@ -0,0 +1,1075 @@ +import { + BaseEditor, + Editor, + Element, + Node, + Path, + Point, + Range, + Scrubber, + Transforms, +} from 'slate' +import { TextDiff } from '../utils/diff-text' +import { + DOMElement, + DOMNode, + DOMPoint, + DOMRange, + DOMSelection, + DOMStaticRange, + DOMText, + getSelection, + hasShadowRoot, + isAfter, + isBefore, + isDOMElement, + isDOMNode, + isDOMSelection, + normalizeDOMPoint, +} from '../utils/dom' +import { IS_ANDROID, IS_CHROME, IS_FIREFOX } from '../utils/environment' + +import { Key } from '../utils/key' +import { + EDITOR_TO_ELEMENT, + EDITOR_TO_KEY_TO_ELEMENT, + EDITOR_TO_PENDING_DIFFS, + EDITOR_TO_SCHEDULE_FLUSH, + EDITOR_TO_WINDOW, + ELEMENT_TO_NODE, + IS_COMPOSING, + IS_FOCUSED, + IS_READ_ONLY, + NODE_TO_INDEX, + NODE_TO_KEY, + NODE_TO_PARENT, +} from '../utils/weak-maps' + +/** + * A DOM-specific version of the `Editor` interface. + */ + +export interface DOMEditor extends BaseEditor { + hasEditableTarget: ( + editor: DOMEditor, + target: EventTarget | null + ) => target is DOMNode + hasRange: (editor: DOMEditor, range: Range) => boolean + hasSelectableTarget: ( + editor: DOMEditor, + target: EventTarget | null + ) => boolean + hasTarget: ( + editor: DOMEditor, + target: EventTarget | null + ) => target is DOMNode + insertData: (data: DataTransfer) => void + insertFragmentData: (data: DataTransfer) => boolean + insertTextData: (data: DataTransfer) => boolean + isTargetInsideNonReadonlyVoid: ( + editor: DOMEditor, + target: EventTarget | null + ) => boolean + setFragmentData: ( + data: DataTransfer, + originEvent?: 'drag' | 'copy' | 'cut' + ) => void +} + +export interface DOMEditorInterface { + /** + * Experimental and android specific: Get pending diffs + */ + androidPendingDiffs: (editor: Editor) => TextDiff[] | undefined + + /** + * Experimental and android specific: Flush all pending diffs and cancel composition at the next possible time. + */ + androidScheduleFlush: (editor: Editor) => void + + /** + * Blur the editor. + */ + blur: (editor: DOMEditor) => void + + /** + * Deselect the editor. + */ + deselect: (editor: DOMEditor) => void + + /** + * Find the DOM node that implements DocumentOrShadowRoot for the editor. + */ + findDocumentOrShadowRoot: (editor: DOMEditor) => Document | ShadowRoot + + /** + * Get the target range from a DOM `event`. + */ + findEventRange: (editor: DOMEditor, event: any) => Range + + /** + * Find a key for a Slate node. + */ + findKey: (editor: DOMEditor, node: Node) => Key + + /** + * Find the path of Slate node. + */ + findPath: (editor: DOMEditor, node: Node) => Path + + /** + * Focus the editor. + */ + focus: (editor: DOMEditor, options?: { retries: number }) => void + + /** + * Return the host window of the current editor. + */ + getWindow: (editor: DOMEditor) => Window + + /** + * Check if a DOM node is within the editor. + */ + hasDOMNode: ( + editor: DOMEditor, + target: DOMNode, + options?: { editable?: boolean } + ) => boolean + + /** + * Check if the target is editable and in the editor. + */ + hasEditableTarget: ( + editor: DOMEditor, + target: EventTarget | null + ) => target is DOMNode + + /** + * + */ + hasRange: (editor: DOMEditor, range: Range) => boolean + + /** + * Check if the target can be selectable + */ + hasSelectableTarget: ( + editor: DOMEditor, + target: EventTarget | null + ) => boolean + + /** + * Check if the target is in the editor. + */ + hasTarget: ( + editor: DOMEditor, + target: EventTarget | null + ) => target is DOMNode + + /** + * Insert data from a `DataTransfer` into the editor. + */ + insertData: (editor: DOMEditor, data: DataTransfer) => void + + /** + * Insert fragment data from a `DataTransfer` into the editor. + */ + insertFragmentData: (editor: DOMEditor, data: DataTransfer) => boolean + + /** + * Insert text data from a `DataTransfer` into the editor. + */ + insertTextData: (editor: DOMEditor, data: DataTransfer) => boolean + + /** + * Check if the user is currently composing inside the editor. + */ + isComposing: (editor: DOMEditor) => boolean + + /** + * Check if the editor is focused. + */ + isFocused: (editor: DOMEditor) => boolean + + /** + * Check if the editor is in read-only mode. + */ + isReadOnly: (editor: DOMEditor) => boolean + + /** + * Check if the target is inside void and in an non-readonly editor. + */ + isTargetInsideNonReadonlyVoid: ( + editor: DOMEditor, + target: EventTarget | null + ) => boolean + + /** + * Sets data from the currently selected fragment on a `DataTransfer`. + */ + setFragmentData: ( + editor: DOMEditor, + data: DataTransfer, + originEvent?: 'drag' | 'copy' | 'cut' + ) => void + + /** + * Find the native DOM element from a Slate node. + */ + toDOMNode: (editor: DOMEditor, node: Node) => HTMLElement + + /** + * Find a native DOM selection point from a Slate point. + */ + toDOMPoint: (editor: DOMEditor, point: Point) => DOMPoint + + /** + * Find a native DOM range from a Slate `range`. + * + * Notice: the returned range will always be ordinal regardless of the direction of Slate `range` due to DOM API limit. + * + * there is no way to create a reverse DOM Range using Range.setStart/setEnd + * according to https://dom.spec.whatwg.org/#concept-range-bp-set. + */ + toDOMRange: (editor: DOMEditor, range: Range) => DOMRange + + /** + * Find a Slate node from a native DOM `element`. + */ + toSlateNode: (editor: DOMEditor, domNode: DOMNode) => Node + + /** + * Find a Slate point from a DOM selection's `domNode` and `domOffset`. + */ + toSlatePoint: ( + editor: DOMEditor, + domPoint: DOMPoint, + options: { + exactMatch: boolean + suppressThrow: T + /** + * The direction to search for Slate leaf nodes if `domPoint` is + * non-editable and non-void. + */ + searchDirection?: 'forward' | 'backward' + } + ) => T extends true ? Point | null : Point + + /** + * Find a Slate range from a DOM range or selection. + */ + toSlateRange: ( + editor: DOMEditor, + domRange: DOMRange | DOMStaticRange | DOMSelection, + options: { + exactMatch: boolean + suppressThrow: T + } + ) => T extends true ? Range | null : Range +} + +// eslint-disable-next-line no-redeclare +export const DOMEditor: DOMEditorInterface = { + androidPendingDiffs: editor => EDITOR_TO_PENDING_DIFFS.get(editor), + + androidScheduleFlush: editor => { + EDITOR_TO_SCHEDULE_FLUSH.get(editor)?.() + }, + + blur: editor => { + const el = DOMEditor.toDOMNode(editor, editor) + const root = DOMEditor.findDocumentOrShadowRoot(editor) + IS_FOCUSED.set(editor, false) + + if (root.activeElement === el) { + el.blur() + } + }, + + deselect: editor => { + const { selection } = editor + const root = DOMEditor.findDocumentOrShadowRoot(editor) + const domSelection = getSelection(root) + + if (domSelection && domSelection.rangeCount > 0) { + domSelection.removeAllRanges() + } + + if (selection) { + Transforms.deselect(editor) + } + }, + + findDocumentOrShadowRoot: editor => { + const el = DOMEditor.toDOMNode(editor, editor) + const root = el.getRootNode() + + if (root instanceof Document || root instanceof ShadowRoot) { + return root + } + + return el.ownerDocument + }, + + findEventRange: (editor, event) => { + if ('nativeEvent' in event) { + event = event.nativeEvent + } + + const { clientX: x, clientY: y, target } = event + + if (x == null || y == null) { + throw new Error(`Cannot resolve a Slate range from a DOM event: ${event}`) + } + + const node = DOMEditor.toSlateNode(editor, event.target) + const path = DOMEditor.findPath(editor, node) + + // If the drop target is inside a void node, move it into either the + // next or previous node, depending on which side the `x` and `y` + // coordinates are closest to. + if (Element.isElement(node) && Editor.isVoid(editor, node)) { + const rect = target.getBoundingClientRect() + const isPrev = editor.isInline(node) + ? x - rect.left < rect.left + rect.width - x + : y - rect.top < rect.top + rect.height - y + + const edge = Editor.point(editor, path, { + edge: isPrev ? 'start' : 'end', + }) + const point = isPrev + ? Editor.before(editor, edge) + : Editor.after(editor, edge) + + if (point) { + const range = Editor.range(editor, point) + return range + } + } + + // Else resolve a range from the caret position where the drop occured. + let domRange + const { document } = DOMEditor.getWindow(editor) + + // COMPAT: In Firefox, `caretRangeFromPoint` doesn't exist. (2016/07/25) + if (document.caretRangeFromPoint) { + domRange = document.caretRangeFromPoint(x, y) + } else { + const position = document.caretPositionFromPoint(x, y) + + if (position) { + domRange = document.createRange() + domRange.setStart(position.offsetNode, position.offset) + domRange.setEnd(position.offsetNode, position.offset) + } + } + + if (!domRange) { + throw new Error(`Cannot resolve a Slate range from a DOM event: ${event}`) + } + + // Resolve a Slate range from the DOM range. + const range = DOMEditor.toSlateRange(editor, domRange, { + exactMatch: false, + suppressThrow: false, + }) + return range + }, + + findKey: (editor, node) => { + let key = NODE_TO_KEY.get(node) + + if (!key) { + key = new Key() + NODE_TO_KEY.set(node, key) + } + + return key + }, + + findPath: (editor, node) => { + const path: Path = [] + let child = node + + while (true) { + const parent = NODE_TO_PARENT.get(child) + + if (parent == null) { + if (Editor.isEditor(child)) { + return path + } else { + break + } + } + + const i = NODE_TO_INDEX.get(child) + + if (i == null) { + break + } + + path.unshift(i) + child = parent + } + + throw new Error( + `Unable to find the path for Slate node: ${Scrubber.stringify(node)}` + ) + }, + + focus: (editor, options = { retries: 5 }) => { + // Return if already focused + if (IS_FOCUSED.get(editor)) { + return + } + + // Retry setting focus if the editor has pending operations. + // The DOM (selection) is unstable while changes are applied. + // Retry until retries are exhausted or editor is focused. + if (options.retries <= 0) { + throw new Error( + 'Could not set focus, editor seems stuck with pending operations' + ) + } + if (editor.operations.length > 0) { + setTimeout(() => { + DOMEditor.focus(editor, { retries: options.retries - 1 }) + }, 10) + return + } + + const el = DOMEditor.toDOMNode(editor, editor) + const root = DOMEditor.findDocumentOrShadowRoot(editor) + if (root.activeElement !== el) { + // Ensure that the DOM selection state is set to the editor's selection + if (editor.selection && root instanceof Document) { + const domSelection = getSelection(root) + const domRange = DOMEditor.toDOMRange(editor, editor.selection) + domSelection?.removeAllRanges() + domSelection?.addRange(domRange) + } + // Create a new selection in the top of the document if missing + if (!editor.selection) { + Transforms.select(editor, Editor.start(editor, [])) + } + // IS_FOCUSED should be set before calling el.focus() to ensure that + // FocusedContext is updated to the correct value + IS_FOCUSED.set(editor, true) + el.focus({ preventScroll: true }) + } + }, + + getWindow: editor => { + const window = EDITOR_TO_WINDOW.get(editor) + if (!window) { + throw new Error('Unable to find a host window element for this editor') + } + return window + }, + + hasDOMNode: (editor, target, options = {}) => { + const { editable = false } = options + const editorEl = DOMEditor.toDOMNode(editor, editor) + let targetEl + + // COMPAT: In Firefox, reading `target.nodeType` will throw an error if + // target is originating from an internal "restricted" element (e.g. a + // stepper arrow on a number input). (2018/05/04) + // https://github.com/ianstormtaylor/slate/issues/1819 + try { + targetEl = ( + isDOMElement(target) ? target : target.parentElement + ) as HTMLElement + } catch (err) { + if ( + err instanceof Error && + !err.message.includes('Permission denied to access property "nodeType"') + ) { + throw err + } + } + + if (!targetEl) { + return false + } + + return ( + targetEl.closest(`[data-slate-editor]`) === editorEl && + (!editable || targetEl.isContentEditable + ? true + : (typeof targetEl.isContentEditable === 'boolean' && // isContentEditable exists only on HTMLElement, and on other nodes it will be undefined + // this is the core logic that lets you know you got the right editor.selection instead of null when editor is contenteditable="false"(readOnly) + targetEl.closest('[contenteditable="false"]') === editorEl) || + !!targetEl.getAttribute('data-slate-zero-width')) + ) + }, + + hasEditableTarget: (editor, target): target is DOMNode => + isDOMNode(target) && + DOMEditor.hasDOMNode(editor, target, { editable: true }), + + hasRange: (editor, range) => { + const { anchor, focus } = range + return ( + Editor.hasPath(editor, anchor.path) && Editor.hasPath(editor, focus.path) + ) + }, + + hasSelectableTarget: (editor, target) => + DOMEditor.hasEditableTarget(editor, target) || + DOMEditor.isTargetInsideNonReadonlyVoid(editor, target), + + hasTarget: (editor, target): target is DOMNode => + isDOMNode(target) && DOMEditor.hasDOMNode(editor, target), + + insertData: (editor, data) => { + editor.insertData(data) + }, + + insertFragmentData: (editor, data) => editor.insertFragmentData(data), + + insertTextData: (editor, data) => editor.insertTextData(data), + + isComposing: editor => { + return !!IS_COMPOSING.get(editor) + }, + + isFocused: editor => !!IS_FOCUSED.get(editor), + + isReadOnly: editor => !!IS_READ_ONLY.get(editor), + + isTargetInsideNonReadonlyVoid: (editor, target) => { + if (IS_READ_ONLY.get(editor)) return false + + const slateNode = + DOMEditor.hasTarget(editor, target) && + DOMEditor.toSlateNode(editor, target) + return Element.isElement(slateNode) && Editor.isVoid(editor, slateNode) + }, + + setFragmentData: (editor, data, originEvent) => + editor.setFragmentData(data, originEvent), + + toDOMNode: (editor, node) => { + const KEY_TO_ELEMENT = EDITOR_TO_KEY_TO_ELEMENT.get(editor) + const domNode = Editor.isEditor(node) + ? EDITOR_TO_ELEMENT.get(editor) + : KEY_TO_ELEMENT?.get(DOMEditor.findKey(editor, node)) + + if (!domNode) { + throw new Error( + `Cannot resolve a DOM node from Slate node: ${Scrubber.stringify(node)}` + ) + } + + return domNode + }, + + toDOMPoint: (editor, point) => { + const [node] = Editor.node(editor, point.path) + const el = DOMEditor.toDOMNode(editor, node) + let domPoint: DOMPoint | undefined + + // If we're inside a void node, force the offset to 0, otherwise the zero + // width spacing character will result in an incorrect offset of 1 + if (Editor.void(editor, { at: point })) { + point = { path: point.path, offset: 0 } + } + + // For each leaf, we need to isolate its content, which means filtering + // to its direct text and zero-width spans. (We have to filter out any + // other siblings that may have been rendered alongside them.) + const selector = `[data-slate-string], [data-slate-zero-width]` + const texts = Array.from(el.querySelectorAll(selector)) + let start = 0 + + for (let i = 0; i < texts.length; i++) { + const text = texts[i] + const domNode = text.childNodes[0] as HTMLElement + + if (domNode == null || domNode.textContent == null) { + continue + } + + const { length } = domNode.textContent + const attr = text.getAttribute('data-slate-length') + const trueLength = attr == null ? length : parseInt(attr, 10) + const end = start + trueLength + + // Prefer putting the selection inside the mark placeholder to ensure + // composed text is displayed with the correct marks. + const nextText = texts[i + 1] + if ( + point.offset === end && + nextText?.hasAttribute('data-slate-mark-placeholder') + ) { + const domText = nextText.childNodes[0] + + domPoint = [ + // COMPAT: If we don't explicity set the dom point to be on the actual + // dom text element, chrome will put the selection behind the actual dom + // text element, causing domRange.getBoundingClientRect() calls on a collapsed + // selection to return incorrect zero values (https://bugs.chromium.org/p/chromium/issues/detail?id=435438) + // which will cause issues when scrolling to it. + domText instanceof DOMText ? domText : nextText, + nextText.textContent?.startsWith('\uFEFF') ? 1 : 0, + ] + break + } + + if (point.offset <= end) { + const offset = Math.min(length, Math.max(0, point.offset - start)) + domPoint = [domNode, offset] + break + } + + start = end + } + + if (!domPoint) { + throw new Error( + `Cannot resolve a DOM point from Slate point: ${Scrubber.stringify( + point + )}` + ) + } + + return domPoint + }, + + toDOMRange: (editor, range) => { + const { anchor, focus } = range + const isBackward = Range.isBackward(range) + const domAnchor = DOMEditor.toDOMPoint(editor, anchor) + const domFocus = Range.isCollapsed(range) + ? domAnchor + : DOMEditor.toDOMPoint(editor, focus) + + const window = DOMEditor.getWindow(editor) + const domRange = window.document.createRange() + const [startNode, startOffset] = isBackward ? domFocus : domAnchor + const [endNode, endOffset] = isBackward ? domAnchor : domFocus + + // A slate Point at zero-width Leaf always has an offset of 0 but a native DOM selection at + // zero-width node has an offset of 1 so we have to check if we are in a zero-width node and + // adjust the offset accordingly. + const startEl = ( + isDOMElement(startNode) ? startNode : startNode.parentElement + ) as HTMLElement + const isStartAtZeroWidth = !!startEl.getAttribute('data-slate-zero-width') + const endEl = ( + isDOMElement(endNode) ? endNode : endNode.parentElement + ) as HTMLElement + const isEndAtZeroWidth = !!endEl.getAttribute('data-slate-zero-width') + + domRange.setStart(startNode, isStartAtZeroWidth ? 1 : startOffset) + domRange.setEnd(endNode, isEndAtZeroWidth ? 1 : endOffset) + return domRange + }, + + toSlateNode: (editor, domNode) => { + let domEl = isDOMElement(domNode) ? domNode : domNode.parentElement + + if (domEl && !domEl.hasAttribute('data-slate-node')) { + domEl = domEl.closest(`[data-slate-node]`) + } + + const node = domEl ? ELEMENT_TO_NODE.get(domEl as HTMLElement) : null + + if (!node) { + throw new Error(`Cannot resolve a Slate node from DOM node: ${domEl}`) + } + + return node + }, + + toSlatePoint: ( + editor: DOMEditor, + domPoint: DOMPoint, + options: { + exactMatch: boolean + suppressThrow: T + searchDirection?: 'forward' | 'backward' + } + ): T extends true ? Point | null : Point => { + const { exactMatch, suppressThrow, searchDirection = 'backward' } = options + const [nearestNode, nearestOffset] = exactMatch + ? domPoint + : normalizeDOMPoint(domPoint) + const parentNode = nearestNode.parentNode as DOMElement + let textNode: DOMElement | null = null + let offset = 0 + + if (parentNode) { + const editorEl = DOMEditor.toDOMNode(editor, editor) + const potentialVoidNode = parentNode.closest('[data-slate-void="true"]') + // Need to ensure that the closest void node is actually a void node + // within this editor, and not a void node within some parent editor. This can happen + // if this editor is within a void node of another editor ("nested editors", like in + // the "Editable Voids" example on the docs site). + const voidNode = + potentialVoidNode && editorEl.contains(potentialVoidNode) + ? potentialVoidNode + : null + const potentialNonEditableNode = parentNode.closest( + '[contenteditable="false"]' + ) + const nonEditableNode = + potentialNonEditableNode && editorEl.contains(potentialNonEditableNode) + ? potentialNonEditableNode + : null + let leafNode = parentNode.closest('[data-slate-leaf]') + let domNode: DOMElement | null = null + + // Calculate how far into the text node the `nearestNode` is, so that we + // can determine what the offset relative to the text node is. + if (leafNode) { + textNode = leafNode.closest('[data-slate-node="text"]') + + if (textNode) { + const window = DOMEditor.getWindow(editor) + const range = window.document.createRange() + range.setStart(textNode, 0) + range.setEnd(nearestNode, nearestOffset) + + const contents = range.cloneContents() + const removals = [ + ...Array.prototype.slice.call( + contents.querySelectorAll('[data-slate-zero-width]') + ), + ...Array.prototype.slice.call( + contents.querySelectorAll('[contenteditable=false]') + ), + ] + + removals.forEach(el => { + // COMPAT: While composing at the start of a text node, some keyboards put + // the text content inside the zero width space. + if ( + IS_ANDROID && + !exactMatch && + el.hasAttribute('data-slate-zero-width') && + el.textContent.length > 0 && + el.textContext !== '\uFEFF' + ) { + if (el.textContent.startsWith('\uFEFF')) { + el.textContent = el.textContent.slice(1) + } + + return + } + + el!.parentNode!.removeChild(el) + }) + + // COMPAT: Edge has a bug where Range.prototype.toString() will + // convert \n into \r\n. The bug causes a loop when slate-dom + // attempts to reposition its cursor to match the native position. Use + // textContent.length instead. + // https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/10291116/ + offset = contents.textContent!.length + domNode = textNode + } + } else if (voidNode) { + // For void nodes, the element with the offset key will be a cousin, not an + // ancestor, so find it by going down from the nearest void parent and taking the + // first one that isn't inside a nested editor. + const leafNodes = voidNode.querySelectorAll('[data-slate-leaf]') + for (let index = 0; index < leafNodes.length; index++) { + const current = leafNodes[index] + if (DOMEditor.hasDOMNode(editor, current)) { + leafNode = current + break + } + } + + // COMPAT: In read-only editors the leaf is not rendered. + if (!leafNode) { + offset = 1 + } else { + textNode = leafNode.closest('[data-slate-node="text"]')! + domNode = leafNode + offset = domNode.textContent!.length + domNode.querySelectorAll('[data-slate-zero-width]').forEach(el => { + offset -= el.textContent!.length + }) + } + } else if (nonEditableNode) { + // Find the edge of the nearest leaf in `searchDirection` + const getLeafNodes = (node: DOMElement | null | undefined) => + node + ? node.querySelectorAll( + // Exclude leaf nodes in nested editors + '[data-slate-leaf]:not(:scope [data-slate-editor] [data-slate-leaf])' + ) + : [] + const elementNode = nonEditableNode.closest( + '[data-slate-node="element"]' + ) + + if (searchDirection === 'forward') { + const leafNodes = [ + ...getLeafNodes(elementNode), + ...getLeafNodes(elementNode?.nextElementSibling), + ] + leafNode = + leafNodes.find(leaf => isAfter(nonEditableNode, leaf)) ?? null + } else { + const leafNodes = [ + ...getLeafNodes(elementNode?.previousElementSibling), + ...getLeafNodes(elementNode), + ] + leafNode = + leafNodes.findLast(leaf => isBefore(nonEditableNode, leaf)) ?? null + } + + if (leafNode) { + textNode = leafNode.closest('[data-slate-node="text"]')! + domNode = leafNode + if (searchDirection === 'forward') { + offset = 0 + } else { + offset = domNode.textContent!.length + domNode.querySelectorAll('[data-slate-zero-width]').forEach(el => { + offset -= el.textContent!.length + }) + } + } + } + + if ( + domNode && + offset === domNode.textContent!.length && + // COMPAT: Android IMEs might remove the zero width space while composing, + // and we don't add it for line-breaks. + IS_ANDROID && + domNode.getAttribute('data-slate-zero-width') === 'z' && + domNode.textContent?.startsWith('\uFEFF') && + // COMPAT: If the parent node is a Slate zero-width space, editor is + // because the text node should have no characters. However, during IME + // composition the ASCII characters will be prepended to the zero-width + // space, so subtract 1 from the offset to account for the zero-width + // space character. + (parentNode.hasAttribute('data-slate-zero-width') || + // COMPAT: In Firefox, `range.cloneContents()` returns an extra trailing '\n' + // when the document ends with a new-line character. This results in the offset + // length being off by one, so we need to subtract one to account for this. + (IS_FIREFOX && domNode.textContent?.endsWith('\n\n'))) + ) { + offset-- + } + } + + if (IS_ANDROID && !textNode && !exactMatch) { + const node = parentNode.hasAttribute('data-slate-node') + ? parentNode + : parentNode.closest('[data-slate-node]') + + if (node && DOMEditor.hasDOMNode(editor, node, { editable: true })) { + const slateNode = DOMEditor.toSlateNode(editor, node) + let { path, offset } = Editor.start( + editor, + DOMEditor.findPath(editor, slateNode) + ) + + if (!node.querySelector('[data-slate-leaf]')) { + offset = nearestOffset + } + + return { path, offset } as T extends true ? Point | null : Point + } + } + + if (!textNode) { + if (suppressThrow) { + return null as T extends true ? Point | null : Point + } + throw new Error( + `Cannot resolve a Slate point from DOM point: ${domPoint}` + ) + } + + // COMPAT: If someone is clicking from one Slate editor into another, + // the select event fires twice, once for the old editor's `element` + // first, and then afterwards for the correct `element`. (2017/03/03) + const slateNode = DOMEditor.toSlateNode(editor, textNode!) + const path = DOMEditor.findPath(editor, slateNode) + return { path, offset } as T extends true ? Point | null : Point + }, + + toSlateRange: ( + editor: DOMEditor, + domRange: DOMRange | DOMStaticRange | DOMSelection, + options: { + exactMatch: boolean + suppressThrow: T + } + ): T extends true ? Range | null : Range => { + const { exactMatch, suppressThrow } = options + const el = isDOMSelection(domRange) + ? domRange.anchorNode + : domRange.startContainer + let anchorNode + let anchorOffset + let focusNode + let focusOffset + let isCollapsed + + if (el) { + if (isDOMSelection(domRange)) { + // COMPAT: In firefox the normal seletion way does not work + // (https://github.com/ianstormtaylor/slate/pull/5486#issue-1820720223) + if (IS_FIREFOX && domRange.rangeCount > 1) { + focusNode = domRange.focusNode // Focus node works fine + const firstRange = domRange.getRangeAt(0) + const lastRange = domRange.getRangeAt(domRange.rangeCount - 1) + + // Here we are in the contenteditable mode of a table in firefox + if ( + focusNode instanceof HTMLTableRowElement && + firstRange.startContainer instanceof HTMLTableRowElement && + lastRange.startContainer instanceof HTMLTableRowElement + ) { + // HTMLElement, becouse Element is a slate element + function getLastChildren(element: HTMLElement): HTMLElement { + if (element.childElementCount > 0) { + return getLastChildren(element.children[0]) + } else { + return element + } + } + + const firstNodeRow = firstRange.startContainer + const lastNodeRow = lastRange.startContainer + + // This should never fail as "The HTMLElement interface represents any HTML element." + const firstNode = getLastChildren( + firstNodeRow.children[firstRange.startOffset] + ) + const lastNode = getLastChildren( + lastNodeRow.children[lastRange.startOffset] + ) + + // Zero, as we allways take the right one as the anchor point + focusOffset = 0 + + if (lastNode.childNodes.length > 0) { + anchorNode = lastNode.childNodes[0] + } else { + anchorNode = lastNode + } + + if (firstNode.childNodes.length > 0) { + focusNode = firstNode.childNodes[0] + } else { + focusNode = firstNode + } + + if (lastNode instanceof HTMLElement) { + anchorOffset = (lastNode).innerHTML.length + } else { + // Fallback option + anchorOffset = 0 + } + } else { + // This is the read only mode of a firefox table + // Right to left + if (firstRange.startContainer === focusNode) { + anchorNode = lastRange.endContainer + anchorOffset = lastRange.endOffset + focusOffset = firstRange.startOffset + } else { + // Left to right + anchorNode = firstRange.startContainer + anchorOffset = firstRange.endOffset + focusOffset = lastRange.startOffset + } + } + } else { + anchorNode = domRange.anchorNode + anchorOffset = domRange.anchorOffset + focusNode = domRange.focusNode + focusOffset = domRange.focusOffset + } + + // COMPAT: There's a bug in chrome that always returns `true` for + // `isCollapsed` for a Selection that comes from a ShadowRoot. + // (2020/08/08) + // https://bugs.chromium.org/p/chromium/issues/detail?id=447523 + // IsCollapsed might not work in firefox, but this will + if ((IS_CHROME && hasShadowRoot(anchorNode)) || IS_FIREFOX) { + isCollapsed = + domRange.anchorNode === domRange.focusNode && + domRange.anchorOffset === domRange.focusOffset + } else { + isCollapsed = domRange.isCollapsed + } + } else { + anchorNode = domRange.startContainer + anchorOffset = domRange.startOffset + focusNode = domRange.endContainer + focusOffset = domRange.endOffset + isCollapsed = domRange.collapsed + } + } + + if ( + anchorNode == null || + focusNode == null || + anchorOffset == null || + focusOffset == null + ) { + throw new Error( + `Cannot resolve a Slate range from DOM range: ${domRange}` + ) + } + + // COMPAT: Firefox sometimes includes an extra \n (rendered by TextString + // when isTrailing is true) in the focusOffset, resulting in an invalid + // Slate point. (2023/11/01) + if ( + IS_FIREFOX && + focusNode.textContent?.endsWith('\n\n') && + focusOffset === focusNode.textContent.length + ) { + focusOffset-- + } + + const anchor = DOMEditor.toSlatePoint(editor, [anchorNode, anchorOffset], { + exactMatch, + suppressThrow, + }) + if (!anchor) { + return null as T extends true ? Range | null : Range + } + + const focusBeforeAnchor = + isBefore(anchorNode, focusNode) || + (anchorNode === focusNode && focusOffset < anchorOffset) + const focus = isCollapsed + ? anchor + : DOMEditor.toSlatePoint(editor, [focusNode, focusOffset], { + exactMatch, + suppressThrow, + searchDirection: focusBeforeAnchor ? 'forward' : 'backward', + }) + if (!focus) { + return null as T extends true ? Range | null : Range + } + + let range: Range = { anchor: anchor as Point, focus: focus as Point } + // if the selection is a hanging range that ends in a void + // and the DOM focus is an Element + // (meaning that the selection ends before the element) + // unhang the range to avoid mistakenly including the void + if ( + Range.isExpanded(range) && + Range.isForward(range) && + isDOMElement(focusNode) && + Editor.void(editor, { at: range.focus, mode: 'highest' }) + ) { + range = Editor.unhangRange(editor, range, { voids: true }) + } + + return range as unknown as T extends true ? Range | null : Range + }, +} diff --git a/packages/slate-dom/src/plugin/with-dom.ts b/packages/slate-dom/src/plugin/with-dom.ts new file mode 100644 index 0000000000..bf6efb7409 --- /dev/null +++ b/packages/slate-dom/src/plugin/with-dom.ts @@ -0,0 +1,382 @@ +import { + BaseEditor, + Editor, + Element, + Node, + Operation, + Path, + PathRef, + Point, + Range, + Transforms, +} from 'slate' +import { + TextDiff, + transformPendingPoint, + transformPendingRange, + transformTextDiff, +} from '../utils/diff-text' +import { + getPlainText, + getSlateFragmentAttribute, + isDOMText, +} from '../utils/dom' +import { Key } from '../utils/key' +import { findCurrentLineRange } from '../utils/lines' +import { + IS_NODE_MAP_DIRTY, + EDITOR_TO_KEY_TO_ELEMENT, + EDITOR_TO_ON_CHANGE, + EDITOR_TO_PENDING_ACTION, + EDITOR_TO_PENDING_DIFFS, + EDITOR_TO_PENDING_INSERTION_MARKS, + EDITOR_TO_PENDING_SELECTION, + EDITOR_TO_SCHEDULE_FLUSH, + EDITOR_TO_USER_MARKS, + EDITOR_TO_USER_SELECTION, + NODE_TO_KEY, +} from '../utils/weak-maps' +import { DOMEditor } from './dom-editor' + +/** + * `withDOM` adds DOM specific behaviors to the editor. + * + * If you are using TypeScript, you must extend Slate's CustomTypes to use + * this plugin. + * + * See https://docs.slatejs.org/concepts/11-typescript to learn how. + */ + +export const withDOM = ( + editor: T, + clipboardFormatKey = 'x-slate-fragment' +): T & DOMEditor => { + const e = editor as T & DOMEditor + const { apply, onChange, deleteBackward, addMark, removeMark } = e + + // The WeakMap which maps a key to a specific HTMLElement must be scoped to the editor instance to + // avoid collisions between editors in the DOM that share the same value. + EDITOR_TO_KEY_TO_ELEMENT.set(e, new WeakMap()) + + e.addMark = (key, value) => { + EDITOR_TO_SCHEDULE_FLUSH.get(e)?.() + + if ( + !EDITOR_TO_PENDING_INSERTION_MARKS.get(e) && + EDITOR_TO_PENDING_DIFFS.get(e)?.length + ) { + // Ensure the current pending diffs originating from changes before the addMark + // are applied with the current formatting + EDITOR_TO_PENDING_INSERTION_MARKS.set(e, null) + } + + EDITOR_TO_USER_MARKS.delete(e) + + addMark(key, value) + } + + e.removeMark = key => { + if ( + !EDITOR_TO_PENDING_INSERTION_MARKS.get(e) && + EDITOR_TO_PENDING_DIFFS.get(e)?.length + ) { + // Ensure the current pending diffs originating from changes before the addMark + // are applied with the current formatting + EDITOR_TO_PENDING_INSERTION_MARKS.set(e, null) + } + + EDITOR_TO_USER_MARKS.delete(e) + + removeMark(key) + } + + e.deleteBackward = unit => { + if (unit !== 'line') { + return deleteBackward(unit) + } + + if (e.selection && Range.isCollapsed(e.selection)) { + const parentBlockEntry = Editor.above(e, { + match: n => Element.isElement(n) && Editor.isBlock(e, n), + at: e.selection, + }) + + if (parentBlockEntry) { + const [, parentBlockPath] = parentBlockEntry + const parentElementRange = Editor.range( + e, + parentBlockPath, + e.selection.anchor + ) + + const currentLineRange = findCurrentLineRange(e, parentElementRange) + + if (!Range.isCollapsed(currentLineRange)) { + Transforms.delete(e, { at: currentLineRange }) + } + } + } + } + + // This attempts to reset the NODE_TO_KEY entry to the correct value + // as apply() changes the object reference and hence invalidates the NODE_TO_KEY entry + e.apply = (op: Operation) => { + const matches: [Path, Key][] = [] + const pathRefMatches: [PathRef, Key][] = [] + + const pendingDiffs = EDITOR_TO_PENDING_DIFFS.get(e) + if (pendingDiffs?.length) { + const transformed = pendingDiffs + .map(textDiff => transformTextDiff(textDiff, op)) + .filter(Boolean) as TextDiff[] + + EDITOR_TO_PENDING_DIFFS.set(e, transformed) + } + + const pendingSelection = EDITOR_TO_PENDING_SELECTION.get(e) + if (pendingSelection) { + EDITOR_TO_PENDING_SELECTION.set( + e, + transformPendingRange(e, pendingSelection, op) + ) + } + + const pendingAction = EDITOR_TO_PENDING_ACTION.get(e) + if (pendingAction?.at) { + const at = Point.isPoint(pendingAction?.at) + ? transformPendingPoint(e, pendingAction.at, op) + : transformPendingRange(e, pendingAction.at, op) + + EDITOR_TO_PENDING_ACTION.set(e, at ? { ...pendingAction, at } : null) + } + + switch (op.type) { + case 'insert_text': + case 'remove_text': + case 'set_node': + case 'split_node': { + matches.push(...getMatches(e, op.path)) + break + } + + case 'set_selection': { + // Selection was manually set, don't restore the user selection after the change. + EDITOR_TO_USER_SELECTION.get(e)?.unref() + EDITOR_TO_USER_SELECTION.delete(e) + break + } + + case 'insert_node': + case 'remove_node': { + matches.push(...getMatches(e, Path.parent(op.path))) + break + } + + case 'merge_node': { + const prevPath = Path.previous(op.path) + matches.push(...getMatches(e, prevPath)) + break + } + + case 'move_node': { + const commonPath = Path.common( + Path.parent(op.path), + Path.parent(op.newPath) + ) + matches.push(...getMatches(e, commonPath)) + + let changedPath: Path + if (Path.isBefore(op.path, op.newPath)) { + matches.push(...getMatches(e, Path.parent(op.path))) + changedPath = op.newPath + } else { + matches.push(...getMatches(e, Path.parent(op.newPath))) + changedPath = op.path + } + + const changedNode = Node.get(editor, Path.parent(changedPath)) + const changedNodeKey = DOMEditor.findKey(e, changedNode) + const changedPathRef = Editor.pathRef(e, Path.parent(changedPath)) + pathRefMatches.push([changedPathRef, changedNodeKey]) + + break + } + } + + apply(op) + + switch (op.type) { + case 'insert_node': + case 'remove_node': + case 'merge_node': + case 'move_node': + case 'split_node': { + IS_NODE_MAP_DIRTY.set(e, true) + } + } + + for (const [path, key] of matches) { + const [node] = Editor.node(e, path) + NODE_TO_KEY.set(node, key) + } + + for (const [pathRef, key] of pathRefMatches) { + if (pathRef.current) { + const [node] = Editor.node(e, pathRef.current) + NODE_TO_KEY.set(node, key) + } + + pathRef.unref() + } + } + + e.setFragmentData = (data: Pick) => { + const { selection } = e + + if (!selection) { + return + } + + const [start, end] = Range.edges(selection) + const startVoid = Editor.void(e, { at: start.path }) + const endVoid = Editor.void(e, { at: end.path }) + + if (Range.isCollapsed(selection) && !startVoid) { + return + } + + // Create a fake selection so that we can add a Base64-encoded copy of the + // fragment to the HTML, to decode on future pastes. + const domRange = DOMEditor.toDOMRange(e, selection) + let contents = domRange.cloneContents() + let attach = contents.childNodes[0] as HTMLElement + + // Make sure attach is non-empty, since empty nodes will not get copied. + contents.childNodes.forEach(node => { + if (node.textContent && node.textContent.trim() !== '') { + attach = node as HTMLElement + } + }) + + // COMPAT: If the end node is a void node, we need to move the end of the + // range from the void node's spacer span, to the end of the void node's + // content, since the spacer is before void's content in the DOM. + if (endVoid) { + const [voidNode] = endVoid + const r = domRange.cloneRange() + const domNode = DOMEditor.toDOMNode(e, voidNode) + r.setEndAfter(domNode) + contents = r.cloneContents() + } + + // COMPAT: If the start node is a void node, we need to attach the encoded + // fragment to the void node's content node instead of the spacer, because + // attaching it to empty `
/` nodes will end up having it erased by + // most browsers. (2018/04/27) + if (startVoid) { + attach = contents.querySelector('[data-slate-spacer]')! as HTMLElement + } + + // Remove any zero-width space spans from the cloned DOM so that they don't + // show up elsewhere when pasted. + Array.from(contents.querySelectorAll('[data-slate-zero-width]')).forEach( + zw => { + const isNewline = zw.getAttribute('data-slate-zero-width') === 'n' + zw.textContent = isNewline ? '\n' : '' + } + ) + + // Set a `data-slate-fragment` attribute on a non-empty node, so it shows up + // in the HTML, and can be used for intra-Slate pasting. If it's a text + // node, wrap it in a `` so we have something to set an attribute on. + if (isDOMText(attach)) { + const span = attach.ownerDocument.createElement('span') + // COMPAT: In Chrome and Safari, if we don't add the `white-space` style + // then leading and trailing spaces will be ignored. (2017/09/21) + span.style.whiteSpace = 'pre' + span.appendChild(attach) + contents.appendChild(span) + attach = span + } + + const fragment = e.getFragment() + const string = JSON.stringify(fragment) + const encoded = window.btoa(encodeURIComponent(string)) + attach.setAttribute('data-slate-fragment', encoded) + data.setData(`application/${clipboardFormatKey}`, encoded) + + // Add the content to a
so that we can get its inner HTML. + const div = contents.ownerDocument.createElement('div') + div.appendChild(contents) + div.setAttribute('hidden', 'true') + contents.ownerDocument.body.appendChild(div) + data.setData('text/html', div.innerHTML) + data.setData('text/plain', getPlainText(div)) + contents.ownerDocument.body.removeChild(div) + return data + } + + e.insertData = (data: DataTransfer) => { + if (!e.insertFragmentData(data)) { + e.insertTextData(data) + } + } + + e.insertFragmentData = (data: DataTransfer): boolean => { + /** + * Checking copied fragment from application/x-slate-fragment or data-slate-fragment + */ + const fragment = + data.getData(`application/${clipboardFormatKey}`) || + getSlateFragmentAttribute(data) + + if (fragment) { + const decoded = decodeURIComponent(window.atob(fragment)) + const parsed = JSON.parse(decoded) as Node[] + e.insertFragment(parsed) + return true + } + return false + } + + e.insertTextData = (data: DataTransfer): boolean => { + const text = data.getData('text/plain') + + if (text) { + const lines = text.split(/\r\n|\r|\n/) + let split = false + + for (const line of lines) { + if (split) { + Transforms.splitNodes(e, { always: true }) + } + + e.insertText(line) + split = true + } + return true + } + return false + } + + e.onChange = options => { + const onContextChange = EDITOR_TO_ON_CHANGE.get(e) + + if (onContextChange) { + onContextChange(options) + } + + onChange(options) + } + + return e +} + +const getMatches = (e: Editor, path: Path) => { + const matches: [Path, Key][] = [] + for (const [n, p] of Editor.levels(e, { at: path })) { + const key = DOMEditor.findKey(e, n) + matches.push([p, key]) + } + return matches +} diff --git a/packages/slate-react/src/utils/constants.ts b/packages/slate-dom/src/utils/constants.ts similarity index 100% rename from packages/slate-react/src/utils/constants.ts rename to packages/slate-dom/src/utils/constants.ts diff --git a/packages/slate-react/src/utils/diff-text.ts b/packages/slate-dom/src/utils/diff-text.ts similarity index 100% rename from packages/slate-react/src/utils/diff-text.ts rename to packages/slate-dom/src/utils/diff-text.ts diff --git a/packages/slate-react/src/utils/dom.ts b/packages/slate-dom/src/utils/dom.ts similarity index 97% rename from packages/slate-react/src/utils/dom.ts rename to packages/slate-dom/src/utils/dom.ts index d600faee9c..4c6163d0b0 100644 --- a/packages/slate-react/src/utils/dom.ts +++ b/packages/slate-dom/src/utils/dom.ts @@ -12,7 +12,7 @@ import DOMText = globalThis.Text import DOMRange = globalThis.Range import DOMSelection = globalThis.Selection import DOMStaticRange = globalThis.StaticRange -import { ReactEditor } from '../plugin/react-editor' +import { DOMEditor } from '../plugin/dom-editor' export { DOMNode, @@ -289,7 +289,7 @@ export const getSelection = (root: Document | ShadowRoot): Selection | null => { */ export const isTrackedMutation = ( - editor: ReactEditor, + editor: DOMEditor, mutation: MutationRecord, batch: MutationRecord[] ): boolean => { @@ -298,9 +298,9 @@ export const isTrackedMutation = ( return false } - const { document } = ReactEditor.getWindow(editor) + const { document } = DOMEditor.getWindow(editor) if (document.contains(target)) { - return ReactEditor.hasDOMNode(editor, target, { editable: true }) + return DOMEditor.hasDOMNode(editor, target, { editable: true }) } const parentMutation = batch.find(({ addedNodes, removedNodes }) => { diff --git a/packages/slate-dom/src/utils/environment.ts b/packages/slate-dom/src/utils/environment.ts new file mode 100644 index 0000000000..a1a8bb5576 --- /dev/null +++ b/packages/slate-dom/src/utils/environment.ts @@ -0,0 +1,83 @@ +export const IS_IOS = + typeof navigator !== 'undefined' && + typeof window !== 'undefined' && + /iPad|iPhone|iPod/.test(navigator.userAgent) && + !window.MSStream + +export const IS_APPLE = + typeof navigator !== 'undefined' && /Mac OS X/.test(navigator.userAgent) + +export const IS_ANDROID = + typeof navigator !== 'undefined' && /Android/.test(navigator.userAgent) + +export const IS_FIREFOX = + typeof navigator !== 'undefined' && + /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent) + +export const IS_WEBKIT = + typeof navigator !== 'undefined' && + /AppleWebKit(?!.*Chrome)/i.test(navigator.userAgent) + +// "modern" Edge was released at 79.x +export const IS_EDGE_LEGACY = + typeof navigator !== 'undefined' && + /Edge?\/(?:[0-6][0-9]|[0-7][0-8])(?:\.)/i.test(navigator.userAgent) + +export const IS_CHROME = + typeof navigator !== 'undefined' && /Chrome/i.test(navigator.userAgent) + +// Native `beforeInput` events don't work well with react on Chrome 75 +// and older, Chrome 76+ can use `beforeInput` though. +export const IS_CHROME_LEGACY = + typeof navigator !== 'undefined' && + /Chrome?\/(?:[0-7][0-5]|[0-6][0-9])(?:\.)/i.test(navigator.userAgent) + +export const IS_ANDROID_CHROME_LEGACY = + IS_ANDROID && + typeof navigator !== 'undefined' && + /Chrome?\/(?:[0-5]?\d)(?:\.)/i.test(navigator.userAgent) + +// Firefox did not support `beforeInput` until `v87`. +export const IS_FIREFOX_LEGACY = + typeof navigator !== 'undefined' && + /^(?!.*Seamonkey)(?=.*Firefox\/(?:[0-7][0-9]|[0-8][0-6])(?:\.)).*/i.test( + navigator.userAgent + ) + +// UC mobile browser +export const IS_UC_MOBILE = + typeof navigator !== 'undefined' && /.*UCBrowser/.test(navigator.userAgent) + +// Wechat browser (not including mac wechat) +export const IS_WECHATBROWSER = + typeof navigator !== 'undefined' && + /.*Wechat/.test(navigator.userAgent) && + !/.*MacWechat/.test(navigator.userAgent) // avoid lookbehind (buggy in safari < 16.4) + +// Check if DOM is available as React does internally. +// https://github.com/facebook/react/blob/master/packages/shared/ExecutionEnvironment.js +export const CAN_USE_DOM = !!( + typeof window !== 'undefined' && + typeof window.document !== 'undefined' && + typeof window.document.createElement !== 'undefined' +) + +// Check if the browser is Safari and older than 17 +export const IS_SAFARI_LEGACY = + typeof navigator !== 'undefined' && + /Safari/.test(navigator.userAgent) && + /Version\/(\d+)/.test(navigator.userAgent) && + (navigator.userAgent.match(/Version\/(\d+)/)?.[1] + ? parseInt(navigator.userAgent.match(/Version\/(\d+)/)?.[1]!, 10) < 17 + : false) + +// COMPAT: Firefox/Edge Legacy don't support the `beforeinput` event +// Chrome Legacy doesn't support `beforeinput` correctly +export const HAS_BEFORE_INPUT_SUPPORT = + (!IS_CHROME_LEGACY || !IS_ANDROID_CHROME_LEGACY) && + !IS_EDGE_LEGACY && + // globalThis is undefined in older browsers + typeof globalThis !== 'undefined' && + globalThis.InputEvent && + // @ts-ignore The `getTargetRanges` property isn't recognized. + typeof globalThis.InputEvent.prototype.getTargetRanges === 'function' diff --git a/packages/slate-react/src/utils/hotkeys.ts b/packages/slate-dom/src/utils/hotkeys.ts similarity index 100% rename from packages/slate-react/src/utils/hotkeys.ts rename to packages/slate-dom/src/utils/hotkeys.ts diff --git a/packages/slate-react/src/utils/key.ts b/packages/slate-dom/src/utils/key.ts similarity index 100% rename from packages/slate-react/src/utils/key.ts rename to packages/slate-dom/src/utils/key.ts diff --git a/packages/slate-react/src/utils/lines.ts b/packages/slate-dom/src/utils/lines.ts similarity index 84% rename from packages/slate-react/src/utils/lines.ts rename to packages/slate-dom/src/utils/lines.ts index 7b0ef70134..f45fa02aa6 100644 --- a/packages/slate-react/src/utils/lines.ts +++ b/packages/slate-dom/src/utils/lines.ts @@ -3,7 +3,7 @@ */ import { Editor, Range } from 'slate' -import { ReactEditor } from '../plugin/react-editor' +import { DOMEditor } from '../plugin/dom-editor' const doRectsIntersect = (rect: DOMRect, compareRect: DOMRect) => { const middle = (compareRect.top + compareRect.bottom) / 2 @@ -11,13 +11,9 @@ const doRectsIntersect = (rect: DOMRect, compareRect: DOMRect) => { return rect.top <= middle && rect.bottom >= middle } -const areRangesSameLine = ( - editor: ReactEditor, - range1: Range, - range2: Range -) => { - const rect1 = ReactEditor.toDOMRange(editor, range1).getBoundingClientRect() - const rect2 = ReactEditor.toDOMRange(editor, range2).getBoundingClientRect() +const areRangesSameLine = (editor: DOMEditor, range1: Range, range2: Range) => { + const rect1 = DOMEditor.toDOMRange(editor, range1).getBoundingClientRect() + const rect2 = DOMEditor.toDOMRange(editor, range2).getBoundingClientRect() return doRectsIntersect(rect1, rect2) && doRectsIntersect(rect2, rect1) } @@ -31,7 +27,7 @@ const areRangesSameLine = ( * @returns {Range} A valid portion of the parentRange which is one a single line */ export const findCurrentLineRange = ( - editor: ReactEditor, + editor: DOMEditor, parentRange: Range ): Range => { const parentRangeBoundary = Editor.range(editor, Range.end(parentRange)) diff --git a/packages/slate-react/src/utils/range-list.ts b/packages/slate-dom/src/utils/range-list.ts similarity index 100% rename from packages/slate-react/src/utils/range-list.ts rename to packages/slate-dom/src/utils/range-list.ts diff --git a/packages/slate-react/src/utils/types.ts b/packages/slate-dom/src/utils/types.ts similarity index 100% rename from packages/slate-react/src/utils/types.ts rename to packages/slate-dom/src/utils/types.ts diff --git a/packages/slate-react/src/utils/weak-maps.ts b/packages/slate-dom/src/utils/weak-maps.ts similarity index 94% rename from packages/slate-react/src/utils/weak-maps.ts rename to packages/slate-dom/src/utils/weak-maps.ts index 25e1d93d3d..5e5b4451c3 100644 --- a/packages/slate-react/src/utils/weak-maps.ts +++ b/packages/slate-dom/src/utils/weak-maps.ts @@ -1,8 +1,18 @@ -import { Ancestor, Editor, Node, Operation, Range, RangeRef, Text } from 'slate' -import { Action } from '../hooks/android-input-manager/android-input-manager' +import { + Ancestor, + Editor, + Node, + Operation, + Point, + Range, + RangeRef, + Text, +} from 'slate' import { TextDiff } from './diff-text' import { Key } from './key' +export type Action = { at?: Point | Range; run: () => void } + /** * Two weak maps that allow us rebuild a path given a node. They are populated * at render time such that after a render occurs we can always backtrack. diff --git a/packages/slate-dom/tsconfig.json b/packages/slate-dom/tsconfig.json new file mode 100644 index 0000000000..8169990f2a --- /dev/null +++ b/packages/slate-dom/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../config/typescript/tsconfig.json", + "include": ["src/**/*"], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib" + }, + "references": [{ "path": "../slate" }] +} diff --git a/packages/slate-react/package.json b/packages/slate-react/package.json index 7ad87326fe..c9a2fb51e0 100644 --- a/packages/slate-react/package.json +++ b/packages/slate-react/package.json @@ -35,13 +35,15 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "slate": "^0.110.2", + "slate-dom": "^0.110.2", "slate-hyperscript": "^0.100.0", "source-map-loader": "^4.0.1" }, "peerDependencies": { "react": ">=18.2.0", "react-dom": ">=18.2.0", - "slate": ">=0.99.0" + "slate": ">=0.99.0", + "slate-dom": ">=0.110.2" }, "umdGlobals": { "react": "React", diff --git a/packages/slate-react/src/components/editable.tsx b/packages/slate-react/src/components/editable.tsx index 2988208a9a..1c3252538a 100644 --- a/packages/slate-react/src/components/editable.tsx +++ b/packages/slate-react/src/components/editable.tsx @@ -31,7 +31,7 @@ import { ReadOnlyContext } from '../hooks/use-read-only' import { useSlate } from '../hooks/use-slate' import { useTrackUserInput } from '../hooks/use-track-user-input' import { ReactEditor } from '../plugin/react-editor' -import { TRIPLE_CLICK } from '../utils/constants' +import { TRIPLE_CLICK } from 'slate-dom' import { DOMElement, DOMRange, @@ -42,7 +42,7 @@ import { isDOMElement, isDOMNode, isPlainTextOnlyPaste, -} from '../utils/dom' +} from 'slate-dom' import { CAN_USE_DOM, HAS_BEFORE_INPUT_SUPPORT, @@ -54,8 +54,8 @@ import { IS_WEBKIT, IS_UC_MOBILE, IS_WECHATBROWSER, -} from '../utils/environment' -import Hotkeys from '../utils/hotkeys' +} from 'slate-dom' +import { Hotkeys } from 'slate-dom' import { IS_NODE_MAP_DIRTY, EDITOR_TO_ELEMENT, @@ -71,7 +71,7 @@ import { MARK_PLACEHOLDER_SYMBOL, NODE_TO_ELEMENT, PLACEHOLDER_SYMBOL, -} from '../utils/weak-maps' +} from 'slate-dom' import { RestoreDOM } from './restore-dom/restore-dom' import { AndroidInputManager } from '../hooks/android-input-manager/android-input-manager' import { ComposingContext } from '../hooks/use-composing' diff --git a/packages/slate-react/src/components/element.tsx b/packages/slate-react/src/components/element.tsx index b1a4eac3fd..37c7196006 100644 --- a/packages/slate-react/src/components/element.tsx +++ b/packages/slate-react/src/components/element.tsx @@ -4,14 +4,14 @@ import { JSX } from 'react' import { Editor, Element as SlateElement, Node, Range } from 'slate' import { ReactEditor, useReadOnly, useSlateStatic } from '..' import useChildren from '../hooks/use-children' -import { isElementDecorationsEqual } from '../utils/range-list' +import { isElementDecorationsEqual } from 'slate-dom' import { EDITOR_TO_KEY_TO_ELEMENT, ELEMENT_TO_NODE, NODE_TO_ELEMENT, NODE_TO_INDEX, NODE_TO_PARENT, -} from '../utils/weak-maps' +} from 'slate-dom' import { RenderElementProps, RenderLeafProps, diff --git a/packages/slate-react/src/components/leaf.tsx b/packages/slate-react/src/components/leaf.tsx index 0bef06537c..562aeb51b2 100644 --- a/packages/slate-react/src/components/leaf.tsx +++ b/packages/slate-react/src/components/leaf.tsx @@ -13,10 +13,10 @@ import { PLACEHOLDER_SYMBOL, EDITOR_TO_PLACEHOLDER_ELEMENT, EDITOR_TO_FORCE_RENDER, -} from '../utils/weak-maps' +} from 'slate-dom' import { RenderLeafProps, RenderPlaceholderProps } from './editable' import { useSlateStatic } from '../hooks/use-slate-static' -import { IS_WEBKIT, IS_ANDROID } from '../utils/environment' +import { IS_WEBKIT, IS_ANDROID } from 'slate-dom' // Delay the placeholder on Android to prevent the keyboard from closing. // (https://github.com/ianstormtaylor/slate/pull/5368) diff --git a/packages/slate-react/src/components/restore-dom/restore-dom-manager.ts b/packages/slate-react/src/components/restore-dom/restore-dom-manager.ts index bfcd25cfcd..469512dc0b 100644 --- a/packages/slate-react/src/components/restore-dom/restore-dom-manager.ts +++ b/packages/slate-react/src/components/restore-dom/restore-dom-manager.ts @@ -1,6 +1,6 @@ import { RefObject } from 'react' import { ReactEditor } from '../../plugin/react-editor' -import { isTrackedMutation } from '../../utils/dom' +import { isTrackedMutation } from 'slate-dom' export type RestoreDOMManager = { registerMutations: (mutations: MutationRecord[]) => void diff --git a/packages/slate-react/src/components/restore-dom/restore-dom.tsx b/packages/slate-react/src/components/restore-dom/restore-dom.tsx index 77ee986b8e..187c3a7b37 100644 --- a/packages/slate-react/src/components/restore-dom/restore-dom.tsx +++ b/packages/slate-react/src/components/restore-dom/restore-dom.tsx @@ -6,7 +6,7 @@ import React, { RefObject, } from 'react' import { EditorContext } from '../../hooks/use-slate-static' -import { IS_ANDROID } from '../../utils/environment' +import { IS_ANDROID } from 'slate-dom' import { createRestoreDomManager, RestoreDOMManager, diff --git a/packages/slate-react/src/components/slate.tsx b/packages/slate-react/src/components/slate.tsx index 5fa941b353..1ed3af02d5 100644 --- a/packages/slate-react/src/components/slate.tsx +++ b/packages/slate-react/src/components/slate.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react' import { Descendant, Editor, Node, Operation, Scrubber, Selection } from 'slate' +import { EDITOR_TO_ON_CHANGE } from 'slate-dom' import { FocusedContext } from '../hooks/use-focused' import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect' import { SlateContext, SlateContextValue } from '../hooks/use-slate' @@ -10,7 +11,6 @@ import { import { EditorContext } from '../hooks/use-slate-static' import { ReactEditor } from '../plugin/react-editor' import { REACT_MAJOR_VERSION } from '../utils/environment' -import { EDITOR_TO_ON_CHANGE } from '../utils/weak-maps' /** * A wrapper around the provider to handle `onChange` events, because the editor diff --git a/packages/slate-react/src/components/string.tsx b/packages/slate-react/src/components/string.tsx index 3b87013315..bdb069b724 100644 --- a/packages/slate-react/src/components/string.tsx +++ b/packages/slate-react/src/components/string.tsx @@ -3,8 +3,8 @@ import { Editor, Text, Path, Element, Node } from 'slate' import { ReactEditor, useSlateStatic } from '..' import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect' -import { IS_ANDROID, IS_IOS } from '../utils/environment' -import { MARK_PLACEHOLDER_SYMBOL } from '../utils/weak-maps' +import { IS_ANDROID, IS_IOS } from 'slate-dom' +import { MARK_PLACEHOLDER_SYMBOL } from 'slate-dom' /** * Leaf content strings. diff --git a/packages/slate-react/src/components/text.tsx b/packages/slate-react/src/components/text.tsx index 1d412b30aa..a162dcad9e 100644 --- a/packages/slate-react/src/components/text.tsx +++ b/packages/slate-react/src/components/text.tsx @@ -1,12 +1,12 @@ import React, { useCallback, useRef } from 'react' import { Element, Range, Text as SlateText } from 'slate' import { ReactEditor, useSlateStatic } from '..' -import { isTextDecorationsEqual } from '../utils/range-list' +import { isTextDecorationsEqual } from 'slate-dom' import { EDITOR_TO_KEY_TO_ELEMENT, ELEMENT_TO_NODE, NODE_TO_ELEMENT, -} from '../utils/weak-maps' +} from 'slate-dom' import { RenderLeafProps, RenderPlaceholderProps } from './editable' import Leaf from './leaf' diff --git a/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts b/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts index a8e1b89811..90a3244897 100644 --- a/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts +++ b/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts @@ -11,8 +11,8 @@ import { targetRange, TextDiff, verifyDiffState, -} from '../../utils/diff-text' -import { isDOMSelection, isTrackedMutation } from '../../utils/dom' +} from 'slate-dom' +import { isDOMSelection, isTrackedMutation } from 'slate-dom' import { EDITOR_TO_FORCE_RENDER, EDITOR_TO_PENDING_ACTION, @@ -23,7 +23,7 @@ import { EDITOR_TO_USER_MARKS, IS_COMPOSING, IS_NODE_MAP_DIRTY, -} from '../../utils/weak-maps' +} from 'slate-dom' export type Action = { at?: Point | Range; run: () => void } diff --git a/packages/slate-react/src/hooks/android-input-manager/use-android-input-manager.ts b/packages/slate-react/src/hooks/android-input-manager/use-android-input-manager.ts index 7b9195eab0..db4a08a2aa 100644 --- a/packages/slate-react/src/hooks/android-input-manager/use-android-input-manager.ts +++ b/packages/slate-react/src/hooks/android-input-manager/use-android-input-manager.ts @@ -1,7 +1,7 @@ import { RefObject, useState } from 'react' import { useSlateStatic } from '../use-slate-static' -import { IS_ANDROID } from '../../utils/environment' -import { EDITOR_TO_SCHEDULE_FLUSH } from '../../utils/weak-maps' +import { IS_ANDROID } from 'slate-dom' +import { EDITOR_TO_SCHEDULE_FLUSH } from 'slate-dom' import { createAndroidInputManager, CreateAndroidInputManagerOptions, diff --git a/packages/slate-react/src/hooks/use-children.tsx b/packages/slate-react/src/hooks/use-children.tsx index 1ede4455fe..3d30f55fb4 100644 --- a/packages/slate-react/src/hooks/use-children.tsx +++ b/packages/slate-react/src/hooks/use-children.tsx @@ -9,11 +9,7 @@ import { import ElementComponent from '../components/element' import TextComponent from '../components/text' import { ReactEditor } from '../plugin/react-editor' -import { - IS_NODE_MAP_DIRTY, - NODE_TO_INDEX, - NODE_TO_PARENT, -} from '../utils/weak-maps' +import { IS_NODE_MAP_DIRTY, NODE_TO_INDEX, NODE_TO_PARENT } from 'slate-dom' import { useDecorate } from './use-decorate' import { SelectedContext } from './use-selected' import { useSlateStatic } from './use-slate-static' diff --git a/packages/slate-react/src/hooks/use-isomorphic-layout-effect.ts b/packages/slate-react/src/hooks/use-isomorphic-layout-effect.ts index 67304ea680..663cc819af 100644 --- a/packages/slate-react/src/hooks/use-isomorphic-layout-effect.ts +++ b/packages/slate-react/src/hooks/use-isomorphic-layout-effect.ts @@ -1,5 +1,5 @@ import { useLayoutEffect, useEffect } from 'react' -import { CAN_USE_DOM } from '../utils/environment' +import { CAN_USE_DOM } from 'slate-dom' /** * Prevent warning on SSR by falling back to useEffect when DOM isn't available diff --git a/packages/slate-react/src/index.ts b/packages/slate-react/src/index.ts index 0824d1fb99..2a9d7a0323 100644 --- a/packages/slate-react/src/index.ts +++ b/packages/slate-react/src/index.ts @@ -27,4 +27,4 @@ export { ReactEditor } from './plugin/react-editor' export { withReact } from './plugin/with-react' // Utils -export { NODE_TO_INDEX, NODE_TO_PARENT } from './utils/weak-maps' +export { NODE_TO_INDEX, NODE_TO_PARENT } from 'slate-dom' diff --git a/packages/slate-react/src/plugin/react-editor.ts b/packages/slate-react/src/plugin/react-editor.ts index a221f7e6d0..ce199041fd 100644 --- a/packages/slate-react/src/plugin/react-editor.ts +++ b/packages/slate-react/src/plugin/react-editor.ts @@ -1,1079 +1,12 @@ -import { - BaseEditor, - Editor, - Element, - Node, - Path, - Point, - Range, - Scrubber, - Transforms, -} from 'slate' -import { TextDiff } from '../utils/diff-text' -import { - DOMElement, - DOMNode, - DOMPoint, - DOMRange, - DOMSelection, - DOMStaticRange, - DOMText, - getSelection, - hasShadowRoot, - isAfter, - isBefore, - isDOMElement, - isDOMNode, - isDOMSelection, - normalizeDOMPoint, -} from '../utils/dom' -import { IS_ANDROID, IS_CHROME, IS_FIREFOX } from '../utils/environment' - -import { Key } from '../utils/key' -import { - EDITOR_TO_ELEMENT, - EDITOR_TO_KEY_TO_ELEMENT, - EDITOR_TO_PENDING_DIFFS, - EDITOR_TO_SCHEDULE_FLUSH, - EDITOR_TO_WINDOW, - ELEMENT_TO_NODE, - IS_COMPOSING, - IS_FOCUSED, - IS_READ_ONLY, - NODE_TO_INDEX, - NODE_TO_KEY, - NODE_TO_PARENT, -} from '../utils/weak-maps' +import { DOMEditor, type DOMEditorInterface } from 'slate-dom' /** * A React and DOM-specific version of the `Editor` interface. */ -export interface ReactEditor extends BaseEditor { - hasEditableTarget: ( - editor: ReactEditor, - target: EventTarget | null - ) => target is DOMNode - hasRange: (editor: ReactEditor, range: Range) => boolean - hasSelectableTarget: ( - editor: ReactEditor, - target: EventTarget | null - ) => boolean - hasTarget: ( - editor: ReactEditor, - target: EventTarget | null - ) => target is DOMNode - insertData: (data: DataTransfer) => void - insertFragmentData: (data: DataTransfer) => boolean - insertTextData: (data: DataTransfer) => boolean - isTargetInsideNonReadonlyVoid: ( - editor: ReactEditor, - target: EventTarget | null - ) => boolean - setFragmentData: ( - data: DataTransfer, - originEvent?: 'drag' | 'copy' | 'cut' - ) => void -} - -export interface ReactEditorInterface { - /** - * Experimental and android specific: Get pending diffs - */ - androidPendingDiffs: (editor: Editor) => TextDiff[] | undefined - - /** - * Experimental and android specific: Flush all pending diffs and cancel composition at the next possible time. - */ - androidScheduleFlush: (editor: Editor) => void - - /** - * Blur the editor. - */ - blur: (editor: ReactEditor) => void - - /** - * Deselect the editor. - */ - deselect: (editor: ReactEditor) => void - - /** - * Find the DOM node that implements DocumentOrShadowRoot for the editor. - */ - findDocumentOrShadowRoot: (editor: ReactEditor) => Document | ShadowRoot - - /** - * Get the target range from a DOM `event`. - */ - findEventRange: (editor: ReactEditor, event: any) => Range - - /** - * Find a key for a Slate node. - */ - findKey: (editor: ReactEditor, node: Node) => Key - - /** - * Find the path of Slate node. - */ - findPath: (editor: ReactEditor, node: Node) => Path - - /** - * Focus the editor. - */ - focus: (editor: ReactEditor, options?: { retries: number }) => void - - /** - * Return the host window of the current editor. - */ - getWindow: (editor: ReactEditor) => Window - - /** - * Check if a DOM node is within the editor. - */ - hasDOMNode: ( - editor: ReactEditor, - target: DOMNode, - options?: { editable?: boolean } - ) => boolean - - /** - * Check if the target is editable and in the editor. - */ - hasEditableTarget: ( - editor: ReactEditor, - target: EventTarget | null - ) => target is DOMNode - - /** - * - */ - hasRange: (editor: ReactEditor, range: Range) => boolean - - /** - * Check if the target can be selectable - */ - hasSelectableTarget: ( - editor: ReactEditor, - target: EventTarget | null - ) => boolean - - /** - * Check if the target is in the editor. - */ - hasTarget: ( - editor: ReactEditor, - target: EventTarget | null - ) => target is DOMNode - - /** - * Insert data from a `DataTransfer` into the editor. - */ - insertData: (editor: ReactEditor, data: DataTransfer) => void - - /** - * Insert fragment data from a `DataTransfer` into the editor. - */ - insertFragmentData: (editor: ReactEditor, data: DataTransfer) => boolean +export interface ReactEditor extends DOMEditor {} - /** - * Insert text data from a `DataTransfer` into the editor. - */ - insertTextData: (editor: ReactEditor, data: DataTransfer) => boolean - - /** - * Check if the user is currently composing inside the editor. - */ - isComposing: (editor: ReactEditor) => boolean - - /** - * Check if the editor is focused. - */ - isFocused: (editor: ReactEditor) => boolean - - /** - * Check if the editor is in read-only mode. - */ - isReadOnly: (editor: ReactEditor) => boolean - - /** - * Check if the target is inside void and in an non-readonly editor. - */ - isTargetInsideNonReadonlyVoid: ( - editor: ReactEditor, - target: EventTarget | null - ) => boolean - - /** - * Sets data from the currently selected fragment on a `DataTransfer`. - */ - setFragmentData: ( - editor: ReactEditor, - data: DataTransfer, - originEvent?: 'drag' | 'copy' | 'cut' - ) => void - - /** - * Find the native DOM element from a Slate node. - */ - toDOMNode: (editor: ReactEditor, node: Node) => HTMLElement - - /** - * Find a native DOM selection point from a Slate point. - */ - toDOMPoint: (editor: ReactEditor, point: Point) => DOMPoint - - /** - * Find a native DOM range from a Slate `range`. - * - * Notice: the returned range will always be ordinal regardless of the direction of Slate `range` due to DOM API limit. - * - * there is no way to create a reverse DOM Range using Range.setStart/setEnd - * according to https://dom.spec.whatwg.org/#concept-range-bp-set. - */ - toDOMRange: (editor: ReactEditor, range: Range) => DOMRange - - /** - * Find a Slate node from a native DOM `element`. - */ - toSlateNode: (editor: ReactEditor, domNode: DOMNode) => Node - - /** - * Find a Slate point from a DOM selection's `domNode` and `domOffset`. - */ - toSlatePoint: ( - editor: ReactEditor, - domPoint: DOMPoint, - options: { - exactMatch: boolean - suppressThrow: T - /** - * The direction to search for Slate leaf nodes if `domPoint` is - * non-editable and non-void. - */ - searchDirection?: 'forward' | 'backward' - } - ) => T extends true ? Point | null : Point - - /** - * Find a Slate range from a DOM range or selection. - */ - toSlateRange: ( - editor: ReactEditor, - domRange: DOMRange | DOMStaticRange | DOMSelection, - options: { - exactMatch: boolean - suppressThrow: T - } - ) => T extends true ? Range | null : Range -} +export interface ReactEditorInterface extends DOMEditorInterface {} // eslint-disable-next-line no-redeclare -export const ReactEditor: ReactEditorInterface = { - androidPendingDiffs: editor => EDITOR_TO_PENDING_DIFFS.get(editor), - - androidScheduleFlush: editor => { - EDITOR_TO_SCHEDULE_FLUSH.get(editor)?.() - }, - - blur: editor => { - const el = ReactEditor.toDOMNode(editor, editor) - const root = ReactEditor.findDocumentOrShadowRoot(editor) - IS_FOCUSED.set(editor, false) - - if (root.activeElement === el) { - el.blur() - } - }, - - deselect: editor => { - const { selection } = editor - const root = ReactEditor.findDocumentOrShadowRoot(editor) - const domSelection = getSelection(root) - - if (domSelection && domSelection.rangeCount > 0) { - domSelection.removeAllRanges() - } - - if (selection) { - Transforms.deselect(editor) - } - }, - - findDocumentOrShadowRoot: editor => { - const el = ReactEditor.toDOMNode(editor, editor) - const root = el.getRootNode() - - if (root instanceof Document || root instanceof ShadowRoot) { - return root - } - - return el.ownerDocument - }, - - findEventRange: (editor, event) => { - if ('nativeEvent' in event) { - event = event.nativeEvent - } - - const { clientX: x, clientY: y, target } = event - - if (x == null || y == null) { - throw new Error(`Cannot resolve a Slate range from a DOM event: ${event}`) - } - - const node = ReactEditor.toSlateNode(editor, event.target) - const path = ReactEditor.findPath(editor, node) - - // If the drop target is inside a void node, move it into either the - // next or previous node, depending on which side the `x` and `y` - // coordinates are closest to. - if (Element.isElement(node) && Editor.isVoid(editor, node)) { - const rect = target.getBoundingClientRect() - const isPrev = editor.isInline(node) - ? x - rect.left < rect.left + rect.width - x - : y - rect.top < rect.top + rect.height - y - - const edge = Editor.point(editor, path, { - edge: isPrev ? 'start' : 'end', - }) - const point = isPrev - ? Editor.before(editor, edge) - : Editor.after(editor, edge) - - if (point) { - const range = Editor.range(editor, point) - return range - } - } - - // Else resolve a range from the caret position where the drop occured. - let domRange - const { document } = ReactEditor.getWindow(editor) - - // COMPAT: In Firefox, `caretRangeFromPoint` doesn't exist. (2016/07/25) - if (document.caretRangeFromPoint) { - domRange = document.caretRangeFromPoint(x, y) - } else { - const position = document.caretPositionFromPoint(x, y) - - if (position) { - domRange = document.createRange() - domRange.setStart(position.offsetNode, position.offset) - domRange.setEnd(position.offsetNode, position.offset) - } - } - - if (!domRange) { - throw new Error(`Cannot resolve a Slate range from a DOM event: ${event}`) - } - - // Resolve a Slate range from the DOM range. - const range = ReactEditor.toSlateRange(editor, domRange, { - exactMatch: false, - suppressThrow: false, - }) - return range - }, - - findKey: (editor, node) => { - let key = NODE_TO_KEY.get(node) - - if (!key) { - key = new Key() - NODE_TO_KEY.set(node, key) - } - - return key - }, - - findPath: (editor, node) => { - const path: Path = [] - let child = node - - while (true) { - const parent = NODE_TO_PARENT.get(child) - - if (parent == null) { - if (Editor.isEditor(child)) { - return path - } else { - break - } - } - - const i = NODE_TO_INDEX.get(child) - - if (i == null) { - break - } - - path.unshift(i) - child = parent - } - - throw new Error( - `Unable to find the path for Slate node: ${Scrubber.stringify(node)}` - ) - }, - - focus: (editor, options = { retries: 5 }) => { - // Return if already focused - if (IS_FOCUSED.get(editor)) { - return - } - - // Retry setting focus if the editor has pending operations. - // The DOM (selection) is unstable while changes are applied. - // Retry until retries are exhausted or editor is focused. - if (options.retries <= 0) { - throw new Error( - 'Could not set focus, editor seems stuck with pending operations' - ) - } - if (editor.operations.length > 0) { - setTimeout(() => { - ReactEditor.focus(editor, { retries: options.retries - 1 }) - }, 10) - return - } - - const el = ReactEditor.toDOMNode(editor, editor) - const root = ReactEditor.findDocumentOrShadowRoot(editor) - if (root.activeElement !== el) { - // Ensure that the DOM selection state is set to the editor's selection - if (editor.selection && root instanceof Document) { - const domSelection = getSelection(root) - const domRange = ReactEditor.toDOMRange(editor, editor.selection) - domSelection?.removeAllRanges() - domSelection?.addRange(domRange) - } - // Create a new selection in the top of the document if missing - if (!editor.selection) { - Transforms.select(editor, Editor.start(editor, [])) - } - // IS_FOCUSED should be set before calling el.focus() to ensure that - // FocusedContext is updated to the correct value - IS_FOCUSED.set(editor, true) - el.focus({ preventScroll: true }) - } - }, - - getWindow: editor => { - const window = EDITOR_TO_WINDOW.get(editor) - if (!window) { - throw new Error('Unable to find a host window element for this editor') - } - return window - }, - - hasDOMNode: (editor, target, options = {}) => { - const { editable = false } = options - const editorEl = ReactEditor.toDOMNode(editor, editor) - let targetEl - - // COMPAT: In Firefox, reading `target.nodeType` will throw an error if - // target is originating from an internal "restricted" element (e.g. a - // stepper arrow on a number input). (2018/05/04) - // https://github.com/ianstormtaylor/slate/issues/1819 - try { - targetEl = ( - isDOMElement(target) ? target : target.parentElement - ) as HTMLElement - } catch (err) { - if ( - err instanceof Error && - !err.message.includes('Permission denied to access property "nodeType"') - ) { - throw err - } - } - - if (!targetEl) { - return false - } - - return ( - targetEl.closest(`[data-slate-editor]`) === editorEl && - (!editable || targetEl.isContentEditable - ? true - : (typeof targetEl.isContentEditable === 'boolean' && // isContentEditable exists only on HTMLElement, and on other nodes it will be undefined - // this is the core logic that lets you know you got the right editor.selection instead of null when editor is contenteditable="false"(readOnly) - targetEl.closest('[contenteditable="false"]') === editorEl) || - !!targetEl.getAttribute('data-slate-zero-width')) - ) - }, - - hasEditableTarget: (editor, target): target is DOMNode => - isDOMNode(target) && - ReactEditor.hasDOMNode(editor, target, { editable: true }), - - hasRange: (editor, range) => { - const { anchor, focus } = range - return ( - Editor.hasPath(editor, anchor.path) && Editor.hasPath(editor, focus.path) - ) - }, - - hasSelectableTarget: (editor, target) => - ReactEditor.hasEditableTarget(editor, target) || - ReactEditor.isTargetInsideNonReadonlyVoid(editor, target), - - hasTarget: (editor, target): target is DOMNode => - isDOMNode(target) && ReactEditor.hasDOMNode(editor, target), - - insertData: (editor, data) => { - editor.insertData(data) - }, - - insertFragmentData: (editor, data) => editor.insertFragmentData(data), - - insertTextData: (editor, data) => editor.insertTextData(data), - - isComposing: editor => { - return !!IS_COMPOSING.get(editor) - }, - - isFocused: editor => !!IS_FOCUSED.get(editor), - - isReadOnly: editor => !!IS_READ_ONLY.get(editor), - - isTargetInsideNonReadonlyVoid: (editor, target) => { - if (IS_READ_ONLY.get(editor)) return false - - const slateNode = - ReactEditor.hasTarget(editor, target) && - ReactEditor.toSlateNode(editor, target) - return Element.isElement(slateNode) && Editor.isVoid(editor, slateNode) - }, - - setFragmentData: (editor, data, originEvent) => - editor.setFragmentData(data, originEvent), - - toDOMNode: (editor, node) => { - const KEY_TO_ELEMENT = EDITOR_TO_KEY_TO_ELEMENT.get(editor) - const domNode = Editor.isEditor(node) - ? EDITOR_TO_ELEMENT.get(editor) - : KEY_TO_ELEMENT?.get(ReactEditor.findKey(editor, node)) - - if (!domNode) { - throw new Error( - `Cannot resolve a DOM node from Slate node: ${Scrubber.stringify(node)}` - ) - } - - return domNode - }, - - toDOMPoint: (editor, point) => { - const [node] = Editor.node(editor, point.path) - const el = ReactEditor.toDOMNode(editor, node) - let domPoint: DOMPoint | undefined - - // If we're inside a void node, force the offset to 0, otherwise the zero - // width spacing character will result in an incorrect offset of 1 - if (Editor.void(editor, { at: point })) { - point = { path: point.path, offset: 0 } - } - - // For each leaf, we need to isolate its content, which means filtering - // to its direct text and zero-width spans. (We have to filter out any - // other siblings that may have been rendered alongside them.) - const selector = `[data-slate-string], [data-slate-zero-width]` - const texts = Array.from(el.querySelectorAll(selector)) - let start = 0 - - for (let i = 0; i < texts.length; i++) { - const text = texts[i] - const domNode = text.childNodes[0] as HTMLElement - - if (domNode == null || domNode.textContent == null) { - continue - } - - const { length } = domNode.textContent - const attr = text.getAttribute('data-slate-length') - const trueLength = attr == null ? length : parseInt(attr, 10) - const end = start + trueLength - - // Prefer putting the selection inside the mark placeholder to ensure - // composed text is displayed with the correct marks. - const nextText = texts[i + 1] - if ( - point.offset === end && - nextText?.hasAttribute('data-slate-mark-placeholder') - ) { - const domText = nextText.childNodes[0] - - domPoint = [ - // COMPAT: If we don't explicity set the dom point to be on the actual - // dom text element, chrome will put the selection behind the actual dom - // text element, causing domRange.getBoundingClientRect() calls on a collapsed - // selection to return incorrect zero values (https://bugs.chromium.org/p/chromium/issues/detail?id=435438) - // which will cause issues when scrolling to it. - domText instanceof DOMText ? domText : nextText, - nextText.textContent?.startsWith('\uFEFF') ? 1 : 0, - ] - break - } - - if (point.offset <= end) { - const offset = Math.min(length, Math.max(0, point.offset - start)) - domPoint = [domNode, offset] - break - } - - start = end - } - - if (!domPoint) { - throw new Error( - `Cannot resolve a DOM point from Slate point: ${Scrubber.stringify( - point - )}` - ) - } - - return domPoint - }, - - toDOMRange: (editor, range) => { - const { anchor, focus } = range - const isBackward = Range.isBackward(range) - const domAnchor = ReactEditor.toDOMPoint(editor, anchor) - const domFocus = Range.isCollapsed(range) - ? domAnchor - : ReactEditor.toDOMPoint(editor, focus) - - const window = ReactEditor.getWindow(editor) - const domRange = window.document.createRange() - const [startNode, startOffset] = isBackward ? domFocus : domAnchor - const [endNode, endOffset] = isBackward ? domAnchor : domFocus - - // A slate Point at zero-width Leaf always has an offset of 0 but a native DOM selection at - // zero-width node has an offset of 1 so we have to check if we are in a zero-width node and - // adjust the offset accordingly. - const startEl = ( - isDOMElement(startNode) ? startNode : startNode.parentElement - ) as HTMLElement - const isStartAtZeroWidth = !!startEl.getAttribute('data-slate-zero-width') - const endEl = ( - isDOMElement(endNode) ? endNode : endNode.parentElement - ) as HTMLElement - const isEndAtZeroWidth = !!endEl.getAttribute('data-slate-zero-width') - - domRange.setStart(startNode, isStartAtZeroWidth ? 1 : startOffset) - domRange.setEnd(endNode, isEndAtZeroWidth ? 1 : endOffset) - return domRange - }, - - toSlateNode: (editor, domNode) => { - let domEl = isDOMElement(domNode) ? domNode : domNode.parentElement - - if (domEl && !domEl.hasAttribute('data-slate-node')) { - domEl = domEl.closest(`[data-slate-node]`) - } - - const node = domEl ? ELEMENT_TO_NODE.get(domEl as HTMLElement) : null - - if (!node) { - throw new Error(`Cannot resolve a Slate node from DOM node: ${domEl}`) - } - - return node - }, - - toSlatePoint: ( - editor: ReactEditor, - domPoint: DOMPoint, - options: { - exactMatch: boolean - suppressThrow: T - searchDirection?: 'forward' | 'backward' - } - ): T extends true ? Point | null : Point => { - const { exactMatch, suppressThrow, searchDirection = 'backward' } = options - const [nearestNode, nearestOffset] = exactMatch - ? domPoint - : normalizeDOMPoint(domPoint) - const parentNode = nearestNode.parentNode as DOMElement - let textNode: DOMElement | null = null - let offset = 0 - - if (parentNode) { - const editorEl = ReactEditor.toDOMNode(editor, editor) - const potentialVoidNode = parentNode.closest('[data-slate-void="true"]') - // Need to ensure that the closest void node is actually a void node - // within this editor, and not a void node within some parent editor. This can happen - // if this editor is within a void node of another editor ("nested editors", like in - // the "Editable Voids" example on the docs site). - const voidNode = - potentialVoidNode && editorEl.contains(potentialVoidNode) - ? potentialVoidNode - : null - const potentialNonEditableNode = parentNode.closest( - '[contenteditable="false"]' - ) - const nonEditableNode = - potentialNonEditableNode && editorEl.contains(potentialNonEditableNode) - ? potentialNonEditableNode - : null - let leafNode = parentNode.closest('[data-slate-leaf]') - let domNode: DOMElement | null = null - - // Calculate how far into the text node the `nearestNode` is, so that we - // can determine what the offset relative to the text node is. - if (leafNode) { - textNode = leafNode.closest('[data-slate-node="text"]') - - if (textNode) { - const window = ReactEditor.getWindow(editor) - const range = window.document.createRange() - range.setStart(textNode, 0) - range.setEnd(nearestNode, nearestOffset) - - const contents = range.cloneContents() - const removals = [ - ...Array.prototype.slice.call( - contents.querySelectorAll('[data-slate-zero-width]') - ), - ...Array.prototype.slice.call( - contents.querySelectorAll('[contenteditable=false]') - ), - ] - - removals.forEach(el => { - // COMPAT: While composing at the start of a text node, some keyboards put - // the text content inside the zero width space. - if ( - IS_ANDROID && - !exactMatch && - el.hasAttribute('data-slate-zero-width') && - el.textContent.length > 0 && - el.textContext !== '\uFEFF' - ) { - if (el.textContent.startsWith('\uFEFF')) { - el.textContent = el.textContent.slice(1) - } - - return - } - - el!.parentNode!.removeChild(el) - }) - - // COMPAT: Edge has a bug where Range.prototype.toString() will - // convert \n into \r\n. The bug causes a loop when slate-react - // attempts to reposition its cursor to match the native position. Use - // textContent.length instead. - // https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/10291116/ - offset = contents.textContent!.length - domNode = textNode - } - } else if (voidNode) { - // For void nodes, the element with the offset key will be a cousin, not an - // ancestor, so find it by going down from the nearest void parent and taking the - // first one that isn't inside a nested editor. - const leafNodes = voidNode.querySelectorAll('[data-slate-leaf]') - for (let index = 0; index < leafNodes.length; index++) { - const current = leafNodes[index] - if (ReactEditor.hasDOMNode(editor, current)) { - leafNode = current - break - } - } - - // COMPAT: In read-only editors the leaf is not rendered. - if (!leafNode) { - offset = 1 - } else { - textNode = leafNode.closest('[data-slate-node="text"]')! - domNode = leafNode - offset = domNode.textContent!.length - domNode.querySelectorAll('[data-slate-zero-width]').forEach(el => { - offset -= el.textContent!.length - }) - } - } else if (nonEditableNode) { - // Find the edge of the nearest leaf in `searchDirection` - const getLeafNodes = (node: DOMElement | null | undefined) => - node - ? node.querySelectorAll( - // Exclude leaf nodes in nested editors - '[data-slate-leaf]:not(:scope [data-slate-editor] [data-slate-leaf])' - ) - : [] - const elementNode = nonEditableNode.closest( - '[data-slate-node="element"]' - ) - - if (searchDirection === 'forward') { - const leafNodes = [ - ...getLeafNodes(elementNode), - ...getLeafNodes(elementNode?.nextElementSibling), - ] - leafNode = - leafNodes.find(leaf => isAfter(nonEditableNode, leaf)) ?? null - } else { - const leafNodes = [ - ...getLeafNodes(elementNode?.previousElementSibling), - ...getLeafNodes(elementNode), - ] - leafNode = - leafNodes.findLast(leaf => isBefore(nonEditableNode, leaf)) ?? null - } - - if (leafNode) { - textNode = leafNode.closest('[data-slate-node="text"]')! - domNode = leafNode - if (searchDirection === 'forward') { - offset = 0 - } else { - offset = domNode.textContent!.length - domNode.querySelectorAll('[data-slate-zero-width]').forEach(el => { - offset -= el.textContent!.length - }) - } - } - } - - if ( - domNode && - offset === domNode.textContent!.length && - // COMPAT: Android IMEs might remove the zero width space while composing, - // and we don't add it for line-breaks. - IS_ANDROID && - domNode.getAttribute('data-slate-zero-width') === 'z' && - domNode.textContent?.startsWith('\uFEFF') && - // COMPAT: If the parent node is a Slate zero-width space, editor is - // because the text node should have no characters. However, during IME - // composition the ASCII characters will be prepended to the zero-width - // space, so subtract 1 from the offset to account for the zero-width - // space character. - (parentNode.hasAttribute('data-slate-zero-width') || - // COMPAT: In Firefox, `range.cloneContents()` returns an extra trailing '\n' - // when the document ends with a new-line character. This results in the offset - // length being off by one, so we need to subtract one to account for this. - (IS_FIREFOX && domNode.textContent?.endsWith('\n\n'))) - ) { - offset-- - } - } - - if (IS_ANDROID && !textNode && !exactMatch) { - const node = parentNode.hasAttribute('data-slate-node') - ? parentNode - : parentNode.closest('[data-slate-node]') - - if (node && ReactEditor.hasDOMNode(editor, node, { editable: true })) { - const slateNode = ReactEditor.toSlateNode(editor, node) - let { path, offset } = Editor.start( - editor, - ReactEditor.findPath(editor, slateNode) - ) - - if (!node.querySelector('[data-slate-leaf]')) { - offset = nearestOffset - } - - return { path, offset } as T extends true ? Point | null : Point - } - } - - if (!textNode) { - if (suppressThrow) { - return null as T extends true ? Point | null : Point - } - throw new Error( - `Cannot resolve a Slate point from DOM point: ${domPoint}` - ) - } - - // COMPAT: If someone is clicking from one Slate editor into another, - // the select event fires twice, once for the old editor's `element` - // first, and then afterwards for the correct `element`. (2017/03/03) - const slateNode = ReactEditor.toSlateNode(editor, textNode!) - const path = ReactEditor.findPath(editor, slateNode) - return { path, offset } as T extends true ? Point | null : Point - }, - - toSlateRange: ( - editor: ReactEditor, - domRange: DOMRange | DOMStaticRange | DOMSelection, - options: { - exactMatch: boolean - suppressThrow: T - } - ): T extends true ? Range | null : Range => { - const { exactMatch, suppressThrow } = options - const el = isDOMSelection(domRange) - ? domRange.anchorNode - : domRange.startContainer - let anchorNode - let anchorOffset - let focusNode - let focusOffset - let isCollapsed - - if (el) { - if (isDOMSelection(domRange)) { - // COMPAT: In firefox the normal seletion way does not work - // (https://github.com/ianstormtaylor/slate/pull/5486#issue-1820720223) - if (IS_FIREFOX && domRange.rangeCount > 1) { - focusNode = domRange.focusNode // Focus node works fine - const firstRange = domRange.getRangeAt(0) - const lastRange = domRange.getRangeAt(domRange.rangeCount - 1) - - // Here we are in the contenteditable mode of a table in firefox - if ( - focusNode instanceof HTMLTableRowElement && - firstRange.startContainer instanceof HTMLTableRowElement && - lastRange.startContainer instanceof HTMLTableRowElement - ) { - // HTMLElement, becouse Element is a slate element - function getLastChildren(element: HTMLElement): HTMLElement { - if (element.childElementCount > 0) { - return getLastChildren(element.children[0]) - } else { - return element - } - } - - const firstNodeRow = firstRange.startContainer - const lastNodeRow = lastRange.startContainer - - // This should never fail as "The HTMLElement interface represents any HTML element." - const firstNode = getLastChildren( - firstNodeRow.children[firstRange.startOffset] - ) - const lastNode = getLastChildren( - lastNodeRow.children[lastRange.startOffset] - ) - - // Zero, as we allways take the right one as the anchor point - focusOffset = 0 - - if (lastNode.childNodes.length > 0) { - anchorNode = lastNode.childNodes[0] - } else { - anchorNode = lastNode - } - - if (firstNode.childNodes.length > 0) { - focusNode = firstNode.childNodes[0] - } else { - focusNode = firstNode - } - - if (lastNode instanceof HTMLElement) { - anchorOffset = (lastNode).innerHTML.length - } else { - // Fallback option - anchorOffset = 0 - } - } else { - // This is the read only mode of a firefox table - // Right to left - if (firstRange.startContainer === focusNode) { - anchorNode = lastRange.endContainer - anchorOffset = lastRange.endOffset - focusOffset = firstRange.startOffset - } else { - // Left to right - anchorNode = firstRange.startContainer - anchorOffset = firstRange.endOffset - focusOffset = lastRange.startOffset - } - } - } else { - anchorNode = domRange.anchorNode - anchorOffset = domRange.anchorOffset - focusNode = domRange.focusNode - focusOffset = domRange.focusOffset - } - - // COMPAT: There's a bug in chrome that always returns `true` for - // `isCollapsed` for a Selection that comes from a ShadowRoot. - // (2020/08/08) - // https://bugs.chromium.org/p/chromium/issues/detail?id=447523 - // IsCollapsed might not work in firefox, but this will - if ((IS_CHROME && hasShadowRoot(anchorNode)) || IS_FIREFOX) { - isCollapsed = - domRange.anchorNode === domRange.focusNode && - domRange.anchorOffset === domRange.focusOffset - } else { - isCollapsed = domRange.isCollapsed - } - } else { - anchorNode = domRange.startContainer - anchorOffset = domRange.startOffset - focusNode = domRange.endContainer - focusOffset = domRange.endOffset - isCollapsed = domRange.collapsed - } - } - - if ( - anchorNode == null || - focusNode == null || - anchorOffset == null || - focusOffset == null - ) { - throw new Error( - `Cannot resolve a Slate range from DOM range: ${domRange}` - ) - } - - // COMPAT: Firefox sometimes includes an extra \n (rendered by TextString - // when isTrailing is true) in the focusOffset, resulting in an invalid - // Slate point. (2023/11/01) - if ( - IS_FIREFOX && - focusNode.textContent?.endsWith('\n\n') && - focusOffset === focusNode.textContent.length - ) { - focusOffset-- - } - - const anchor = ReactEditor.toSlatePoint( - editor, - [anchorNode, anchorOffset], - { - exactMatch, - suppressThrow, - } - ) - if (!anchor) { - return null as T extends true ? Range | null : Range - } - - const focusBeforeAnchor = - isBefore(anchorNode, focusNode) || - (anchorNode === focusNode && focusOffset < anchorOffset) - const focus = isCollapsed - ? anchor - : ReactEditor.toSlatePoint(editor, [focusNode, focusOffset], { - exactMatch, - suppressThrow, - searchDirection: focusBeforeAnchor ? 'forward' : 'backward', - }) - if (!focus) { - return null as T extends true ? Range | null : Range - } - - let range: Range = { anchor: anchor as Point, focus: focus as Point } - // if the selection is a hanging range that ends in a void - // and the DOM focus is an Element - // (meaning that the selection ends before the element) - // unhang the range to avoid mistakenly including the void - if ( - Range.isExpanded(range) && - Range.isForward(range) && - isDOMElement(focusNode) && - Editor.void(editor, { at: range.focus, mode: 'highest' }) - ) { - range = Editor.unhangRange(editor, range, { voids: true }) - } - - return range as unknown as T extends true ? Range | null : Range - }, -} +export const ReactEditor: ReactEditorInterface = DOMEditor diff --git a/packages/slate-react/src/plugin/with-react.ts b/packages/slate-react/src/plugin/with-react.ts index ae1dd1e71d..033dfd07e4 100644 --- a/packages/slate-react/src/plugin/with-react.ts +++ b/packages/slate-react/src/plugin/with-react.ts @@ -1,42 +1,6 @@ import ReactDOM from 'react-dom' -import { - BaseEditor, - Editor, - Element, - Node, - Operation, - Path, - PathRef, - Point, - Range, - Transforms, -} from 'slate' -import { - TextDiff, - transformPendingPoint, - transformPendingRange, - transformTextDiff, -} from '../utils/diff-text' -import { - getPlainText, - getSlateFragmentAttribute, - isDOMText, -} from '../utils/dom' -import { Key } from '../utils/key' -import { findCurrentLineRange } from '../utils/lines' -import { - IS_NODE_MAP_DIRTY, - EDITOR_TO_KEY_TO_ELEMENT, - EDITOR_TO_ON_CHANGE, - EDITOR_TO_PENDING_ACTION, - EDITOR_TO_PENDING_DIFFS, - EDITOR_TO_PENDING_INSERTION_MARKS, - EDITOR_TO_PENDING_SELECTION, - EDITOR_TO_SCHEDULE_FLUSH, - EDITOR_TO_USER_MARKS, - EDITOR_TO_USER_SELECTION, - NODE_TO_KEY, -} from '../utils/weak-maps' +import { BaseEditor } from 'slate' +import { withDOM } from 'slate-dom' import { ReactEditor } from './react-editor' import { REACT_MAJOR_VERSION } from '../utils/environment' @@ -48,318 +12,15 @@ import { REACT_MAJOR_VERSION } from '../utils/environment' * * See https://docs.slatejs.org/concepts/11-typescript to learn how. */ - export const withReact = ( editor: T, clipboardFormatKey = 'x-slate-fragment' ): T & ReactEditor => { - const e = editor as T & ReactEditor - const { apply, onChange, deleteBackward, addMark, removeMark } = e - - // The WeakMap which maps a key to a specific HTMLElement must be scoped to the editor instance to - // avoid collisions between editors in the DOM that share the same value. - EDITOR_TO_KEY_TO_ELEMENT.set(e, new WeakMap()) - - e.addMark = (key, value) => { - EDITOR_TO_SCHEDULE_FLUSH.get(e)?.() - - if ( - !EDITOR_TO_PENDING_INSERTION_MARKS.get(e) && - EDITOR_TO_PENDING_DIFFS.get(e)?.length - ) { - // Ensure the current pending diffs originating from changes before the addMark - // are applied with the current formatting - EDITOR_TO_PENDING_INSERTION_MARKS.set(e, null) - } - - EDITOR_TO_USER_MARKS.delete(e) - - addMark(key, value) - } - - e.removeMark = key => { - if ( - !EDITOR_TO_PENDING_INSERTION_MARKS.get(e) && - EDITOR_TO_PENDING_DIFFS.get(e)?.length - ) { - // Ensure the current pending diffs originating from changes before the addMark - // are applied with the current formatting - EDITOR_TO_PENDING_INSERTION_MARKS.set(e, null) - } - - EDITOR_TO_USER_MARKS.delete(e) - - removeMark(key) - } - - e.deleteBackward = unit => { - if (unit !== 'line') { - return deleteBackward(unit) - } - - if (e.selection && Range.isCollapsed(e.selection)) { - const parentBlockEntry = Editor.above(e, { - match: n => Element.isElement(n) && Editor.isBlock(e, n), - at: e.selection, - }) - - if (parentBlockEntry) { - const [, parentBlockPath] = parentBlockEntry - const parentElementRange = Editor.range( - e, - parentBlockPath, - e.selection.anchor - ) - - const currentLineRange = findCurrentLineRange(e, parentElementRange) - - if (!Range.isCollapsed(currentLineRange)) { - Transforms.delete(e, { at: currentLineRange }) - } - } - } - } - - // This attempts to reset the NODE_TO_KEY entry to the correct value - // as apply() changes the object reference and hence invalidates the NODE_TO_KEY entry - e.apply = (op: Operation) => { - const matches: [Path, Key][] = [] - const pathRefMatches: [PathRef, Key][] = [] - - const pendingDiffs = EDITOR_TO_PENDING_DIFFS.get(e) - if (pendingDiffs?.length) { - const transformed = pendingDiffs - .map(textDiff => transformTextDiff(textDiff, op)) - .filter(Boolean) as TextDiff[] - - EDITOR_TO_PENDING_DIFFS.set(e, transformed) - } - - const pendingSelection = EDITOR_TO_PENDING_SELECTION.get(e) - if (pendingSelection) { - EDITOR_TO_PENDING_SELECTION.set( - e, - transformPendingRange(e, pendingSelection, op) - ) - } - - const pendingAction = EDITOR_TO_PENDING_ACTION.get(e) - if (pendingAction?.at) { - const at = Point.isPoint(pendingAction?.at) - ? transformPendingPoint(e, pendingAction.at, op) - : transformPendingRange(e, pendingAction.at, op) - - EDITOR_TO_PENDING_ACTION.set(e, at ? { ...pendingAction, at } : null) - } - - switch (op.type) { - case 'insert_text': - case 'remove_text': - case 'set_node': - case 'split_node': { - matches.push(...getMatches(e, op.path)) - break - } - - case 'set_selection': { - // Selection was manually set, don't restore the user selection after the change. - EDITOR_TO_USER_SELECTION.get(e)?.unref() - EDITOR_TO_USER_SELECTION.delete(e) - break - } - - case 'insert_node': - case 'remove_node': { - matches.push(...getMatches(e, Path.parent(op.path))) - break - } - - case 'merge_node': { - const prevPath = Path.previous(op.path) - matches.push(...getMatches(e, prevPath)) - break - } - - case 'move_node': { - const commonPath = Path.common( - Path.parent(op.path), - Path.parent(op.newPath) - ) - matches.push(...getMatches(e, commonPath)) - - let changedPath: Path - if (Path.isBefore(op.path, op.newPath)) { - matches.push(...getMatches(e, Path.parent(op.path))) - changedPath = op.newPath - } else { - matches.push(...getMatches(e, Path.parent(op.newPath))) - changedPath = op.path - } - - const changedNode = Node.get(editor, Path.parent(changedPath)) - const changedNodeKey = ReactEditor.findKey(e, changedNode) - const changedPathRef = Editor.pathRef(e, Path.parent(changedPath)) - pathRefMatches.push([changedPathRef, changedNodeKey]) - - break - } - } - - apply(op) + let e = editor as T & ReactEditor - switch (op.type) { - case 'insert_node': - case 'remove_node': - case 'merge_node': - case 'move_node': - case 'split_node': { - IS_NODE_MAP_DIRTY.set(e, true) - } - } + e = withDOM(e, clipboardFormatKey) - for (const [path, key] of matches) { - const [node] = Editor.node(e, path) - NODE_TO_KEY.set(node, key) - } - - for (const [pathRef, key] of pathRefMatches) { - if (pathRef.current) { - const [node] = Editor.node(e, pathRef.current) - NODE_TO_KEY.set(node, key) - } - - pathRef.unref() - } - } - - e.setFragmentData = (data: Pick) => { - const { selection } = e - - if (!selection) { - return - } - - const [start, end] = Range.edges(selection) - const startVoid = Editor.void(e, { at: start.path }) - const endVoid = Editor.void(e, { at: end.path }) - - if (Range.isCollapsed(selection) && !startVoid) { - return - } - - // Create a fake selection so that we can add a Base64-encoded copy of the - // fragment to the HTML, to decode on future pastes. - const domRange = ReactEditor.toDOMRange(e, selection) - let contents = domRange.cloneContents() - let attach = contents.childNodes[0] as HTMLElement - - // Make sure attach is non-empty, since empty nodes will not get copied. - contents.childNodes.forEach(node => { - if (node.textContent && node.textContent.trim() !== '') { - attach = node as HTMLElement - } - }) - - // COMPAT: If the end node is a void node, we need to move the end of the - // range from the void node's spacer span, to the end of the void node's - // content, since the spacer is before void's content in the DOM. - if (endVoid) { - const [voidNode] = endVoid - const r = domRange.cloneRange() - const domNode = ReactEditor.toDOMNode(e, voidNode) - r.setEndAfter(domNode) - contents = r.cloneContents() - } - - // COMPAT: If the start node is a void node, we need to attach the encoded - // fragment to the void node's content node instead of the spacer, because - // attaching it to empty `
/` nodes will end up having it erased by - // most browsers. (2018/04/27) - if (startVoid) { - attach = contents.querySelector('[data-slate-spacer]')! as HTMLElement - } - - // Remove any zero-width space spans from the cloned DOM so that they don't - // show up elsewhere when pasted. - Array.from(contents.querySelectorAll('[data-slate-zero-width]')).forEach( - zw => { - const isNewline = zw.getAttribute('data-slate-zero-width') === 'n' - zw.textContent = isNewline ? '\n' : '' - } - ) - - // Set a `data-slate-fragment` attribute on a non-empty node, so it shows up - // in the HTML, and can be used for intra-Slate pasting. If it's a text - // node, wrap it in a `` so we have something to set an attribute on. - if (isDOMText(attach)) { - const span = attach.ownerDocument.createElement('span') - // COMPAT: In Chrome and Safari, if we don't add the `white-space` style - // then leading and trailing spaces will be ignored. (2017/09/21) - span.style.whiteSpace = 'pre' - span.appendChild(attach) - contents.appendChild(span) - attach = span - } - - const fragment = e.getFragment() - const string = JSON.stringify(fragment) - const encoded = window.btoa(encodeURIComponent(string)) - attach.setAttribute('data-slate-fragment', encoded) - data.setData(`application/${clipboardFormatKey}`, encoded) - - // Add the content to a
so that we can get its inner HTML. - const div = contents.ownerDocument.createElement('div') - div.appendChild(contents) - div.setAttribute('hidden', 'true') - contents.ownerDocument.body.appendChild(div) - data.setData('text/html', div.innerHTML) - data.setData('text/plain', getPlainText(div)) - contents.ownerDocument.body.removeChild(div) - return data - } - - e.insertData = (data: DataTransfer) => { - if (!e.insertFragmentData(data)) { - e.insertTextData(data) - } - } - - e.insertFragmentData = (data: DataTransfer): boolean => { - /** - * Checking copied fragment from application/x-slate-fragment or data-slate-fragment - */ - const fragment = - data.getData(`application/${clipboardFormatKey}`) || - getSlateFragmentAttribute(data) - - if (fragment) { - const decoded = decodeURIComponent(window.atob(fragment)) - const parsed = JSON.parse(decoded) as Node[] - e.insertFragment(parsed) - return true - } - return false - } - - e.insertTextData = (data: DataTransfer): boolean => { - const text = data.getData('text/plain') - - if (text) { - const lines = text.split(/\r\n|\r|\n/) - let split = false - - for (const line of lines) { - if (split) { - Transforms.splitNodes(e, { always: true }) - } - - e.insertText(line) - split = true - } - return true - } - return false - } + const { onChange } = e e.onChange = options => { // COMPAT: React < 18 doesn't batch `setState` hook calls, which means @@ -373,24 +34,9 @@ export const withReact = ( : (callback: () => void) => callback() maybeBatchUpdates(() => { - const onContextChange = EDITOR_TO_ON_CHANGE.get(e) - - if (onContextChange) { - onContextChange(options) - } - onChange(options) }) } return e } - -const getMatches = (e: Editor, path: Path) => { - const matches: [Path, Key][] = [] - for (const [n, p] of Editor.levels(e, { at: path })) { - const key = ReactEditor.findKey(e, n) - matches.push([p, key]) - } - return matches -} diff --git a/packages/slate-react/src/utils/environment.ts b/packages/slate-react/src/utils/environment.ts index e6592ca606..6b070a6eb3 100644 --- a/packages/slate-react/src/utils/environment.ts +++ b/packages/slate-react/src/utils/environment.ts @@ -1,87 +1,3 @@ import React from 'react' export const REACT_MAJOR_VERSION = parseInt(React.version.split('.')[0], 10) - -export const IS_IOS = - typeof navigator !== 'undefined' && - typeof window !== 'undefined' && - /iPad|iPhone|iPod/.test(navigator.userAgent) && - !window.MSStream - -export const IS_APPLE = - typeof navigator !== 'undefined' && /Mac OS X/.test(navigator.userAgent) - -export const IS_ANDROID = - typeof navigator !== 'undefined' && /Android/.test(navigator.userAgent) - -export const IS_FIREFOX = - typeof navigator !== 'undefined' && - /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent) - -export const IS_WEBKIT = - typeof navigator !== 'undefined' && - /AppleWebKit(?!.*Chrome)/i.test(navigator.userAgent) - -// "modern" Edge was released at 79.x -export const IS_EDGE_LEGACY = - typeof navigator !== 'undefined' && - /Edge?\/(?:[0-6][0-9]|[0-7][0-8])(?:\.)/i.test(navigator.userAgent) - -export const IS_CHROME = - typeof navigator !== 'undefined' && /Chrome/i.test(navigator.userAgent) - -// Native `beforeInput` events don't work well with react on Chrome 75 -// and older, Chrome 76+ can use `beforeInput` though. -export const IS_CHROME_LEGACY = - typeof navigator !== 'undefined' && - /Chrome?\/(?:[0-7][0-5]|[0-6][0-9])(?:\.)/i.test(navigator.userAgent) - -export const IS_ANDROID_CHROME_LEGACY = - IS_ANDROID && - typeof navigator !== 'undefined' && - /Chrome?\/(?:[0-5]?\d)(?:\.)/i.test(navigator.userAgent) - -// Firefox did not support `beforeInput` until `v87`. -export const IS_FIREFOX_LEGACY = - typeof navigator !== 'undefined' && - /^(?!.*Seamonkey)(?=.*Firefox\/(?:[0-7][0-9]|[0-8][0-6])(?:\.)).*/i.test( - navigator.userAgent - ) - -// UC mobile browser -export const IS_UC_MOBILE = - typeof navigator !== 'undefined' && /.*UCBrowser/.test(navigator.userAgent) - -// Wechat browser (not including mac wechat) -export const IS_WECHATBROWSER = - typeof navigator !== 'undefined' && - /.*Wechat/.test(navigator.userAgent) && - !/.*MacWechat/.test(navigator.userAgent) // avoid lookbehind (buggy in safari < 16.4) - -// Check if DOM is available as React does internally. -// https://github.com/facebook/react/blob/master/packages/shared/ExecutionEnvironment.js -export const CAN_USE_DOM = !!( - typeof window !== 'undefined' && - typeof window.document !== 'undefined' && - typeof window.document.createElement !== 'undefined' -) - -// Check if the browser is Safari and older than 17 -export const IS_SAFARI_LEGACY = - typeof navigator !== 'undefined' && - /Safari/.test(navigator.userAgent) && - /Version\/(\d+)/.test(navigator.userAgent) && - (navigator.userAgent.match(/Version\/(\d+)/)?.[1] - ? parseInt(navigator.userAgent.match(/Version\/(\d+)/)?.[1]!, 10) < 17 - : false) - -// COMPAT: Firefox/Edge Legacy don't support the `beforeinput` event -// Chrome Legacy doesn't support `beforeinput` correctly -export const HAS_BEFORE_INPUT_SUPPORT = - (!IS_CHROME_LEGACY || !IS_ANDROID_CHROME_LEGACY) && - !IS_EDGE_LEGACY && - // globalThis is undefined in older browsers - typeof globalThis !== 'undefined' && - globalThis.InputEvent && - // @ts-ignore The `getTargetRanges` property isn't recognized. - typeof globalThis.InputEvent.prototype.getTargetRanges === 'function' diff --git a/packages/slate-react/tsconfig.json b/packages/slate-react/tsconfig.json index 8169990f2a..b1d714bb35 100644 --- a/packages/slate-react/tsconfig.json +++ b/packages/slate-react/tsconfig.json @@ -5,5 +5,12 @@ "rootDir": "./src", "outDir": "./lib" }, - "references": [{ "path": "../slate" }] + "references": [ + { + "path": "../slate" + }, + { + "path": "../slate-dom" + } + ] } diff --git a/yarn.lock b/yarn.lock index 391e1e7cc1..60a99b94bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13258,6 +13258,31 @@ __metadata: languageName: node linkType: hard +"slate-dom@npm:^0.110.2, slate-dom@workspace:packages/slate-dom": + version: 0.0.0-use.local + resolution: "slate-dom@workspace:packages/slate-dom" + dependencies: + "@babel/runtime": "npm:^7.23.2" + "@juggle/resize-observer": "npm:^3.4.0" + "@types/is-hotkey": "npm:^0.1.8" + "@types/jest": "npm:29.5.6" + "@types/jsdom": "npm:^21.1.4" + "@types/lodash": "npm:^4.14.200" + "@types/resize-observer-browser": "npm:^0.1.8" + direction: "npm:^1.0.4" + is-hotkey: "npm:^0.2.0" + is-plain-object: "npm:^5.0.0" + lodash: "npm:^4.17.21" + scroll-into-view-if-needed: "npm:^3.1.0" + slate: "npm:^0.110.2" + slate-hyperscript: "npm:^0.100.0" + source-map-loader: "npm:^4.0.1" + tiny-invariant: "npm:1.3.1" + peerDependencies: + slate: ">=0.99.0" + languageName: unknown + linkType: soft + "slate-history@workspace:*, slate-history@workspace:packages/slate-history": version: 0.0.0-use.local resolution: "slate-history@workspace:packages/slate-history" @@ -13391,6 +13416,7 @@ __metadata: react-dom: "npm:^18.2.0" scroll-into-view-if-needed: "npm:^3.1.0" slate: "npm:^0.110.2" + slate-dom: "npm:^0.110.2" slate-hyperscript: "npm:^0.100.0" source-map-loader: "npm:^4.0.1" tiny-invariant: "npm:1.3.1" @@ -13398,6 +13424,7 @@ __metadata: react: ">=18.2.0" react-dom: ">=18.2.0" slate: ">=0.99.0" + slate-dom: ">=0.110.2" languageName: unknown linkType: soft