From cfb38f503b6b55b9cdc924056fbb89ad6434ec79 Mon Sep 17 00:00:00 2001 From: Youngteac Hong Date: Sun, 6 Oct 2024 00:20:52 +0900 Subject: [PATCH] Represent linear offset in XML-like content using range This commit introduces offsetOf to express linear positioning in XML-like content using range. It allows for clear differentiation between content inside and outside of open and end tags, similar to the indexing system used in ProseMirror. --- .gitignore | 3 +++ src/editor.ts | 4 +++ src/plugins/devtools.ts | 4 ++- src/range.ts | 11 ++++++-- src/view/selection.ts | 47 +++++++++++++++++++++++----------- src/view/view.ts | 1 + test/view/selection.test.ts | 50 +++++++++++++------------------------ 7 files changed, 71 insertions(+), 49 deletions(-) diff --git a/.gitignore b/.gitignore index a547bf3..b503945 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +# __screenshots__ +__screenshots__ \ No newline at end of file diff --git a/src/editor.ts b/src/editor.ts index 6ff14f5..04cfcaa 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -114,6 +114,10 @@ export class Editor extends Observable> implements Module { } } + getModel(): Model { + return this.model; + } + getHistory(): History { return this.history; } diff --git a/src/plugins/devtools.ts b/src/plugins/devtools.ts index 8e83f54..a51b012 100644 --- a/src/plugins/devtools.ts +++ b/src/plugins/devtools.ts @@ -20,7 +20,9 @@ export class Devtools implements Module { initialize(editor: Editor) { this.unsubscribe = editor.subscribe(() => { - this.render(editor.getHistory().toJSON()); + this.render( + `v:${editor.getModel().getValue()}\nh:${editor.getHistory().toJSON()}`, + ); }); } diff --git a/src/range.ts b/src/range.ts index afb7b35..3d8bfba 100644 --- a/src/range.ts +++ b/src/range.ts @@ -1,5 +1,12 @@ -// TODO(hackerwins): For now, we define the Range type linearly. -// We need to define tree-like structure for the range. +/** + * `Range` represents the range of the content in the editor. It contains the + * start and end offsets of the content. The offset indicates the position of + * the xml-like content in the editor. + * + * e.g.) For `
HelloWorld!
`, positions are as follows: + * 0 1 6 7 8 14 15 + *
Hello World!
+ */ export type Range = { s: number; e: number; diff --git a/src/view/selection.ts b/src/view/selection.ts index 97e694a..46d6e19 100644 --- a/src/view/selection.ts +++ b/src/view/selection.ts @@ -1,37 +1,56 @@ import { Range } from '../range.ts'; +export type Position = [Node, number]; + /** * `offsetOf` returns the offset of the node in the container. */ -function offsetOf(node: Node, container: Node): number { - let offset = 0; - let current = node.previousSibling; +export function offsetOf(pos: Position, container: Node): number { + const [node, offset] = pos; - while (current) { - offset += current.textContent?.length || 0; - current = current.previousSibling; - } + let found = false; + function visit(n: Node): number { + if (n === node) { + found = true; + return offset; + } - if (node.parentNode !== container) { - if (!node.parentNode) { - throw new Error('node is not in the container'); + if (n.nodeType === Node.TEXT_NODE) { + return n.textContent!.length; } - return offset + offsetOf(node.parentNode, container); + // Add 1 for the open tag. + let sum = 1; + for (const child of n.childNodes) { + sum += visit(child); + if (found) { + return sum; + } + } + + // Add 1 for the close tag. + return sum + 1; + } + + // NOTE(hackerwins): Subtract 1 because the open tag of the container is not + // included in the offset. + const result = visit(container) - 1; + if (!found) { + throw new Error('node is not in the container'); } - return offset; + return result; } /** * `toRange` converts the abstract range to the range. */ export function toRange(range: AbstractRange, container: Node): Range { - const start = offsetOf(range.startContainer, container) + range.startOffset; + const start = offsetOf([range.startContainer, range.startOffset], container); if (range.collapsed) { return { s: start, e: start }; } - const end = offsetOf(range.endContainer, container) + range.endOffset; + const end = offsetOf([range.endContainer, range.endOffset], container); return { s: start, e: end }; } diff --git a/src/view/view.ts b/src/view/view.ts index 395302c..137ca13 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -34,6 +34,7 @@ export class View extends Observable implements Module { handleBeforeInput(event: InputEvent) { const text = this.getTextFromEvent(event); + // TODO(hackerwins): We need to capture enter key as well. const range = toRange(event.getTargetRanges()[0], this.container); this.notify({ t: 'e', diff --git a/test/view/selection.test.ts b/test/view/selection.test.ts index 13072c1..7363a65 100644 --- a/test/view/selection.test.ts +++ b/test/view/selection.test.ts @@ -1,41 +1,27 @@ import { describe, expect, it } from 'vitest'; -import { toRange } from '../../src/view/selection'; +import { offsetOf } from '../../src/view/selection'; -describe('toRange', () => { - it('should convert to the range', () => { - const container = document.createElement('div'); - container.innerHTML = 'Hello, World!'; - - const range = document.createRange(); - range.setStart(container.firstChild!, 1); - range.setEnd(container.firstChild!, 4); - - const result = toRange(range, container); - expect(result).toEqual({ s: 1, e: 4 }); +describe('offsetOf', () => { + it('should convert to the given position', () => { + const d = document.createElement('div'); + d.innerHTML = 'Hello World!'; + expect(offsetOf([d.firstChild!, 1], d)).toEqual(1); }); - it('should convert to the range in the nested element', () => { - const container = document.createElement('div'); - container.innerHTML = 'Hello, World!'; - - const range = document.createRange(); - range.setStart(container.firstChild!, 1); - range.setEnd(container.querySelector('b')!.firstChild!, 2); - - const result = toRange(range, container); - expect(result).toEqual({ s: 1, e: 9 }); + it('should convert to the given position in element', () => { + const d = document.createElement('div'); + d.innerHTML = 'Hello World!'; + expect(offsetOf([d.querySelector('b')!.firstChild!, 0], d)).toEqual(1); + expect(offsetOf([d.querySelector('i')!.firstChild!, 0], d)).toEqual(9); + expect(offsetOf([d.querySelector('i')!.firstChild!, 1], d)).toEqual(10); + expect(offsetOf([d.querySelector('i')!.nextSibling!, 0], d)).toEqual(15); }); it('should return error if the range is not in the container', () => { - const container = document.createElement('div'); - container.innerHTML = 'Hello, World!'; - - const range = document.createRange(); - range.setStart(container.firstChild!, 1); - range.setEnd(container.firstChild!, 4); - - expect(() => toRange(range, document.createElement('div'))).toThrowError( - 'node is not in the container', - ); + const d = document.createElement('div'); + d.innerHTML = 'Hello World!'; + expect(() => + offsetOf([d.firstChild!, 4], document.createElement('div')), + ).toThrowError('node is not in the container'); }); });