From 9f4613bb30013e30f652b3851caa83579a8ce761 Mon Sep 17 00:00:00 2001 From: Youngteac Hong Date: Sat, 19 Oct 2024 21:19:04 +0900 Subject: [PATCH] Apply schema to model (#1) This commit enhances the editor to support schema management and improved text insertion logic. It also introduces new command structure for text operations. and it adds methods for node handling, utility functions, and a new schema for plain text. Implement comprehensive unit tests to ensure reliability. --- src/commands.ts | 48 ----------- src/commands/commands.ts | 48 +++++++++++ src/editor.ts | 30 ++++--- src/main.ts | 7 +- src/model/model.ts | 161 ++++++++++++++++++++++++++++++++---- src/model/node.ts | 12 --- src/model/nodepos.ts | 82 ++++++++++++++++++ src/model/nodes.ts | 129 +++++++++++++++++++++++++++++ src/model/operations.ts | 27 ++++++ src/model/range.ts | 8 ++ src/model/schema.ts | 85 +++++++++++++++++-- src/model/types.ts | 65 +++++++++++++++ src/plugins/devtools.ts | 2 +- src/range.ts | 13 --- src/utils/array.ts | 73 ++++++++++++++++ src/view/selection.ts | 77 ++++++++++++++++- src/view/view.ts | 26 +++--- test/model/helper.ts | 28 +++++++ test/model/model.test.ts | 39 +++++++++ test/model/nodepos.test.ts | 63 ++++++++++++++ test/model/nodes.test.ts | 36 ++++++++ test/model/schema.test.ts | 16 ++++ test/util/array.test.ts | 52 ++++++++++++ test/view/selection.test.ts | 35 +++++++- 24 files changed, 1033 insertions(+), 129 deletions(-) delete mode 100644 src/commands.ts create mode 100644 src/commands/commands.ts delete mode 100644 src/model/node.ts create mode 100644 src/model/nodepos.ts create mode 100644 src/model/nodes.ts create mode 100644 src/model/operations.ts create mode 100644 src/model/range.ts create mode 100644 src/model/types.ts delete mode 100644 src/range.ts create mode 100644 src/utils/array.ts create mode 100644 test/model/helper.ts create mode 100644 test/model/model.test.ts create mode 100644 test/model/nodepos.test.ts create mode 100644 test/model/nodes.test.ts create mode 100644 test/util/array.test.ts diff --git a/src/commands.ts b/src/commands.ts deleted file mode 100644 index 21b6882..0000000 --- a/src/commands.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Model } from './model/model'; - -export type Command = SetValue | Edit; - -/** - * `SetValue` is a command that sets the value of the model. - */ -export type SetValue = { - t: 'v'; - v: string; -}; - -/** - * `Edit` is a command that edits the value of the model. - */ -export type Edit = { - t: 'e'; - s: number; - e: number; - v: string; -}; - -/** - * `execute` executes the command on the model and returns inverse command. - */ -export function execute(model: Model, command: Command): Command { - switch (command.t) { - case 'v': - let prevValue = model.getValue(); - model.setValue(command.v); - return { - t: 'v', - v: prevValue, - }; - case 'e': - let value = model.getValue(command.s, command.e); - model.edit(command.s, command.e, command.v); - return { - t: 'e', - s: command.s, - e: command.s + command.v.length, - v: value, - }; - break; - default: - throw new Error(`Unknown command type: ${JSON.stringify(command)}`); - } -} diff --git a/src/commands/commands.ts b/src/commands/commands.ts new file mode 100644 index 0000000..464c8f7 --- /dev/null +++ b/src/commands/commands.ts @@ -0,0 +1,48 @@ +import { Model } from '../model/model'; +import { Range } from '../model/types'; +import { Operation } from '../model/operations'; + +export type Command = { + ops: Array; +}; + +export function insertText(range: Range, text: string): Command { + return { + ops: [ + { + type: 'edit', + range, + value: [{ type: 'text', text }], + }, + ], + }; +} + +export function splitBlock(range: Range): Command { + // TODO(hackerwins): Implement this according to the schema. + return { + ops: [ + { + type: 'edit', + range, + value: [{ type: 'p' }], + }, + ], + }; +} + +/** + * `execute` executes the command on the model and returns inverse command. + */ +export function execute(model: Model, command: Command): Command { + const ops = []; + + for (const op of command.ops) { + const inverse = model.apply(op); + ops.push(inverse); + } + + return { + ops: ops, + }; +} diff --git a/src/editor.ts b/src/editor.ts index 4c0b508..4ea4bad 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -4,13 +4,15 @@ import { Observable, Unsubscribe } from './utils/observable'; import { View as View } from './view/view'; import { Model } from './model/model'; -import { Command, execute } from './commands'; +import { Command, execute, insertText } from './commands/commands'; +import { SchemaSpec, BasicSchema } from './model/schema'; /** * `EditorOptions` is an object that contains the initial value of the editor * and the plugins that should be initialized. */ type EditorOptions = { + schema: SchemaSpec; initialValue?: string; plugins?: Array; }; @@ -28,7 +30,10 @@ export class Editor extends Observable> implements Module { private plugins: Array; - static create(container: HTMLDivElement, opts: EditorOptions = {}) { + static create( + container: HTMLDivElement, + opts: EditorOptions = { schema: BasicSchema }, + ) { const editor = new Editor(container, opts); editor.initialize(); return editor; @@ -38,7 +43,7 @@ export class Editor extends Observable> implements Module { super(); this.view = View.create(container); - this.model = Model.create(opts.initialValue || ''); + this.model = Model.create(opts.schema, opts.initialValue || ''); this.history = new History((command) => execute(this.model, command), ); @@ -50,7 +55,7 @@ export class Editor extends Observable> implements Module { initialize() { // 01. Initialize the view with the model's content. - this.view.setValue(this.model.getValue()); + this.view.setValue(this.model.toXML()); // 02. Upstream: view creates commands and then the editor executes them. this.unsubscribes.push( @@ -63,13 +68,14 @@ export class Editor extends Observable> implements Module { // 03. Downstream: If the model changes, the view should be updated. this.unsubscribes.push( - this.model.subscribe((value) => { + this.model.subscribe(() => { // Prevent downstream updates if the view is the source of change. if (this.isUpstream) { return; } - this.view.setValue(value); + // TODO(hackerwins): We need to optimize this part. + this.view.setValue(this.model.toXML()); }), ); @@ -126,11 +132,11 @@ export class Editor extends Observable> implements Module { * TODO(hackerwins): Find a better way to provide APIs. */ insertText(text: string) { - this.execute({ - t: 'e', - s: this.model.getValue().length, - e: this.model.getValue().length, - v: text, - }); + let range = this.view.getSelection(); + if (!range) { + range = this.model.getContentEndRange(); + } + + this.execute(insertText(range, text)); } } diff --git a/src/main.ts b/src/main.ts index b43787e..deca03f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,7 +6,12 @@ import { Devtools } from './plugins/devtools'; const editor = Editor.create( document.querySelector('#editor')!, { - initialValue: 'Hello,', + initialValue: '

Hello,

', + schema: { + root: { children: 'p*' }, + p: { children: 'text*' }, + text: {}, + }, plugins: [ Toolbar.create(document.querySelector('#toolbar')!, { buttons: ['destroy', 'undo', 'redo'], diff --git a/src/model/model.ts b/src/model/model.ts index e3deb57..3c51fdc 100644 --- a/src/model/model.ts +++ b/src/model/model.ts @@ -1,33 +1,160 @@ import { Observable } from '../utils/observable'; +import { Node, Text, Element, Range, NodePos } from './types'; +import { Schema, SchemaSpec } from './schema'; +import { Operation } from './operations'; +import { + insertAfter, + insertBefore, + lengthOf, + pathOf, + removeNode, + splitText, + toXML, +} from './nodes'; +import { firstOf, lastOf } from '../utils/array'; +import { isLeftMost, toNodePos, nodesBetween } from './nodepos'; +import { isCollapsed } from './range'; -// TODO(hackerwins): Build a tree-based model with schema validation. -export class Model extends Observable { - private value: string; +export class Model extends Observable> { + private schema: Schema; + private root: Element; - static create(initialValue: string): Model { - return new Model(initialValue); + static create(spec: SchemaSpec, initialValue: string): Model { + const schema = new Schema(spec); + const value = schema.fromXML(initialValue); + return new Model(schema, value); } - constructor(value: string) { + constructor(schema: Schema, value: Node) { super(); - this.value = value; + this.schema = schema; + this.root = value; } - setValue(value: string): void { - this.value = value; - this.notify(this.value); + createText(value: string): Text { + return this.schema.create('text', value) as Text; } - getValue(from?: number, to?: number): string { - if (from !== undefined && to !== undefined) { - return this.value.slice(from, to); + toXML(): string { + return toXML(this.root); + } + + apply(op: Operation): Operation { + let inverse: Operation; + switch (op.type) { + case 'edit': + inverse = this.edit(op.range, op.value); + this.notify([op]); + return inverse; + case 'move': + inverse = this.move(/*op.source, op.target*/); + this.notify([op]); + return inverse; + default: + throw new Error(`invalid operation type: ${op}`); + } + } + + edit(range: Range, values: Array): Operation { + // NOTE(hackerwins): To keep the node of the position, we need to split the + // text node at the end of the range. + const end = this.nodePosOf(range.e); + const start = isCollapsed(range) ? end : this.nodePosOf(range.s); + + const nodesToRemove: Array = []; + for (const [node, type] of nodesBetween(this.root, start, end)) { + if (type === 'open') { + continue; + } + + nodesToRemove.push(node); + } + + for (const node of nodesToRemove) { + removeNode(node); + } + + const hasValues = values.length > 0; + if (hasValues) { + if (isLeftMost(start)) { + insertBefore(start.node, ...values); + } else { + insertAfter(start.node, ...values); + } + } + + return { + type: 'edit', + range: hasValues ? this.rangeOf(values) : { s: range.s, e: range.s }, + value: nodesToRemove, + }; + } + + /** + * `rangeOf` returns the range of the given nodes in the model. + */ + rangeOf(values: Array): Range { + // TODO(hackerwins): This is a naive implementation that assumes the first + // and last nodes are always text nodes. We need to handle the case where + // the first and last nodes are elements. + const start = pathOf(firstOf(values)!, this.root); + start.push(0); + + const end = pathOf(lastOf(values)!, this.root); + end.push(lengthOf(lastOf(values)!)); + + return { s: start, e: end }; + } + + /** + * `nodePosOf` returns the node position of the given path. + */ + nodePosOf(path: Array, withSplitText: boolean = true): NodePos { + const pos = toNodePos(path, this.root); + if (!withSplitText) { + return pos; + } + + const newNode = splitText(pos); + if (!newNode) { + return pos; + } + + return { + node: newNode, + offset: 0, + }; + } + + /** + * `getContentEndRange` returns the range of the end of the content in the + * model. + */ + getContentEndRange(): Range { + const path = []; + + let node = this.root; + while (node) { + if (node.type === 'text') { + const text = node as Text; + path.push(text.text.length); + break; + } + + const elem = node as Element; + const children = elem.children || []; + if (children.length === 0) { + break; + } + + path.push(children.length - 1); + node = lastOf(children)!; } - return this.value; + return { s: path, e: path }; } - edit(start: number, end: number, text: string): void { - this.value = this.value.slice(0, start) + text + this.value.slice(end); - this.notify(this.value); + move(/*source: Range, target: Range*/): Operation { + throw new Error('Method not implemented.'); } } diff --git a/src/model/node.ts b/src/model/node.ts deleted file mode 100644 index 88eb31f..0000000 --- a/src/model/node.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type Node = Element | Text; - -export type Element = { - type: string; - attrs: Record; - children: Array; -}; - -export type Text = { - type: 'text'; - text: string; -}; diff --git a/src/model/nodepos.ts b/src/model/nodepos.ts new file mode 100644 index 0000000..9ed6ea3 --- /dev/null +++ b/src/model/nodepos.ts @@ -0,0 +1,82 @@ +import { NodePos, Path, Node, Element } from "./types"; +import { lengthOf, TagType } from "./nodes"; +import { initialOf, lastOf } from "../utils/array"; + +/** + * `toNodePos` converts the path to the node position. + */ +export function toNodePos(path: Path, container: Element): NodePos { + let node = container; + + for (const offset of initialOf(path)) { + node = node.children![offset]; + } + + return { node, offset: lastOf(path)! }; +} + +/** + * `nodesBetween` iterates over the nodes between the start and end positions. + */ +export function* nodesBetween( + container: Element, + start: NodePos, + end: NodePos, +): Generator<[Node, TagType]> { + let inRange = false; + function* traverse(node: Node): Generator<[Node, TagType]> { + if (node === start.node) { + inRange = true; + } + + if (node.type === 'text') { + if (inRange) { + yield [node, 'text']; + } + } else { + const elem = node as Element; + for (const child of elem.children || []) { + if (child.type !== 'text' && inRange) { + yield [child, 'open']; + } + + yield* traverse(child); + + if (child.type !== 'text' && inRange) { + yield [child, 'close']; + } + } + } + + if (node === end.node) { + inRange = false; + } + } + + if (equals(start, end)) { + return; + } + + yield* traverse(container); +} + +/** + * `isLeftMost` checks if the node position is the leftmost position of the node. + */ +export function isLeftMost(pos: NodePos): boolean { + return pos.offset === 0; +} + +/** + * `isRightMost` checks if the node position is the rightmost position of the node. + */ +export function isRightMost(pos: NodePos): boolean { + return pos.offset === lengthOf(pos.node); +} + +/** + * `equals` checks if two node positions are equal. + */ +export function equals(a: NodePos, b: NodePos): boolean { + return a.node === b.node && a.offset === b.offset; +} diff --git a/src/model/nodes.ts b/src/model/nodes.ts new file mode 100644 index 0000000..cc601ad --- /dev/null +++ b/src/model/nodes.ts @@ -0,0 +1,129 @@ +import { Node, Text, Element, NodePos, Path } from './types'; + +/** + * `TagType` represents the type of the tag. It can be 'open', 'close', or + * 'text'. + */ +export type TagType = 'open' | 'close' | 'text'; + +/** + * `toXML` converts the node to the XML string. + */ +export function toXML(node: Node): string { + if (node.type === 'text') { + const n = node as Text; + return n.text; + } + + const elem = node as Element; + + const children = elem.children?.map(toXML).join(''); + return `<${elem.type}>${children}`; +} + +/** + * `pathOf` returns the path of the node from the container. + */ +export function pathOf(node: Node, container: Element): Path { + const path: Path = []; + let current = node; + + while (current !== container) { + if (!current.parent || !current.parent.children) { + throw new Error('node is not in the container'); + } + + path.unshift(current.parent.children.indexOf(current)); + current = current.parent; + } + + return path; +} + +export function lengthOf(node: Node): number { + if (node.type === 'text') { + return (node as Text).text.length; + } + + const elem = node as Element; + return elem.children ? elem.children.length : 0; +} + +/** + * `splitText` splits the text node at the given position. + */ +export function splitText(pos: NodePos): Text | undefined { + if (pos.node.type !== 'text') { + return; + } + + const text = pos.node as Text; + + if (pos.offset === 0 || pos.offset === text.text.length) { + return; + } + + const prev = text.text.slice(0, pos.offset); + const next = text.text.slice(pos.offset); + + text.text = prev; + const newNode = { type: 'text', text: next }; + return insertAfter(text, newNode)[0] as Text; +} + +/** + * `insertBefore` inserts the new nodes before the next node. + */ +export function insertBefore( + next: Node, + ...newNodes: Array +): Array { + const parent = next.parent; + if (!parent) { + throw new Error('node does not have a parent'); + } + + const index = parent.children!.indexOf(next); + parent.children!.splice(index, 0, ...newNodes); + for (const node of newNodes) { + node.parent = parent; + } + + return newNodes; +} + +/** + * `insertAfter` inserts the new nodes after the previous node. + */ +export function insertAfter(prev: Node, ...newNodes: Array): Array { + const parent = prev.parent; + if (!parent) { + throw new Error('node does not have a parent'); + } + + const index = parent.children!.indexOf(prev); + parent.children!.splice(index + 1, 0, ...newNodes); + for (const node of newNodes) { + node.parent = parent; + } + + return newNodes; +} + +/** + * `removeNode` removes the node from the parent. + */ +export function removeNode(node: Node) { + const parent = node.parent; + if (!parent) { + throw new Error('node does not have a parent'); + } + + const children = parent.children!; + const index = children.indexOf(node); + if (index === -1) { + throw new Error('node is not in the parent'); + } + + children.splice(index, 1); +} diff --git a/src/model/operations.ts b/src/model/operations.ts new file mode 100644 index 0000000..5df189c --- /dev/null +++ b/src/model/operations.ts @@ -0,0 +1,27 @@ +import { Node, Range } from './types'; + +/** + * `Operation` represents a operation that can be applied to the content in the + * editor. + */ +export type Operation = EditOperation | MoveOperation; + +/** + * `ReplaceOperation` represents a operation that replaces a range of nodes with + * another range of nodes. + */ +export type EditOperation = { + type: 'edit'; + range: Range; + value: Array; +}; + +/** + * `MoveOperation` represents a operation that moves a range of nodes to another + * position. + */ +export type MoveOperation = { + type: 'move'; + source: Range; + target: Range; +}; diff --git a/src/model/range.ts b/src/model/range.ts new file mode 100644 index 0000000..b003ea0 --- /dev/null +++ b/src/model/range.ts @@ -0,0 +1,8 @@ +import { Range } from "./types"; +/** + * `isCollapsed` checks if the range is collapsed. + */ +export function isCollapsed(range: Range): boolean { + return range.s.length === range.e.length && + range.s.every((s, i) => s === range.e[i]); +} diff --git a/src/model/schema.ts b/src/model/schema.ts index 0f783c6..cc269e7 100644 --- a/src/model/schema.ts +++ b/src/model/schema.ts @@ -1,13 +1,25 @@ -import { Node } from './node'; +import { Node as NoteNode, Element as NoteElement } from './types'; -type SchemaSpec = Record; +/** + * `SchemaSpec` is a type that defines the structure of the schema. + */ +export type SchemaSpec = Record; type NodeSpec = ElementSpec | TextNodeSpec; type ElementSpec = { children: string }; -type TextNodeSpec = {}; +type TextNodeSpec = Record; + +/** + * `BasicSchema` represents basic schema that represents a simple text editor. + */ +export const BasicSchema: SchemaSpec = { + root: { children: 'p+' }, + p: { children: 'text*' }, + text: {}, +}; /** * `Schema` is a class that defines the structure of the model. It is used to - * validate the model and to build the model from JSON. + * validate the model and to build the model from DOM or JSON or XML. */ export class Schema { private spec: SchemaSpec; @@ -17,6 +29,18 @@ export class Schema { this.spec = spec; } + create(type: string, value: string): NoteNode { + if (!this.spec[type]) { + throw new Error(`invalid node type: ${type}`); + } + + if (type === 'text') { + return { type: 'text', text: value }; + } + + throw new Error('not implemented'); + } + validate(schemaSpec: SchemaSpec): void { if (!schemaSpec.root) { throw new Error('root node not defined'); @@ -42,7 +66,7 @@ export class Schema { return [children]; } - fromJSON(json: any): Node { + fromJSON(json: any): NoteNode { if (json.type === 'text') { return { type: 'text', @@ -53,10 +77,61 @@ export class Schema { if (!this.spec[json.type]) { throw new Error(`invalid node type: ${json.type}`); } + return { type: json.type, attrs: json.attributes, children: json.children.map((child: any) => this.fromJSON(child)), }; } + + fromDOM(domNode: Node): NoteNode { + const spec = this.spec; + + function fromDOM(domNode: Node): NoteNode { + if (domNode.nodeType === Node.TEXT_NODE) { + return { + type: 'text', + text: domNode.textContent || '', + }; + } + + if (domNode.nodeType !== Node.ELEMENT_NODE) { + throw new Error(`invalid node type: ${domNode.nodeType}`); + } + if (!spec[domNode.nodeName.toLowerCase()]) { + throw new Error(`invalid node type: ${domNode.nodeName}`); + } + + const children = Array.from(domNode.childNodes).map(fromDOM); + const node = { + type: domNode.nodeName.toLowerCase(), + children, + } as NoteElement; + for (const child of children) { + child.parent = node; + } + + const domElem = domNode as Element; + if (domElem.attributes.length) { + node.attrs = Array.from(domElem.attributes).reduce( + (attrs, attr) => { + attrs[attr.name] = attr.value; + return attrs; + }, + {} as Record, + ); + } + + return node; + } + + return fromDOM(domNode); + } + + fromXML(xml: string): NoteNode { + const parser = new DOMParser(); + const doc = parser.parseFromString(xml, 'application/xml'); + return this.fromDOM(doc.documentElement); + } } diff --git a/src/model/types.ts b/src/model/types.ts new file mode 100644 index 0000000..c4b94a0 --- /dev/null +++ b/src/model/types.ts @@ -0,0 +1,65 @@ +/** + * `Node` represents the node in the model. + */ +export type Node = Text | Element; + +/** + * `Element` represents the element node in the model. + */ +export type Element = { + type: string; + parent?: Element; + attrs?: Record; + children?: Array; +}; + +/** + * `Text` represents the text node in the model. + */ +export type Text = { + type: 'text'; + parent?: Element; + text: string; +}; + +/** + * Path represents the position of the content in the editor without direct + * node reference. + * + * e.g.) For `
HelloWorld!
` + * `[1]` of `
` means the position between `` and `` + * `[0, 0, 0]` of `
` means the leftmost position of "Hello" + * `[0, 0, 1]` of `
` means the position between "H" and "e" + */ +export type Path = Array; + +/** + * `Range` represents the range of the content in the editor. + */ +export type Range = { + s: Path; + e: Path; +}; + +/** + * `NodePos` represents the position of the node in the model. It can be converted + * to `Path`. + */ +export type NodePos = { + node: Node; + offset: number; +}; + +/** + * `IndexRange` represents the range of the content in the editor. It contains the + * start and end indexes of the content. The index 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 IndexRange = { + s: number; + e: number; +}; diff --git a/src/plugins/devtools.ts b/src/plugins/devtools.ts index d6752b2..958dcb9 100644 --- a/src/plugins/devtools.ts +++ b/src/plugins/devtools.ts @@ -21,7 +21,7 @@ export class Devtools implements Module { initialize(editor: Editor) { this.unsubscribe = editor.subscribe(() => { this.render( - `${editor.getModel().getValue()}\n${editor.getHistory().toJSON()}`, + `${editor.getModel().toXML()}\n${editor.getHistory().toJSON()}`, ); }); } diff --git a/src/range.ts b/src/range.ts deleted file mode 100644 index 3d8bfba..0000000 --- a/src/range.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * `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/utils/array.ts b/src/utils/array.ts new file mode 100644 index 0000000..1e2a52a --- /dev/null +++ b/src/utils/array.ts @@ -0,0 +1,73 @@ +/** + * `headOf` returns the first element of an array, or `undefined` if the array is + * empty. + */ +export function firstOf(array: Array): T | undefined { + return array[0]; +} + +/** + * `lastOf` returns the last element of an array, or `undefined` if the array is + * empty. + */ +export function lastOf(array: Array): T | undefined { + return array[array.length - 1]; +} + +/** + * `initialOf` returns all elements of an array except the last one. + */ +export function initialOf(array: Array): Array { + return array.slice(0, array.length - 1); +} + +/** + * `tailOf` returns all elements of an array except the first one. + */ +export function tailOf(array: Array): Array { + return array.slice(1); +} + +/** + * `groupBy` groups adjacent elements of an array according to a given function. + */ +export function groupBy( + array: Array, + fn: (prev: T, curr: T) => boolean, +): Array> { + if (!array.length) { + return []; + } + + const tail = array.slice(1); + return tail.reduce( + (memo: Array>, v: T): Array> => { + const prevGroup = memo[memo.length - 1]; + if (fn(prevGroup[prevGroup.length - 1], v)) { + prevGroup[prevGroup.length] = v; + } else { + memo[memo.length] = [v]; + } + return memo; + }, + [[array[0]]], + ); +} + +/** + * `takeWhile` takes elements from the beginning of an array while the given + * predicate function. + */ +export function takeWhile( + array: Array, + fn: (value: T) => boolean, +): Array { + const result: Array = []; + for (const value of array) { + if (!fn(value)) { + break; + } + result.push(value); + } + return result; +} diff --git a/src/view/selection.ts b/src/view/selection.ts index 46d6e19..63f701b 100644 --- a/src/view/selection.ts +++ b/src/view/selection.ts @@ -1,7 +1,77 @@ -import { Range } from '../range.ts'; +import { IndexRange, Range, Path } from '../model/types'; +import { groupBy, takeWhile } from '../utils/array'; +/** + * `Position` represents the position in the given container in the DOM. For + * passing the position to the model, we need to convert `Position` to the + * `Path`. + */ export type Position = [Node, number]; +export function getSelection(container: Node): Range | undefined { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return; + } + + const range = selection.getRangeAt(0); + return toRange(range, container); +} + +/** + * `toRange` converts the abstract range to the path range. + */ +export function toRange(range: AbstractRange, container: Node): Range { + const start = pathOf([range.startContainer, range.startOffset], container); + if (range.collapsed) { + return { s: start, e: start }; + } + + const end = pathOf([range.endContainer, range.endOffset], container); + return { s: start, e: end }; +} + +/** + * `pathOf` returns the path of the node in the container. + */ +export function pathOf(pos: Position, container: Node): Path { + let [node, offset] = pos; + const path = []; + + while (node !== container) { + const parent = node.parentNode; + if (!parent) { + throw new Error('node is not in the container'); + } + + // NOTE(hackerwins): In the path, adjacent text nodes are treated as one + // node, so we need to group them and calculate the offset in the group. + const children = Array.from(parent.childNodes); + const groups = groupBy( + children, + (p, c) => p.nodeType === Node.TEXT_NODE && c.nodeType === Node.TEXT_NODE, + ); + + const i = groups.findIndex((g) => g.includes(node as ChildNode)); + + // NOTE(hackerwins): If the node is a text, we need to append the index in + // the concatenated text content. + if (node.nodeType === Node.TEXT_NODE) { + const index = + takeWhile(groups[i], (c) => c !== node).reduce( + (acc, c) => acc + (c.textContent || '').length, + 0, + ) + offset; + path.unshift(index); + } + + path.unshift(i); + node = parent; + } + + return path; +} + /** * `offsetOf` returns the offset of the node in the container. */ @@ -45,7 +115,10 @@ export function offsetOf(pos: Position, container: Node): number { /** * `toRange` converts the abstract range to the range. */ -export function toRange(range: AbstractRange, container: Node): Range { +export function toIndexRange( + range: AbstractRange, + container: Node, +): IndexRange { const start = offsetOf([range.startContainer, range.startOffset], container); if (range.collapsed) { return { s: start, e: start }; diff --git a/src/view/view.ts b/src/view/view.ts index d44088e..171cb32 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,7 +1,8 @@ -import { Command } from '../commands'; +import { Command, splitBlock, insertText } from '../commands/commands'; import { Module } from '../utils/module'; import { Observable } from '../utils/observable'; -import { toRange } from './selection'; +import { Range } from '../model/types'; +import { toRange, getSelection } from './selection'; /** * `CommonInputEventTypes` is a list of common input event types that are @@ -48,25 +49,14 @@ export class View extends Observable implements Module { if (CommonInputEventTypes.includes(event.inputType)) { // TODO(hackerwins): We need to aggregate consecutive input events created // by composition text into one command. - const text = this.getTextFromEvent(event); const range = toRange(event.getTargetRanges()[0], this.container); - this.notify({ - t: 'e', - s: range.s, - e: range.e, - v: text, - }); + const text = this.getTextFromEvent(event); + this.notify(insertText(range, text)); } else if (event.inputType === 'insertParagraph') { // TODO(hackerwins): We figure out more input types created by enter key. // TODO(hackerwins): We need to handle dummy br element created by enter key. - const text = '\n'; const range = toRange(event.getTargetRanges()[0], this.container); - this.notify({ - t: 'e', - s: range.s, - e: range.e, - v: text, - }); + this.notify(splitBlock(range)); } } @@ -86,4 +76,8 @@ export class View extends Observable implements Module { setValue(value: string) { this.container.innerHTML = value; } + + getSelection(): Range | undefined { + return getSelection(this.container); + } } diff --git a/test/model/helper.ts b/test/model/helper.ts new file mode 100644 index 0000000..b6cd486 --- /dev/null +++ b/test/model/helper.ts @@ -0,0 +1,28 @@ +import { TagType } from '../../src/model/nodes'; +import { Node, Text } from '../../src/model/types'; +import { Schema } from '../../src/model/schema'; + +/** + * `PlainTextSpec` is a schema for a plain text document. + */ +export const PlainTextSpec = { + root: { children: 'p*' }, + p: { children: 'text*' }, + text: {}, +}; + +/** + * `tokenToXML` converts a node and tag type to an XML string. + */ +export function tokenToXML(node: Node, type: TagType): string { + switch (type) { + case 'open': + return `<${node.type}>`; + case 'close': + return ``; + case 'text': + return (node as Text).text; + } +} + +export const plainTextSchema = new Schema(PlainTextSpec); diff --git a/test/model/model.test.ts b/test/model/model.test.ts new file mode 100644 index 0000000..4fb5f16 --- /dev/null +++ b/test/model/model.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { Model } from '../../src/model/model'; +import { PlainTextSpec as PTSpec } from './helper'; + +describe('model', () => { + it('should be created with schema and value', () => { + const initialValue = /*html*/ `

Hello, world!

`; + const m = Model.create(PTSpec, initialValue); + expect(m.toXML()).toEqual(initialValue); + }); + + it('should be able to edit text', () => { + const m = Model.create(PTSpec, /*html*/ `

Hell!

`); + + let inverse = m.edit({ s: [0, 0, 4], e: [0, 0, 4] }, [ + { type: 'text', text: 'o' }, + ]); + expect(m.toXML()).toEqual(/*html*/ `

Hello!

`); + + inverse = m.apply(inverse); + expect(m.toXML()).toEqual(/*html*/ `

Hell!

`); + + m.apply(inverse); + expect(m.toXML()).toEqual(/*html*/ `

Hello!

`); + }); + + it.skip('should be able to edit text with multiple elements', () => { + const m = Model.create(PTSpec, /*html*/ `

1p

2p

`); + + let inverse = m.edit({ s: [0, 0, 1], e: [1, 0, 1] }, []); + expect(m.toXML()).toEqual(/*html*/ `

1p

`); + + inverse = m.apply(inverse); + expect(m.toXML()).toEqual(/*html*/ `

1p

2p

`); + + m.apply(inverse); + expect(m.toXML()).toEqual(/*html*/ `

1p

`); + }); +}); diff --git a/test/model/nodepos.test.ts b/test/model/nodepos.test.ts new file mode 100644 index 0000000..b0b6bdf --- /dev/null +++ b/test/model/nodepos.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { plainTextSchema, tokenToXML } from './helper'; +import { Element } from '../../src/model/types'; +import { pathOf } from '../../src/model/nodes'; +import { nodesBetween, toNodePos } from '../../src/model/nodepos'; + +describe('NodePos', () => { + it('should convert a Path to a Pos', () => { + const val = /*html*/ `

Hello

World!

`; + const container = plainTextSchema.fromXML(val) as Element; + + expect(toNodePos([0, 0, 0], container)).toEqual({ + node: (container.children![0] as Element).children![0], + offset: 0, + }); + }); + + it('should convert a node to a path', () => { + const val = /*html*/ `

Hello

`; + const container = plainTextSchema.fromXML(val) as Element; + const text = (container.children![0] as Element).children![0]; + expect(pathOf(text, container)).toEqual([0, 0]); + }); +}); + +describe('Nodes.Between', () => { + it('should iterate over nodes between two positions', () => { + const val = /*html*/ `

ab

cd

ef

`; + const container = plainTextSchema.fromXML(val) as Element; + + const start = toNodePos([0, 0, 1], container); + const end = toNodePos([2, 0, 1], container); + const xml = Array.from(nodesBetween(container, start, end)).map(([node, type]) => + tokenToXML(node, type) + ).join(''); + expect(xml).toEqual('ab

cd

ef'); + }); + + it('should iterate over a text node', () => { + const val = /*html*/ `

Hello

`; + const container = plainTextSchema.fromXML(val) as Element; + const text = (container.children![0] as Element).children![0]; + + const start = { node: text, offset: 1 }; + const end = { node: text, offset: 4 }; + const xml = Array.from(nodesBetween(container, start, end)).map(([node, type]) => + tokenToXML(node, type) + ).join(''); + + expect(xml).toEqual('Hello'); + }); + + it('should return empty if start and end are the same', () => { + const val = /*html*/ `

Hello

`; + const container = plainTextSchema.fromXML(val) as Element; + const start = toNodePos([0, 0, 1], container); + const end = toNodePos([0, 0, 1], container); + const xml = Array.from(nodesBetween(container, start, end)).map(([node, type]) => + tokenToXML(node, type) + ).join(''); + expect(xml).toEqual(''); + }); +}); diff --git a/test/model/nodes.test.ts b/test/model/nodes.test.ts new file mode 100644 index 0000000..482f8d6 --- /dev/null +++ b/test/model/nodes.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { plainTextSchema } from './helper'; +import { Element } from '../../src/model/types'; +import { insertAfter, removeNode, splitText, toXML } from '../../src/model/nodes'; +import { toNodePos } from '../../src/model/nodepos'; + + +describe('Nodes', () => { + const val = /*html*/ `

Hello, world!

`; + const container = plainTextSchema.fromXML(val) as Element; + + it('should serialize to XML', () => { + expect(toXML(container)).toEqual(val); + }); + + it('should split a text node', () => { + const val = /*html*/ `

Hello, world!

`; + const container = plainTextSchema.fromXML(val) as Element; + const node = splitText(toNodePos([0, 5], container)); + expect(toXML(node!)).toEqual(', world!'); + }); + + it('should insert a node after another', () => { + const val = /*html*/ `

Hello

`; + const container = plainTextSchema.fromXML(val) as Element; + const node = insertAfter(container.children![0], { + type: 'text', + text: ', world!', + })[0]; + expect(toXML(node!)).toEqual(', world!'); + expect(toXML(container)).toEqual(/*html*/ `

Hello, world!

`); + + removeNode(container.children![0]); + expect(toXML(container)).toEqual(/*html*/ `

, world!

`); + }); +}); diff --git a/test/model/schema.test.ts b/test/model/schema.test.ts index d5e9933..7f6cb83 100644 --- a/test/model/schema.test.ts +++ b/test/model/schema.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { Schema } from '../../src/model/schema'; +import { toXML } from '../../src/model/nodes'; describe('Schema', () => { it('should be created with a valid spec', () => { @@ -47,4 +48,19 @@ describe('Schema', () => { }); }).toThrowError('invalid node type: paragraph'); }); + + it('should build a node from XML', () => { + const schema = new Schema({ + root: { children: 'p*' }, + p: { children: 'text*' }, + text: {}, + }); + + const val = /*html*/ `

Hello, world!

`; + expect(toXML(schema.fromXML(val))).toEqual(val); + + expect(() => { + schema.fromXML(/*html*/ `Hello, world!`); + }).toThrowError('invalid node type: para'); + }); }); diff --git a/test/util/array.test.ts b/test/util/array.test.ts new file mode 100644 index 0000000..3d874e7 --- /dev/null +++ b/test/util/array.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { groupBy, takeWhile } from '../../src/utils/array'; + +describe('groupBy', () => { + it('should group the array by the given function', () => { + expect( + groupBy([1, 2, 2, 3, 3, 3, 2, 2], (prev, curr) => prev === curr), + ).toEqual([[1], [2, 2], [3, 3, 3], [2, 2]]); + + expect( + groupBy( + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + (prev, curr) => prev + 1 === curr, + ), + ).toEqual([[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]]); + }); + + it('should return empty array if the input is empty', () => { + const array = []; + const result = groupBy(array, (prev, curr) => prev + 1 === curr); + expect(result).toEqual([]); + }); + + it('should return single group if the input is not grouped', () => { + const array = [1, 2, 3, 5, 6, 7]; + const result = groupBy(array, (prev, curr) => prev + 1 === curr); + expect(result).toEqual([ + [1, 2, 3], + [5, 6, 7], + ]); + }); +}); + +describe('takeWhile', () => { + it('should take elements while the predicate is true', () => { + expect(takeWhile([1, 2, 3, 4, 5], (v) => v < 4)).toEqual([1, 2, 3]); + expect(takeWhile([1, 2, 3, 4, 5], (v) => v < 1)).toEqual([]); + expect(takeWhile([1, 2, 3, 4, 5], (v) => v < 5)).toEqual([1, 2, 3, 4]); + }); + + it('should return empty array if the input is empty', () => { + const array = []; + const result = takeWhile(array, () => true); + expect(result).toEqual([]); + }); + + it('should return all elements if the predicate is always true', () => { + const array = [1, 2, 3, 4, 5]; + const result = takeWhile(array, () => true); + expect(result).toEqual([1, 2, 3, 4, 5]); + }); +}); diff --git a/test/view/selection.test.ts b/test/view/selection.test.ts index 7363a65..27cd27a 100644 --- a/test/view/selection.test.ts +++ b/test/view/selection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { offsetOf } from '../../src/view/selection'; +import { offsetOf, pathOf } from '../../src/view/selection'; describe('offsetOf', () => { it('should convert to the given position', () => { @@ -10,7 +10,7 @@ describe('offsetOf', () => { it('should convert to the given position in element', () => { const d = document.createElement('div'); - d.innerHTML = 'Hello World!'; + d.innerHTML = /*html*/ `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); @@ -25,3 +25,34 @@ describe('offsetOf', () => { ).toThrowError('node is not in the container'); }); }); + +describe('pathOf', () => { + it('should convert to the given position', () => { + const d = document.createElement('div'); + d.innerHTML = 'Hello World!'; + expect(pathOf([d.firstChild!, 1], d)).toEqual([0, 1]); + }); + + it('should convert to the given position in element', () => { + const d = document.createElement('div'); + d.innerHTML = /*html*/ `Hello World!`; + expect(pathOf([d.querySelector('b')!.firstChild!, 0], d)).toEqual([ + 0, 0, 0, + ]); + expect(pathOf([d.querySelector('i')!.firstChild!, 0], d)).toEqual([ + 2, 0, 0, + ]); + expect(pathOf([d.querySelector('i')!.firstChild!, 1], d)).toEqual([ + 2, 0, 1, + ]); + expect(pathOf([d.querySelector('i')!.nextSibling!, 0], d)).toEqual([3, 0]); + }); + + it('should return error if the range is not in the container', () => { + const d = document.createElement('div'); + d.innerHTML = 'Hello World!'; + expect(() => + pathOf([d.firstChild!, 4], document.createElement('div')), + ).toThrowError('node is not in the container'); + }); +});