diff --git a/package.json b/package.json new file mode 100644 index 0000000000..0cfebf7763 --- /dev/null +++ b/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/src/custom-types.ts b/src/custom-types.ts new file mode 100644 index 0000000000..c3cd94773b --- /dev/null +++ b/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/src/index.ts b/src/index.ts new file mode 100644 index 0000000000..4742d7bc4d --- /dev/null +++ b/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/src/plugin/dom-editor.ts b/src/plugin/dom-editor.ts new file mode 100644 index 0000000000..a44160b285 --- /dev/null +++ b/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/src/plugin/with-dom.ts b/src/plugin/with-dom.ts new file mode 100644 index 0000000000..bf6efb7409 --- /dev/null +++ b/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/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 0000000000..a8a13e355d --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1 @@ +export const TRIPLE_CLICK = 3 diff --git a/src/utils/diff-text.ts b/src/utils/diff-text.ts new file mode 100644 index 0000000000..eeefde6c42 --- /dev/null +++ b/src/utils/diff-text.ts @@ -0,0 +1,426 @@ +import { + Editor, + Node, + Operation, + Path, + Point, + Range, + Text, + Element, +} from 'slate' +import { EDITOR_TO_PENDING_DIFFS } from './weak-maps' + +export type StringDiff = { + start: number + end: number + text: string +} + +export type TextDiff = { + id: number + path: Path + diff: StringDiff +} + +/** + * Check whether a text diff was applied in a way we can perform the pending action on / + * recover the pending selection. + */ +export function verifyDiffState(editor: Editor, textDiff: TextDiff): boolean { + const { path, diff } = textDiff + if (!Editor.hasPath(editor, path)) { + return false + } + + const node = Node.get(editor, path) + if (!Text.isText(node)) { + return false + } + + if (diff.start !== node.text.length || diff.text.length === 0) { + return ( + node.text.slice(diff.start, diff.start + diff.text.length) === diff.text + ) + } + + const nextPath = Path.next(path) + if (!Editor.hasPath(editor, nextPath)) { + return false + } + + const nextNode = Node.get(editor, nextPath) + return Text.isText(nextNode) && nextNode.text.startsWith(diff.text) +} + +export function applyStringDiff(text: string, ...diffs: StringDiff[]) { + return diffs.reduce( + (text, diff) => + text.slice(0, diff.start) + diff.text + text.slice(diff.end), + text + ) +} + +function longestCommonPrefixLength(str: string, another: string) { + const length = Math.min(str.length, another.length) + + for (let i = 0; i < length; i++) { + if (str.charAt(i) !== another.charAt(i)) { + return i + } + } + + return length +} + +function longestCommonSuffixLength( + str: string, + another: string, + max: number +): number { + const length = Math.min(str.length, another.length, max) + + for (let i = 0; i < length; i++) { + if ( + str.charAt(str.length - i - 1) !== another.charAt(another.length - i - 1) + ) { + return i + } + } + + return length +} + +/** + * Remove redundant changes from the diff so that it spans the minimal possible range + */ +export function normalizeStringDiff(targetText: string, diff: StringDiff) { + const { start, end, text } = diff + const removedText = targetText.slice(start, end) + + const prefixLength = longestCommonPrefixLength(removedText, text) + const max = Math.min( + removedText.length - prefixLength, + text.length - prefixLength + ) + const suffixLength = longestCommonSuffixLength(removedText, text, max) + + const normalized: StringDiff = { + start: start + prefixLength, + end: end - suffixLength, + text: text.slice(prefixLength, text.length - suffixLength), + } + + if (normalized.start === normalized.end && normalized.text.length === 0) { + return null + } + + return normalized +} + +/** + * Return a string diff that is equivalent to applying b after a spanning the range of + * both changes + */ +export function mergeStringDiffs( + targetText: string, + a: StringDiff, + b: StringDiff +): StringDiff | null { + const start = Math.min(a.start, b.start) + const overlap = Math.max( + 0, + Math.min(a.start + a.text.length, b.end) - b.start + ) + + const applied = applyStringDiff(targetText, a, b) + const sliceEnd = Math.max( + b.start + b.text.length, + a.start + + a.text.length + + (a.start + a.text.length > b.start ? b.text.length : 0) - + overlap + ) + + const text = applied.slice(start, sliceEnd) + const end = Math.max(a.end, b.end - a.text.length + (a.end - a.start)) + return normalizeStringDiff(targetText, { start, end, text }) +} + +/** + * Get the slate range the text diff spans. + */ +export function targetRange(textDiff: TextDiff): Range { + const { path, diff } = textDiff + return { + anchor: { path, offset: diff.start }, + focus: { path, offset: diff.end }, + } +} + +/** + * Normalize a 'pending point' a.k.a a point based on the dom state before applying + * the pending diffs. Since the pending diffs might have been inserted with different + * marks we have to 'walk' the offset from the starting position to ensure we still + * have a valid point inside the document + */ +export function normalizePoint(editor: Editor, point: Point): Point | null { + let { path, offset } = point + if (!Editor.hasPath(editor, path)) { + return null + } + + let leaf = Node.get(editor, path) + if (!Text.isText(leaf)) { + return null + } + + const parentBlock = Editor.above(editor, { + match: n => Element.isElement(n) && Editor.isBlock(editor, n), + at: path, + }) + + if (!parentBlock) { + return null + } + + while (offset > leaf.text.length) { + const entry = Editor.next(editor, { at: path, match: Text.isText }) + if (!entry || !Path.isDescendant(entry[1], parentBlock[1])) { + return null + } + + offset -= leaf.text.length + leaf = entry[0] + path = entry[1] + } + + return { path, offset } +} + +/** + * Normalize a 'pending selection' to ensure it's valid in the current document state. + */ +export function normalizeRange(editor: Editor, range: Range): Range | null { + const anchor = normalizePoint(editor, range.anchor) + if (!anchor) { + return null + } + + if (Range.isCollapsed(range)) { + return { anchor, focus: anchor } + } + + const focus = normalizePoint(editor, range.focus) + if (!focus) { + return null + } + + return { anchor, focus } +} + +export function transformPendingPoint( + editor: Editor, + point: Point, + op: Operation +): Point | null { + const pendingDiffs = EDITOR_TO_PENDING_DIFFS.get(editor) + const textDiff = pendingDiffs?.find(({ path }) => + Path.equals(path, point.path) + ) + + if (!textDiff || point.offset <= textDiff.diff.start) { + return Point.transform(point, op, { affinity: 'backward' }) + } + + const { diff } = textDiff + // Point references location inside the diff => transform the point based on the location + // the diff will be applied to and add the offset inside the diff. + if (point.offset <= diff.start + diff.text.length) { + const anchor = { path: point.path, offset: diff.start } + const transformed = Point.transform(anchor, op, { + affinity: 'backward', + }) + + if (!transformed) { + return null + } + + return { + path: transformed.path, + offset: transformed.offset + point.offset - diff.start, + } + } + + // Point references location after the diff + const anchor = { + path: point.path, + offset: point.offset - diff.text.length + diff.end - diff.start, + } + const transformed = Point.transform(anchor, op, { + affinity: 'backward', + }) + if (!transformed) { + return null + } + + if ( + op.type === 'split_node' && + Path.equals(op.path, point.path) && + anchor.offset < op.position && + diff.start < op.position + ) { + return transformed + } + + return { + path: transformed.path, + offset: transformed.offset + diff.text.length - diff.end + diff.start, + } +} + +export function transformPendingRange( + editor: Editor, + range: Range, + op: Operation +): Range | null { + const anchor = transformPendingPoint(editor, range.anchor, op) + if (!anchor) { + return null + } + + if (Range.isCollapsed(range)) { + return { anchor, focus: anchor } + } + + const focus = transformPendingPoint(editor, range.focus, op) + if (!focus) { + return null + } + + return { anchor, focus } +} + +export function transformTextDiff( + textDiff: TextDiff, + op: Operation +): TextDiff | null { + const { path, diff, id } = textDiff + + switch (op.type) { + case 'insert_text': { + if (!Path.equals(op.path, path) || op.offset >= diff.end) { + return textDiff + } + + if (op.offset <= diff.start) { + return { + diff: { + start: op.text.length + diff.start, + end: op.text.length + diff.end, + text: diff.text, + }, + id, + path, + } + } + + return { + diff: { + start: diff.start, + end: diff.end + op.text.length, + text: diff.text, + }, + id, + path, + } + } + case 'remove_text': { + if (!Path.equals(op.path, path) || op.offset >= diff.end) { + return textDiff + } + + if (op.offset + op.text.length <= diff.start) { + return { + diff: { + start: diff.start - op.text.length, + end: diff.end - op.text.length, + text: diff.text, + }, + id, + path, + } + } + + return { + diff: { + start: diff.start, + end: diff.end - op.text.length, + text: diff.text, + }, + id, + path, + } + } + case 'split_node': { + if (!Path.equals(op.path, path) || op.position >= diff.end) { + return { + diff, + id, + path: Path.transform(path, op, { affinity: 'backward' })!, + } + } + + if (op.position > diff.start) { + return { + diff: { + start: diff.start, + end: Math.min(op.position, diff.end), + text: diff.text, + }, + id, + path, + } + } + + return { + diff: { + start: diff.start - op.position, + end: diff.end - op.position, + text: diff.text, + }, + id, + path: Path.transform(path, op, { affinity: 'forward' })!, + } + } + case 'merge_node': { + if (!Path.equals(op.path, path)) { + return { + diff, + id, + path: Path.transform(path, op)!, + } + } + + return { + diff: { + start: diff.start + op.position, + end: diff.end + op.position, + text: diff.text, + }, + id, + path: Path.transform(path, op)!, + } + } + } + + const newPath = Path.transform(path, op) + if (!newPath) { + return null + } + + return { + diff, + path: newPath, + id, + } +} diff --git a/src/utils/dom.ts b/src/utils/dom.ts new file mode 100644 index 0000000000..4c6163d0b0 --- /dev/null +++ b/src/utils/dom.ts @@ -0,0 +1,357 @@ +/** + * Types. + */ + +// COMPAT: This is required to prevent TypeScript aliases from doing some very +// weird things for Slate's types with the same name as globals. (2019/11/27) +// https://github.com/microsoft/TypeScript/issues/35002 +import DOMNode = globalThis.Node +import DOMComment = globalThis.Comment +import DOMElement = globalThis.Element +import DOMText = globalThis.Text +import DOMRange = globalThis.Range +import DOMSelection = globalThis.Selection +import DOMStaticRange = globalThis.StaticRange +import { DOMEditor } from '../plugin/dom-editor' + +export { + DOMNode, + DOMComment, + DOMElement, + DOMText, + DOMRange, + DOMSelection, + DOMStaticRange, +} + +declare global { + interface Window { + Selection: (typeof Selection)['constructor'] + DataTransfer: (typeof DataTransfer)['constructor'] + Node: (typeof Node)['constructor'] + } +} + +export type DOMPoint = [Node, number] + +/** + * Returns the host window of a DOM node + */ + +export const getDefaultView = (value: any): Window | null => { + return ( + (value && value.ownerDocument && value.ownerDocument.defaultView) || null + ) +} + +/** + * Check if a DOM node is a comment node. + */ + +export const isDOMComment = (value: any): value is DOMComment => { + return isDOMNode(value) && value.nodeType === 8 +} + +/** + * Check if a DOM node is an element node. + */ + +export const isDOMElement = (value: any): value is DOMElement => { + return isDOMNode(value) && value.nodeType === 1 +} + +/** + * Check if a value is a DOM node. + */ + +export const isDOMNode = (value: any): value is DOMNode => { + const window = getDefaultView(value) + return !!window && value instanceof window.Node +} + +/** + * Check if a value is a DOM selection. + */ + +export const isDOMSelection = (value: any): value is DOMSelection => { + const window = value && value.anchorNode && getDefaultView(value.anchorNode) + return !!window && value instanceof window.Selection +} + +/** + * Check if a DOM node is an element node. + */ + +export const isDOMText = (value: any): value is DOMText => { + return isDOMNode(value) && value.nodeType === 3 +} + +/** + * Checks whether a paste event is a plaintext-only event. + */ + +export const isPlainTextOnlyPaste = (event: ClipboardEvent) => { + return ( + event.clipboardData && + event.clipboardData.getData('text/plain') !== '' && + event.clipboardData.types.length === 1 + ) +} + +/** + * Normalize a DOM point so that it always refers to a text node. + */ + +export const normalizeDOMPoint = (domPoint: DOMPoint): DOMPoint => { + let [node, offset] = domPoint + + // If it's an element node, its offset refers to the index of its children + // including comment nodes, so try to find the right text child node. + if (isDOMElement(node) && node.childNodes.length) { + let isLast = offset === node.childNodes.length + let index = isLast ? offset - 1 : offset + ;[node, index] = getEditableChildAndIndex( + node, + index, + isLast ? 'backward' : 'forward' + ) + // If the editable child found is in front of input offset, we instead seek to its end + isLast = index < offset + + // If the node has children, traverse until we have a leaf node. Leaf nodes + // can be either text nodes, or other void DOM nodes. + while (isDOMElement(node) && node.childNodes.length) { + const i = isLast ? node.childNodes.length - 1 : 0 + node = getEditableChild(node, i, isLast ? 'backward' : 'forward') + } + + // Determine the new offset inside the text node. + offset = isLast && node.textContent != null ? node.textContent.length : 0 + } + + // Return the node and offset. + return [node, offset] +} + +/** + * Determines whether the active element is nested within a shadowRoot + */ + +export const hasShadowRoot = (node: Node | null) => { + let parent = node && node.parentNode + while (parent) { + if (parent.toString() === '[object ShadowRoot]') { + return true + } + parent = parent.parentNode + } + return false +} + +/** + * Get the nearest editable child and index at `index` in a `parent`, preferring + * `direction`. + */ + +export const getEditableChildAndIndex = ( + parent: DOMElement, + index: number, + direction: 'forward' | 'backward' +): [DOMNode, number] => { + const { childNodes } = parent + let child = childNodes[index] + let i = index + let triedForward = false + let triedBackward = false + + // While the child is a comment node, or an element node with no children, + // keep iterating to find a sibling non-void, non-comment node. + while ( + isDOMComment(child) || + (isDOMElement(child) && child.childNodes.length === 0) || + (isDOMElement(child) && child.getAttribute('contenteditable') === 'false') + ) { + if (triedForward && triedBackward) { + break + } + + if (i >= childNodes.length) { + triedForward = true + i = index - 1 + direction = 'backward' + continue + } + + if (i < 0) { + triedBackward = true + i = index + 1 + direction = 'forward' + continue + } + + child = childNodes[i] + index = i + i += direction === 'forward' ? 1 : -1 + } + + return [child, index] +} + +/** + * Get the nearest editable child at `index` in a `parent`, preferring + * `direction`. + */ + +export const getEditableChild = ( + parent: DOMElement, + index: number, + direction: 'forward' | 'backward' +): DOMNode => { + const [child] = getEditableChildAndIndex(parent, index, direction) + return child +} + +/** + * Get a plaintext representation of the content of a node, accounting for block + * elements which get a newline appended. + * + * The domNode must be attached to the DOM. + */ + +export const getPlainText = (domNode: DOMNode) => { + let text = '' + + if (isDOMText(domNode) && domNode.nodeValue) { + return domNode.nodeValue + } + + if (isDOMElement(domNode)) { + for (const childNode of Array.from(domNode.childNodes)) { + text += getPlainText(childNode) + } + + const display = getComputedStyle(domNode).getPropertyValue('display') + + if (display === 'block' || display === 'list' || domNode.tagName === 'BR') { + text += '\n' + } + } + + return text +} + +/** + * Get x-slate-fragment attribute from data-slate-fragment + */ +const catchSlateFragment = /data-slate-fragment="(.+?)"/m +export const getSlateFragmentAttribute = ( + dataTransfer: DataTransfer +): string | void => { + const htmlData = dataTransfer.getData('text/html') + const [, fragment] = htmlData.match(catchSlateFragment) || [] + return fragment +} + +/** + * Get the x-slate-fragment attribute that exist in text/html data + * and append it to the DataTransfer object + */ +export const getClipboardData = ( + dataTransfer: DataTransfer, + clipboardFormatKey = 'x-slate-fragment' +): DataTransfer => { + if (!dataTransfer.getData(`application/${clipboardFormatKey}`)) { + const fragment = getSlateFragmentAttribute(dataTransfer) + if (fragment) { + const clipboardData = new DataTransfer() + dataTransfer.types.forEach(type => { + clipboardData.setData(type, dataTransfer.getData(type)) + }) + clipboardData.setData(`application/${clipboardFormatKey}`, fragment) + return clipboardData + } + } + return dataTransfer +} + +/** + * Get the dom selection from Shadow Root if possible, otherwise from the document + */ +export const getSelection = (root: Document | ShadowRoot): Selection | null => { + if (root.getSelection != null) { + return root.getSelection() + } + return document.getSelection() +} + +/** + * Check whether a mutation originates from a editable element inside the editor. + */ + +export const isTrackedMutation = ( + editor: DOMEditor, + mutation: MutationRecord, + batch: MutationRecord[] +): boolean => { + const { target } = mutation + if (isDOMElement(target) && target.matches('[contentEditable="false"]')) { + return false + } + + const { document } = DOMEditor.getWindow(editor) + if (document.contains(target)) { + return DOMEditor.hasDOMNode(editor, target, { editable: true }) + } + + const parentMutation = batch.find(({ addedNodes, removedNodes }) => { + for (const node of addedNodes) { + if (node === target || node.contains(target)) { + return true + } + } + + for (const node of removedNodes) { + if (node === target || node.contains(target)) { + return true + } + } + }) + + if (!parentMutation || parentMutation === mutation) { + return false + } + + // Target add/remove is tracked. Track the mutation if we track the parent mutation. + return isTrackedMutation(editor, parentMutation, batch) +} + +/** + * Retrieves the deepest active element in the DOM, considering nested shadow DOMs. + */ +export const getActiveElement = () => { + let activeElement = document.activeElement + + while (activeElement?.shadowRoot && activeElement.shadowRoot?.activeElement) { + activeElement = activeElement?.shadowRoot?.activeElement + } + + return activeElement +} + +/** + * @returns `true` if `otherNode` is before `node` in the document; otherwise, `false`. + */ +export const isBefore = (node: DOMNode, otherNode: DOMNode): boolean => + Boolean( + node.compareDocumentPosition(otherNode) & + DOMNode.DOCUMENT_POSITION_PRECEDING + ) + +/** + * @returns `true` if `otherNode` is after `node` in the document; otherwise, `false`. + */ +export const isAfter = (node: DOMNode, otherNode: DOMNode): boolean => + Boolean( + node.compareDocumentPosition(otherNode) & + DOMNode.DOCUMENT_POSITION_FOLLOWING + ) diff --git a/src/utils/environment.ts b/src/utils/environment.ts new file mode 100644 index 0000000000..a1a8bb5576 --- /dev/null +++ b/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/src/utils/hotkeys.ts b/src/utils/hotkeys.ts new file mode 100644 index 0000000000..c15491ab60 --- /dev/null +++ b/src/utils/hotkeys.ts @@ -0,0 +1,97 @@ +import { isHotkey } from 'is-hotkey' +import { IS_APPLE } from './environment' + +/** + * Hotkey mappings for each platform. + */ + +const HOTKEYS = { + bold: 'mod+b', + compose: ['down', 'left', 'right', 'up', 'backspace', 'enter'], + moveBackward: 'left', + moveForward: 'right', + moveWordBackward: 'ctrl+left', + moveWordForward: 'ctrl+right', + deleteBackward: 'shift?+backspace', + deleteForward: 'shift?+delete', + extendBackward: 'shift+left', + extendForward: 'shift+right', + italic: 'mod+i', + insertSoftBreak: 'shift+enter', + splitBlock: 'enter', + undo: 'mod+z', +} + +const APPLE_HOTKEYS = { + moveLineBackward: 'opt+up', + moveLineForward: 'opt+down', + moveWordBackward: 'opt+left', + moveWordForward: 'opt+right', + deleteBackward: ['ctrl+backspace', 'ctrl+h'], + deleteForward: ['ctrl+delete', 'ctrl+d'], + deleteLineBackward: 'cmd+shift?+backspace', + deleteLineForward: ['cmd+shift?+delete', 'ctrl+k'], + deleteWordBackward: 'opt+shift?+backspace', + deleteWordForward: 'opt+shift?+delete', + extendLineBackward: 'opt+shift+up', + extendLineForward: 'opt+shift+down', + redo: 'cmd+shift+z', + transposeCharacter: 'ctrl+t', +} + +const WINDOWS_HOTKEYS = { + deleteWordBackward: 'ctrl+shift?+backspace', + deleteWordForward: 'ctrl+shift?+delete', + redo: ['ctrl+y', 'ctrl+shift+z'], +} + +/** + * Create a platform-aware hotkey checker. + */ + +const create = (key: string) => { + const generic = HOTKEYS[key] + const apple = APPLE_HOTKEYS[key] + const windows = WINDOWS_HOTKEYS[key] + const isGeneric = generic && isHotkey(generic) + const isApple = apple && isHotkey(apple) + const isWindows = windows && isHotkey(windows) + + return (event: KeyboardEvent) => { + if (isGeneric && isGeneric(event)) return true + if (IS_APPLE && isApple && isApple(event)) return true + if (!IS_APPLE && isWindows && isWindows(event)) return true + return false + } +} + +/** + * Hotkeys. + */ + +export default { + isBold: create('bold'), + isCompose: create('compose'), + isMoveBackward: create('moveBackward'), + isMoveForward: create('moveForward'), + isDeleteBackward: create('deleteBackward'), + isDeleteForward: create('deleteForward'), + isDeleteLineBackward: create('deleteLineBackward'), + isDeleteLineForward: create('deleteLineForward'), + isDeleteWordBackward: create('deleteWordBackward'), + isDeleteWordForward: create('deleteWordForward'), + isExtendBackward: create('extendBackward'), + isExtendForward: create('extendForward'), + isExtendLineBackward: create('extendLineBackward'), + isExtendLineForward: create('extendLineForward'), + isItalic: create('italic'), + isMoveLineBackward: create('moveLineBackward'), + isMoveLineForward: create('moveLineForward'), + isMoveWordBackward: create('moveWordBackward'), + isMoveWordForward: create('moveWordForward'), + isRedo: create('redo'), + isSoftBreak: create('insertSoftBreak'), + isSplitBlock: create('splitBlock'), + isTransposeCharacter: create('transposeCharacter'), + isUndo: create('undo'), +} diff --git a/src/utils/key.ts b/src/utils/key.ts new file mode 100644 index 0000000000..0c8773312e --- /dev/null +++ b/src/utils/key.ts @@ -0,0 +1,18 @@ +/** + * An auto-incrementing identifier for keys. + */ + +let n = 0 + +/** + * A class that keeps track of a key string. We use a full class here because we + * want to be able to use them as keys in `WeakMap` objects. + */ + +export class Key { + id: string + + constructor() { + this.id = `${n++}` + } +} diff --git a/src/utils/lines.ts b/src/utils/lines.ts new file mode 100644 index 0000000000..f45fa02aa6 --- /dev/null +++ b/src/utils/lines.ts @@ -0,0 +1,75 @@ +/** + * Utilities for single-line deletion + */ + +import { Editor, Range } from 'slate' +import { DOMEditor } from '../plugin/dom-editor' + +const doRectsIntersect = (rect: DOMRect, compareRect: DOMRect) => { + const middle = (compareRect.top + compareRect.bottom) / 2 + + return rect.top <= middle && rect.bottom >= middle +} + +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) +} + +/** + * A helper utility that returns the end portion of a `Range` + * which is located on a single line. + * + * @param {Editor} editor The editor object to compare against + * @param {Range} parentRange The parent range to compare against + * @returns {Range} A valid portion of the parentRange which is one a single line + */ +export const findCurrentLineRange = ( + editor: DOMEditor, + parentRange: Range +): Range => { + const parentRangeBoundary = Editor.range(editor, Range.end(parentRange)) + const positions = Array.from(Editor.positions(editor, { at: parentRange })) + + let left = 0 + let right = positions.length + let middle = Math.floor(right / 2) + + if ( + areRangesSameLine( + editor, + Editor.range(editor, positions[left]), + parentRangeBoundary + ) + ) { + return Editor.range(editor, positions[left], parentRangeBoundary) + } + + if (positions.length < 2) { + return Editor.range( + editor, + positions[positions.length - 1], + parentRangeBoundary + ) + } + + while (middle !== positions.length && middle !== left) { + if ( + areRangesSameLine( + editor, + Editor.range(editor, positions[middle]), + parentRangeBoundary + ) + ) { + right = middle + } else { + left = middle + } + + middle = Math.floor((left + right) / 2) + } + + return Editor.range(editor, positions[right], parentRangeBoundary) +} diff --git a/src/utils/range-list.ts b/src/utils/range-list.ts new file mode 100644 index 0000000000..1c0b5e19c1 --- /dev/null +++ b/src/utils/range-list.ts @@ -0,0 +1,82 @@ +import { Range } from 'slate' +import { PLACEHOLDER_SYMBOL } from './weak-maps' + +export const shallowCompare = ( + obj1: { [key: string]: unknown }, + obj2: { [key: string]: unknown } +) => + Object.keys(obj1).length === Object.keys(obj2).length && + Object.keys(obj1).every( + key => obj2.hasOwnProperty(key) && obj1[key] === obj2[key] + ) + +const isDecorationFlagsEqual = (range: Range, other: Range) => { + const { anchor: rangeAnchor, focus: rangeFocus, ...rangeOwnProps } = range + const { anchor: otherAnchor, focus: otherFocus, ...otherOwnProps } = other + + return ( + range[PLACEHOLDER_SYMBOL] === other[PLACEHOLDER_SYMBOL] && + shallowCompare(rangeOwnProps, otherOwnProps) + ) +} + +/** + * Check if a list of decorator ranges are equal to another. + * + * PERF: this requires the two lists to also have the ranges inside them in the + * same order, but this is an okay constraint for us since decorations are + * kept in order, and the odd case where they aren't is okay to re-render for. + */ + +export const isElementDecorationsEqual = ( + list: Range[], + another: Range[] +): boolean => { + if (list.length !== another.length) { + return false + } + + for (let i = 0; i < list.length; i++) { + const range = list[i] + const other = another[i] + + if (!Range.equals(range, other) || !isDecorationFlagsEqual(range, other)) { + return false + } + } + + return true +} + +/** + * Check if a list of decorator ranges are equal to another. + * + * PERF: this requires the two lists to also have the ranges inside them in the + * same order, but this is an okay constraint for us since decorations are + * kept in order, and the odd case where they aren't is okay to re-render for. + */ + +export const isTextDecorationsEqual = ( + list: Range[], + another: Range[] +): boolean => { + if (list.length !== another.length) { + return false + } + + for (let i = 0; i < list.length; i++) { + const range = list[i] + const other = another[i] + + // compare only offsets because paths doesn't matter for text + if ( + range.anchor.offset !== other.anchor.offset || + range.focus.offset !== other.focus.offset || + !isDecorationFlagsEqual(range, other) + ) { + return false + } + } + + return true +} diff --git a/src/utils/types.ts b/src/utils/types.ts new file mode 100644 index 0000000000..c4d025c364 --- /dev/null +++ b/src/utils/types.ts @@ -0,0 +1,3 @@ +export type OmitFirstArg = F extends (x: any, ...args: infer P) => infer R + ? (...args: P) => R + : never diff --git a/src/utils/weak-maps.ts b/src/utils/weak-maps.ts new file mode 100644 index 0000000000..5e5b4451c3 --- /dev/null +++ b/src/utils/weak-maps.ts @@ -0,0 +1,98 @@ +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. + */ +export const IS_NODE_MAP_DIRTY: WeakMap = new WeakMap() +export const NODE_TO_INDEX: WeakMap = new WeakMap() +export const NODE_TO_PARENT: WeakMap = new WeakMap() + +/** + * Weak maps that allow us to go between Slate nodes and DOM nodes. These + * are used to resolve DOM event-related logic into Slate actions. + */ +export const EDITOR_TO_WINDOW: WeakMap = new WeakMap() +export const EDITOR_TO_ELEMENT: WeakMap = new WeakMap() +export const EDITOR_TO_PLACEHOLDER: WeakMap = new WeakMap() +export const EDITOR_TO_PLACEHOLDER_ELEMENT: WeakMap = + new WeakMap() +export const ELEMENT_TO_NODE: WeakMap = new WeakMap() +export const NODE_TO_ELEMENT: WeakMap = new WeakMap() +export const NODE_TO_KEY: WeakMap = new WeakMap() +export const EDITOR_TO_KEY_TO_ELEMENT: WeakMap< + Editor, + WeakMap +> = new WeakMap() + +/** + * Weak maps for storing editor-related state. + */ + +export const IS_READ_ONLY: WeakMap = new WeakMap() +export const IS_FOCUSED: WeakMap = new WeakMap() +export const IS_COMPOSING: WeakMap = new WeakMap() + +export const EDITOR_TO_USER_SELECTION: WeakMap = + new WeakMap() + +/** + * Weak map for associating the context `onChange` context with the plugin. + */ + +export const EDITOR_TO_ON_CHANGE = new WeakMap< + Editor, + (options?: { operation?: Operation }) => void +>() + +/** + * Weak maps for saving pending state on composition stage. + */ + +export const EDITOR_TO_SCHEDULE_FLUSH: WeakMap void> = + new WeakMap() + +export const EDITOR_TO_PENDING_INSERTION_MARKS: WeakMap< + Editor, + Partial | null +> = new WeakMap() + +export const EDITOR_TO_USER_MARKS: WeakMap | null> = + new WeakMap() + +/** + * Android input handling specific weak-maps + */ + +export const EDITOR_TO_PENDING_DIFFS: WeakMap = + new WeakMap() + +export const EDITOR_TO_PENDING_ACTION: WeakMap = + new WeakMap() + +export const EDITOR_TO_PENDING_SELECTION: WeakMap = + new WeakMap() + +export const EDITOR_TO_FORCE_RENDER: WeakMap void> = new WeakMap() + +/** + * Symbols. + */ + +export const PLACEHOLDER_SYMBOL = Symbol('placeholder') as unknown as string +export const MARK_PLACEHOLDER_SYMBOL = Symbol( + 'mark-placeholder' +) as unknown as string diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000..8169990f2a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../config/typescript/tsconfig.json", + "include": ["src/**/*"], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib" + }, + "references": [{ "path": "../slate" }] +}