Skip to content

Commit

Permalink
Apply schema to model (#1)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
hackerwins authored Oct 19, 2024
1 parent 79151f7 commit 9f4613b
Show file tree
Hide file tree
Showing 24 changed files with 1,033 additions and 129 deletions.
48 changes: 0 additions & 48 deletions src/commands.ts

This file was deleted.

48 changes: 48 additions & 0 deletions src/commands/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Model } from '../model/model';
import { Range } from '../model/types';
import { Operation } from '../model/operations';

export type Command = {
ops: Array<Operation>;
};

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,
};
}
30 changes: 18 additions & 12 deletions src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Module>;
};
Expand All @@ -28,7 +30,10 @@ export class Editor extends Observable<Array<Command>> implements Module {

private plugins: Array<Module>;

static create(container: HTMLDivElement, opts: EditorOptions = {}) {
static create(
container: HTMLDivElement,
opts: EditorOptions = { schema: BasicSchema },
) {
const editor = new Editor(container, opts);
editor.initialize();
return editor;
Expand All @@ -38,7 +43,7 @@ export class Editor extends Observable<Array<Command>> 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>((command) =>
execute(this.model, command),
);
Expand All @@ -50,7 +55,7 @@ export class Editor extends Observable<Array<Command>> 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(
Expand All @@ -63,13 +68,14 @@ export class Editor extends Observable<Array<Command>> 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());
}),
);

Expand Down Expand Up @@ -126,11 +132,11 @@ export class Editor extends Observable<Array<Command>> 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));
}
}
7 changes: 6 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import { Devtools } from './plugins/devtools';
const editor = Editor.create(
document.querySelector<HTMLDivElement>('#editor')!,
{
initialValue: 'Hello,',
initialValue: '<p>Hello,</p>',
schema: {
root: { children: 'p*' },
p: { children: 'text*' },
text: {},
},
plugins: [
Toolbar.create(document.querySelector<HTMLDivElement>('#toolbar')!, {
buttons: ['destroy', 'undo', 'redo'],
Expand Down
161 changes: 144 additions & 17 deletions src/model/model.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
private value: string;
export class Model extends Observable<Array<Operation>> {
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<Node>): 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<Node> = [];
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<Node>): 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<number>, 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.');
}
}
12 changes: 0 additions & 12 deletions src/model/node.ts

This file was deleted.

Loading

0 comments on commit 9f4613b

Please sign in to comment.