Skip to content

Commit

Permalink
Represent linear offset in XML-like content using range
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
hackerwins committed Oct 5, 2024
1 parent 3365f92 commit cfb38f5
Show file tree
Hide file tree
Showing 7 changed files with 71 additions and 49 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?

# __screenshots__
__screenshots__
4 changes: 4 additions & 0 deletions src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ export class Editor extends Observable<Array<Command>> implements Module {
}
}

getModel(): Model {
return this.model;
}

getHistory(): History<Command> {
return this.history;
}
Expand Down
4 changes: 3 additions & 1 deletion src/plugins/devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()}`,
);
});
}

Expand Down
11 changes: 9 additions & 2 deletions src/range.ts
Original file line number Diff line number Diff line change
@@ -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 `<div><b>Hello</b><s>World!</s></div>`, positions are as follows:
* 0 1 6 7 8 14 15
* <div> <b> Hello </b> <s> World! </s> </div>
*/
export type Range = {
s: number;
e: number;
Expand Down
47 changes: 33 additions & 14 deletions src/view/selection.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
1 change: 1 addition & 0 deletions src/view/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export class View extends Observable<Command> 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',
Expand Down
50 changes: 18 additions & 32 deletions test/view/selection.test.ts
Original file line number Diff line number Diff line change
@@ -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, <b>World</b>!';

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 = '<b>Hello</b> <i>World</i>!';
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');
});
});

0 comments on commit cfb38f5

Please sign in to comment.