diff --git a/packages/lexical-offset/src/__tests__/unit/OffsetView.test.ts b/packages/lexical-offset/src/__tests__/unit/OffsetView.test.ts new file mode 100644 index 00000000000..1199b93412d --- /dev/null +++ b/packages/lexical-offset/src/__tests__/unit/OffsetView.test.ts @@ -0,0 +1,486 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import {$createOffsetView, type OffsetView} from '@lexical/offset'; +import { + $getNodeByKey, + type EditorState, + type LexicalEditor, + type LexicalNode, +} from 'lexical'; + +import {NodeMapBuilder} from './helpers/NodeMapBuilder'; + +describe('OffsetView', () => { + describe('$createOffsetView', () => { + describe('internally produced offset map', () => { + it('should not contain the root node', () => { + const nodeMapBuilder = new NodeMapBuilder(); + const nodeMap = nodeMapBuilder + .addRootNode() + .addParagraphNode('paragraphNode') + .addTextNode('text', 'textNode') + .build(); + + const offsetView: OffsetView = $createOffsetView( + {} as LexicalEditor, + 1, + arrangeEditorState(nodeMap), + ); + + expect(offsetView).toBeTruthy(); + expect(offsetView._offsetMap.size).toEqual(2); + expect(offsetView._offsetMap.has('paragraphNode')).toBe(true); + expect(offsetView._offsetMap.has('textNode')).toBe(true); + }); + + describe('inline nodes', () => { + it('should have type "inline"', () => { + const nodeMapBuilder = new NodeMapBuilder(); + const nodeMap = nodeMapBuilder + .addRootNode() + .addParagraphNode() + .addLineBreakNode('inlineNode') + .build(); + + const offsetView: OffsetView = $createOffsetView( + {} as LexicalEditor, + 1, + arrangeEditorState(nodeMap), + ); + + const inlineOffsetNode = offsetView._offsetMap.get('inlineNode'); + expect(inlineOffsetNode?.type).toBe('inline'); + }); + + it('should have end being start plus 1', () => { + const nodeMapBuilder = new NodeMapBuilder(); + const nodeMap = nodeMapBuilder + .addRootNode() + .addParagraphNode() + .addLineBreakNode('inlineNode') + .build(); + + const offsetView: OffsetView = $createOffsetView( + {} as LexicalEditor, + 1, + arrangeEditorState(nodeMap), + ); + + const inlineOffsetNode = offsetView._offsetMap.get('inlineNode'); + expect(inlineOffsetNode?.start).toBe(0); + expect(inlineOffsetNode?.end).toBe(1); + }); + }); + + describe('element nodes', () => { + it('should have type "element"', () => { + const nodeMapBuilder = new NodeMapBuilder(); + const nodeMap = nodeMapBuilder + .addRootNode() + .addParagraphNode('elementNode') + .build(); + + const offsetView: OffsetView = $createOffsetView( + {} as LexicalEditor, + 1, + arrangeEditorState(nodeMap), + ); + + const elementOffsetNode = offsetView._offsetMap.get('elementNode'); + expect(elementOffsetNode?.type).toBe('element'); + }); + + it("should have end being greater than its last child node's end by 1", () => { + // Arrange + const nodeMapBuilder = new NodeMapBuilder(); + const nodeMap = nodeMapBuilder + .addRootNode() + .addParagraphNode('elementNode') + .addTextNodeOf(4, 'lastChildOfElementNode') + .addParagraphNode('elementNode2') + .addTextNodeOf(4) + .addLineBreakNode('lastChildOfElementNode2') + .build(); + + // Act + const offsetView: OffsetView = $createOffsetView( + {} as LexicalEditor, + 1, + arrangeEditorState(nodeMap), + ); + + // Assert + const elementOffsetNode = offsetView._offsetMap.get('elementNode'); + expect(elementOffsetNode?.end).toBe(5); + + const lastOffsetChildOfElementNode = offsetView._offsetMap.get( + 'lastChildOfElementNode', + ); + expect(lastOffsetChildOfElementNode?.end).toBe(4); + + const elementOffsetNode2 = offsetView._offsetMap.get('elementNode2'); + expect(elementOffsetNode2?.end).toBe(11); + + const lastOffsetChildOfElementNode2 = offsetView._offsetMap.get( + 'lastChildOfElementNode2', + ); + expect(lastOffsetChildOfElementNode2?.end).toBe(10); + }); + }); + + describe('text nodes', () => { + it('should have type "text"', () => { + const nodeMapBuilder = new NodeMapBuilder(); + const nodeMap = nodeMapBuilder + .addRootNode() + .addParagraphNode() + .addTextNodeOf(5, 'textNode') + .build(); + + const offsetView: OffsetView = $createOffsetView( + {} as LexicalEditor, + 1, + arrangeEditorState(nodeMap), + ); + + const textOffsetNode = offsetView._offsetMap.get('textNode'); + expect(textOffsetNode?.type).toBe('text'); + }); + + it('should have end being start plus length of text', () => { + // Arrange + const nodeMapBuilder = new NodeMapBuilder(); + const nodeMap = nodeMapBuilder + .addRootNode() + .addParagraphNode() + .addTextNodeOf(1, 'textNode') + .addTextNodeOf(4, 'textNode2') + .build(); + + // Act + const offsetView: OffsetView = $createOffsetView( + {} as LexicalEditor, + 1, + arrangeEditorState(nodeMap), + ); + + // Assert + const textOffsetNode = offsetView._offsetMap.get('textNode'); + expect(textOffsetNode?.start).toBe(0); + expect(textOffsetNode?.end).toBe(1); + + const textOffsetNode2 = offsetView._offsetMap.get('textNode2'); + expect(textOffsetNode2?.start).toBe(1); + expect(textOffsetNode2?.end).toBe(5); + }); + }); + + it('should have proper start and end offsets for nodes', () => { + // Arrange + const nodeMapBuilder = new NodeMapBuilder(); + const nodeMap = nodeMapBuilder + .addRootNode() + .addParagraphNode('paragraphNode1') + .addTextNodeOf(4, 'textNode1') + .addLineBreakNode('lineBreakNode1') + .addTextNodeOf(4, 'textNode2') + .build(); + + // Act + const offsetView: OffsetView = $createOffsetView( + {} as LexicalEditor, + 1, + arrangeEditorState(nodeMap), + ); + + // Assert + expect(offsetView).toBeTruthy(); + expect(offsetView._offsetMap.size).toEqual(4); + const paragraphOffsetNode = offsetView._offsetMap.get('paragraphNode1'); + expect(paragraphOffsetNode?.start).toBe(0); + expect(paragraphOffsetNode?.end).toBe(10); + + const textNode1 = offsetView._offsetMap.get('textNode1'); + expect(textNode1?.start).toBe(0); + expect(textNode1?.end).toBe(4); + + const lineBreakNode = offsetView._offsetMap.get('lineBreakNode1'); + expect(lineBreakNode?.start).toBe(4); + expect(lineBreakNode?.end).toBe(5); + + const textNode2 = offsetView._offsetMap.get('textNode2'); + expect(textNode2?.start).toBe(5); + expect(textNode2?.end).toBe(9); + }); + }); + }); + + describe('createSelectionFromOffsets', () => { + it('should return null when end offset is over text length', () => { + const nodeMapBuilder = new NodeMapBuilder(); + const nodeMap = nodeMapBuilder + .addRootNode() + .addParagraphNode() + .addTextNode('text') + .build(); + const offsetView: OffsetView = $arrangeOffsetView(nodeMap); + + const selection = offsetView.createSelectionFromOffsets(0, 6); + + expect(selection).toBeNull(); + }); + + it('should return null when start offset is over text length', () => { + const nodeMapBuilder = new NodeMapBuilder(); + const nodeMap = nodeMapBuilder + .addRootNode() + .addParagraphNode() + .addTextNode('text') + .build(); + const offsetView: OffsetView = $arrangeOffsetView(nodeMap); + + const selection = offsetView.createSelectionFromOffsets(6, 0); + + expect(selection).toBeNull(); + }); + + it('should return null when start node cannot be found by its key', () => { + const nodeMapBuilder = new NodeMapBuilder(); + const nodeMap = nodeMapBuilder + .addRootNode() + .addParagraphNode() + .addTextNode('text') + .addTextNode('some more text', 'textNodeCanBeFound') + .build(); + ($getNodeByKey as jest.Mock).mockImplementation((key) => { + if (key === 'textNodeCanBeFound') { + return nodeMap.get(key); + } + return null; + }); + const offsetView: OffsetView = $arrangeOffsetView(nodeMap, false); + + const selection = offsetView.createSelectionFromOffsets(0, 10); + + expect(selection).toBeNull(); + }); + + it('should return null when end node cannot be found by its key', () => { + const nodeMapBuilder = new NodeMapBuilder(); + const nodeMap = nodeMapBuilder + .addRootNode() + .addParagraphNode() + .addTextNode('text', 'textNodeCanBeFound') + .addTextNode('some more text') + .build(); + ($getNodeByKey as jest.Mock).mockImplementation((key) => { + if (key === 'textNodeCanBeFound') { + return nodeMap.get(key); + } + return null; + }); + const offsetView: OffsetView = $arrangeOffsetView(nodeMap, false); + + const selection = offsetView.createSelectionFromOffsets(0, 10); + + expect(selection).toBeNull(); + }); + + describe('returned selection', () => { + it('should have anchor being same as focus when start offset is same as end offset', () => { + const nodeMapBuilder = new NodeMapBuilder(); + const nodeMap = nodeMapBuilder + .addRootNode() + .addParagraphNode() + .addTextNode('text', 'targetNode') + .build(); + const offsetView: OffsetView = $arrangeOffsetView(nodeMap); + + const selection = offsetView.createSelectionFromOffsets(0, 0); + + expect(selection).toBeTruthy(); + + if (!selection) { + throw new Error('Selection is null'); + } + + expect(selection.anchor.key).toBe('targetNode'); + expect(selection.anchor.key).toEqual(selection.focus.key); + expect(selection.anchor.offset).toBe(0); + expect(selection.anchor.offset).toEqual(selection.focus.offset); + expect(selection.anchor.type).toBe('text'); + expect(selection.anchor.type).toEqual(selection.focus.type); + }); + + describe('input offsets point to an inline node', () => { + it('should "index" the containing element node', () => { + const nodeMapBuilder = new NodeMapBuilder(); + // \n + const nodeMap = nodeMapBuilder + .addRootNode() + .addParagraphNode('elementNode') + .addLineBreakNode('inlineNode') + .build(); + const offsetView: OffsetView = $arrangeOffsetView(nodeMap); + + const selection = offsetView.createSelectionFromOffsets(0, 0); + + expect(selection).toBeTruthy(); + + if (!selection) { + throw new Error('Selection is null'); + } + + expect(selection.anchor.key).toBe('elementNode'); + expect(selection.focus.key).toBe('elementNode'); + expect(selection.anchor.type).toBe('element'); + expect(selection.focus.type).toBe('element'); + expect(selection.anchor.offset).toBe(0); + expect(selection.focus.offset).toBe(0); + }); + + it('should have offset as "index" of inline node plus 1 when both input offsets point directly after the inline node', () => { + const nodeMapBuilder = new NodeMapBuilder(); + // xxxx\n + const nodeMap = nodeMapBuilder + .addRootNode() + .addParagraphNode('elementNode') + .addTextNodeOf(4) + .addLineBreakNode('inlineNode') + .build(); + const offsetView: OffsetView = $arrangeOffsetView(nodeMap); + + const selection = offsetView.createSelectionFromOffsets(5, 5); + + expect(selection).toBeTruthy(); + + if (!selection) { + throw new Error('Selection is null'); + } + + expect(selection.anchor.key).toBe('elementNode'); + expect(selection.focus.key).toBe('elementNode'); + expect(selection.anchor.type).toBe('element'); + expect(selection.focus.type).toBe('element'); + expect(selection.anchor.offset).toBe(2); + expect(selection.focus.offset).toBe(2); + }); + + it('should have adjacent anchor and focus offsets when input offsets are "selecting" a single inline node', () => { + const nodeMapBuilder = new NodeMapBuilder(); + // \n + const nodeMap = nodeMapBuilder + .addRootNode() + .addParagraphNode('elementNode') + .addLineBreakNode('inlineNode') + .build(); + const offsetView: OffsetView = $arrangeOffsetView(nodeMap); + + const selection = offsetView.createSelectionFromOffsets(0, 1); + + expect(selection).toBeTruthy(); + + if (!selection) { + throw new Error('Selection is null'); + } + + expect(selection.anchor.key).toBe('elementNode'); + expect(selection.focus.key).toBe('elementNode'); + expect(selection.anchor.type).toBe('element'); + expect(selection.focus.type).toBe('element'); + expect(selection.anchor.offset).toBe(0); + expect(selection.focus.offset).toBe(1); + }); + }); + + it('should index a text node when input offset points to a text node', () => { + const nodeMapBuilder = new NodeMapBuilder(); + const nodeMap = nodeMapBuilder + .addRootNode() + .addParagraphNode() + .addTextNodeOf(4, 'textNode') + .build(); + const offsetView: OffsetView = $arrangeOffsetView(nodeMap); + + const selection = offsetView.createSelectionFromOffsets(0, 4); + + expect(selection).toBeTruthy(); + + if (!selection) { + throw new Error('Selection is null'); + } + + expect(selection.anchor.key).toBe('textNode'); + expect(selection.focus.key).toBe('textNode'); + expect(selection.anchor.type).toBe('text'); + expect(selection.focus.type).toBe('text'); + expect(selection.anchor.offset).toBe(0); + expect(selection.focus.offset).toBe(4); + }); + }); + + // TODO Prefer text node and over next node start + }); +}); + +function $arrangeOffsetView( + nodeMap: Map, + doArrangeGetNodeByKey: boolean = true, +): OffsetView { + if (doArrangeGetNodeByKey) { + ($getNodeByKey as jest.Mock).mockImplementation((key) => { + return nodeMap.get(key); + }); + } + const editorState = arrangeEditorState(nodeMap); + const editor = {} as LexicalEditor; + const offsetView: OffsetView = $createOffsetView(editor, 1, editorState); + return offsetView; +} + +function arrangeEditorState(nodeMap: Map) { + return { + _nodeMap: nodeMap, + } as EditorState; +} + +jest.mock('lexical', () => { + const actual = jest.requireActual('lexical'); + return { + ...actual, + $createRangeSelection: jest.fn(() => { + // Have to do this as checks on PointType's set would require an active state... + const createPointStub = () => { + const pointStub = { + key: '', + offset: -1, + set: (key: string, offset: number, type: string) => {}, + type: 'element', + }; + pointStub.set = (key: string, offset: number, type: string) => { + pointStub.key = key; + pointStub.offset = offset; + pointStub.type = type; + }; + return pointStub; + }; + + return { + anchor: createPointStub(), + focus: createPointStub(), + }; + }), + $getNodeByKey: jest.fn(), + $isElementNode: jest.fn((node) => { + return node.__type === 'element' || node.__type === 'paragraph'; + }), + $isTextNode: jest.fn((node) => { + return node.__type === 'text'; + }), + }; +}); diff --git a/packages/lexical-offset/src/__tests__/unit/helpers/NodeMapBuilder.ts b/packages/lexical-offset/src/__tests__/unit/helpers/NodeMapBuilder.ts new file mode 100644 index 00000000000..2ff55a1d90b --- /dev/null +++ b/packages/lexical-offset/src/__tests__/unit/helpers/NodeMapBuilder.ts @@ -0,0 +1,139 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import { + type ElementNode, + type LexicalNode, + type LineBreakNode, + type ParagraphNode, + type RootNode, + type TextNode, +} from 'lexical'; + +export class NodeMapBuilder { + private nodeMap: Map = new Map(); + private rootNode: RootNode | null = null; + private paragraphKeyToChildren: Map = new Map(); + private currentParagraph: ParagraphNode | null = null; + + addRootNode(): NodeMapBuilder { + if (this.rootNode) { + throw new Error('Root node already exists'); + } + const key = 'root'; + const rootNode = { + ...this.createBaseStubNode('root', key), + __first: null, + } as RootNode; + this.nodeMap.set(key, rootNode); + this.rootNode = rootNode; + return this; + } + + addParagraphNode(key?: string): NodeMapBuilder { + if (!this.rootNode) { + throw new Error('Root node does not exist'); + } + + const paragraphNode = { + ...this.createBaseStubNode('paragraph', key), + __first: null, + } as ParagraphNode; + this.nodeMap.set(paragraphNode.__key, paragraphNode); + if (this.currentParagraph === null) { + this.rootNode.__first = paragraphNode.__key; + } else { + this.currentParagraph.__next = paragraphNode.__key; + } + + this.currentParagraph = paragraphNode; + this.paragraphKeyToChildren.set(paragraphNode.__key, []); + + return this; + } + + addTextNodeOf(length: number, key?: string): NodeMapBuilder { + const textContent = 'x'.repeat(length); + return this.addTextNode(textContent, key); + } + + addTextNode(text: string, key?: string): NodeMapBuilder { + if (this.currentParagraph === null) { + throw new Error('No paragraph to add text node to'); + } + + const textNode = { + ...this.createBaseStubNode('text', key), + __text: text, + getTextContentSize: () => text.length, + } as TextNode; + this.nodeMap.set(textNode.__key, textNode); + this.linkNode(textNode); + + return this; + } + + addLineBreakNode(key?: string): NodeMapBuilder { + if (this.currentParagraph === null) { + throw new Error('No paragraph to add line break node to'); + } + + const lineBreakNode = { + ...this.createBaseStubNode('lineBreak', key), + } as LineBreakNode; + this.nodeMap.set(lineBreakNode.__key, lineBreakNode); + this.linkNode(lineBreakNode); + + return this; + } + + build(): Map { + return this.nodeMap; + } + + private linkNode(newNode: LexicalNode) { + if (this.currentParagraph === null) { + throw new Error('No paragraph to link node to'); + } + + if (this.currentParagraph.__first === null) { + this.currentParagraph.__first = newNode.__key; + } + + const childrenOfContainingParagraph = this.paragraphKeyToChildren.get( + this.currentParagraph.__key, + ); + if (childrenOfContainingParagraph) { + const linkTo = childrenOfContainingParagraph.at(-1); + if (linkTo) { + linkTo.__next = newNode.__key; + } + + const indexWithinParent = childrenOfContainingParagraph.length; + newNode.getIndexWithinParent = () => indexWithinParent; + newNode.getParentOrThrow = (): T => + this.currentParagraph as unknown as T; + + childrenOfContainingParagraph.push(newNode); + } + } + + private createBaseStubNode(type: string, key?: string): LexicalNode { + key = key ?? `autoKey-${this.nodeMap.size + 1}-${type}`; + const node = { + __key: key, + __next: null, + __type: type, + getKey: () => key, + } as LexicalNode; + + node.getNextSibling = (): T | null => + node.__next ? (this.nodeMap.get(node.__next) as T) : null; + + return node; + } +} diff --git a/packages/lexical-offset/src/index.ts b/packages/lexical-offset/src/index.ts index db43070ba06..741abc36966 100644 --- a/packages/lexical-offset/src/index.ts +++ b/packages/lexical-offset/src/index.ts @@ -85,42 +85,31 @@ export class OffsetView { let start = originalStart; let end = originalEnd; + const isCollapsed = start === end; + let startOffsetNode = $searchForNodeWithOffset( firstNode, start, - this._blockOffsetSize, - ); - let endOffsetNode = $searchForNodeWithOffset( - firstNode, - end, - this._blockOffsetSize, + isCollapsed, ); + let endOffsetNode = $searchForNodeWithOffset(firstNode, end, true); + if (diffOffsetView !== undefined) { start = $getAdjustedOffsetFromDiff( start, startOffsetNode, diffOffsetView, this, - this._blockOffsetSize, - ); - startOffsetNode = $searchForNodeWithOffset( - firstNode, - start, - this._blockOffsetSize, ); + startOffsetNode = $searchForNodeWithOffset(firstNode, start); end = $getAdjustedOffsetFromDiff( end, endOffsetNode, diffOffsetView, this, - this._blockOffsetSize, - ); - endOffsetNode = $searchForNodeWithOffset( - firstNode, - end, - this._blockOffsetSize, ); + endOffsetNode = $searchForNodeWithOffset(firstNode, end); } if (startOffsetNode === null || endOffsetNode === null) { @@ -142,15 +131,21 @@ export class OffsetView { let endType: 'element' | 'text' = 'element'; if (startOffsetNode.type === 'text') { - startOffset = start - startOffsetNode.start; startType = 'text'; + const preferredTextNodeEndOverNextNodeStart = start > startOffsetNode.end; + startOffset = start - startOffsetNode.start; + if (preferredTextNodeEndOverNextNodeStart) { + startOffset--; + } + + // TODO check the following logic. // If we are at the edge of a text node and we // don't have a collapsed selection, then let's // try and correct the offset node. const sibling = startNode.getNextSibling(); if ( - start !== end && + !isCollapsed && startOffset === startNode.getTextContentSize() && $isTextNode(sibling) ) { @@ -158,20 +153,26 @@ export class OffsetView { startKey = sibling.__key; } } else if (startOffsetNode.type === 'inline') { + const indexWithinParent = startNode.getIndexWithinParent(); startKey = startNode.getParentOrThrow().getKey(); startOffset = - end > startOffsetNode.start - ? startOffsetNode.end - : startOffsetNode.start; + start === startOffsetNode.end + ? indexWithinParent + 1 + : indexWithinParent; // TODO can this happen in any ways? } if (endOffsetNode.type === 'text') { - endOffset = end - endOffsetNode.start; endType = 'text'; + const preferredTextNodeEndOverNextNodeStart = end > endOffsetNode.end; + endOffset = end - endOffsetNode.start; + if (preferredTextNodeEndOverNextNodeStart) { + endOffset--; + } } else if (endOffsetNode.type === 'inline') { + const indexWithinParent = endNode.getIndexWithinParent(); endKey = endNode.getParentOrThrow().getKey(); endOffset = - end > endOffsetNode.start ? endOffsetNode.end : endOffsetNode.start; + end === endOffsetNode.end ? indexWithinParent + 1 : indexWithinParent; } const selection = $createRangeSelection(); @@ -202,14 +203,20 @@ export class OffsetView { start = offsetNode.start + anchorOffset; } } else { - const node = anchor.getNode().getDescendantByIndex(anchorOffset); - - if (node !== null) { - const offsetNode = offsetMap.get(node.getKey()); - - if (offsetNode !== undefined) { - const isAtEnd = node.getIndexWithinParent() !== anchorOffset; - start = isAtEnd ? offsetNode.end : offsetNode.start; + const anchorNode = anchor.getNode(); + if (anchorNode.isEmpty()) { + const anchorOffsetNode = offsetMap.get(anchorNode.getKey()); + start = anchorOffsetNode ? anchorOffsetNode.start : -1; + } else { + const node = anchorNode.getDescendantByIndex(anchorOffset); + + if (node !== null) { + const offsetNode = offsetMap.get(node.getKey()); + + if (offsetNode !== undefined) { + const isAtEnd = node.getIndexWithinParent() !== anchorOffset; + start = isAtEnd ? offsetNode.end : offsetNode.start; + } } } } @@ -221,14 +228,20 @@ export class OffsetView { end = offsetNode.start + focus.offset; } } else { - const node = focus.getNode().getDescendantByIndex(focusOffset); - - if (node !== null) { - const offsetNode = offsetMap.get(node.getKey()); - - if (offsetNode !== undefined) { - const isAtEnd = node.getIndexWithinParent() !== focusOffset; - end = isAtEnd ? offsetNode.end : offsetNode.start; + const focusNode = focus.getNode(); + if (focusNode.isEmpty()) { + const focusOffsetNode = offsetMap.get(focusNode.getKey()); + end = focusOffsetNode ? focusOffsetNode.start : -1; + } else { + const node = focusNode.getDescendantByIndex(focusOffset); + + if (node !== null) { + const offsetNode = offsetMap.get(node.getKey()); + + if (offsetNode !== undefined) { + const isAtEnd = node.getIndexWithinParent() !== focusOffset; + end = isAtEnd ? offsetNode.end : offsetNode.start; + } } } } @@ -242,7 +255,6 @@ function $getAdjustedOffsetFromDiff( offsetNode: null | OffsetNode, prevOffsetView: OffsetView, offsetView: OffsetView, - blockOffsetSize: number, ): number { const prevOffsetMap = prevOffsetView._offsetMap; const offsetMap = offsetView._offsetMap; @@ -310,11 +322,7 @@ function $getAdjustedOffsetFromDiff( const prevFirstNode = prevOffsetView._firstNode; if (prevFirstNode !== null) { - currentNode = $searchForNodeWithOffset( - prevFirstNode, - offset, - blockOffsetSize, - ); + currentNode = $searchForNodeWithOffset(prevFirstNode, offset); let alreadyVisitedParentOfCurrentNode = false; while (currentNode !== null) { @@ -356,16 +364,12 @@ function $getAdjustedOffsetFromDiff( function $searchForNodeWithOffset( firstNode: OffsetNode, offset: number, - blockOffsetSize: number, + preferTextNodeEndOverNextNodeStart = false, ): OffsetNode | null { let currentNode = firstNode; while (currentNode !== null) { - const end = - currentNode.end + - (currentNode.type !== 'element' || blockOffsetSize === 0 ? 1 : 0); - - if (offset < end) { + if (offset < currentNode.end) { const child = currentNode.child; if (child !== null) { @@ -373,12 +377,31 @@ function $searchForNodeWithOffset( continue; } + return currentNode; + } else if (currentNode.start === offset) { + return currentNode; + } else if ( + preferTextNodeEndOverNextNodeStart && + currentNode.type === 'text' && + currentNode.end === offset + ) { + // We can think of a position on the 'edge' between two nodes as either: + // - end of first node + // or + // - start of the second node + // + // selecting via user input thinks as the end of first node when it comes to text nodes, so we adjust to that. + // Note, we only do this if preferTextNodeEndOverNextNodeStart is true, which should be the case when: + // - we are getting the endNode for a selection + // - we are getting the startNode for a selection and startOffset and endOffset are the same (collapsed selection) return currentNode; } const sibling = currentNode.next; - if (sibling === null) { + if (currentNode.end === offset) { + return currentNode; + } break; }