Skip to content

Commit

Permalink
[WIP] Apply schema to model
Browse files Browse the repository at this point in the history
  • Loading branch information
hackerwins committed Oct 19, 2024
1 parent 79151f7 commit c0603c8
Show file tree
Hide file tree
Showing 23 changed files with 970 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 c0603c8

Please sign in to comment.