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'); + }); +});