Skip to content

Commit 2813043

Browse files
committed
[WIP] Apply schema to model
1 parent 79151f7 commit 2813043

File tree

23 files changed

+1015
-128
lines changed

23 files changed

+1015
-128
lines changed

src/commands.ts

Lines changed: 0 additions & 48 deletions
This file was deleted.

src/commands/commands.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Model } from '../model/model';
2+
import { Range } from '../model/types';
3+
import { Operation } from '../model/operations';
4+
5+
export type Command = {
6+
ops: Array<Operation>;
7+
};
8+
9+
export function insertText(range: Range, text: string): Command {
10+
return {
11+
ops: [
12+
{
13+
type: 'edit',
14+
range,
15+
value: [{ type: 'text', text }],
16+
},
17+
],
18+
};
19+
}
20+
21+
export function splitBlock(range: Range): Command {
22+
// TODO(hackerwins): Implement this according to the schema.
23+
return {
24+
ops: [
25+
{
26+
type: 'edit',
27+
range,
28+
value: [{ type: 'p' }],
29+
},
30+
],
31+
};
32+
}
33+
34+
/**
35+
* `execute` executes the command on the model and returns inverse command.
36+
*/
37+
export function execute(model: Model, command: Command): Command {
38+
const ops = [];
39+
40+
for (const op of command.ops) {
41+
const inverse = model.apply(op);
42+
ops.push(inverse);
43+
}
44+
45+
return {
46+
ops: ops,
47+
};
48+
}

src/editor.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import { Observable, Unsubscribe } from './utils/observable';
44

55
import { View as View } from './view/view';
66
import { Model } from './model/model';
7-
import { Command, execute } from './commands';
7+
import { Command, execute, insertText } from './commands/commands';
8+
import { SchemaSpec, BasicSchema } from './model/schema';
89

910
/**
1011
* `EditorOptions` is an object that contains the initial value of the editor
1112
* and the plugins that should be initialized.
1213
*/
1314
type EditorOptions = {
15+
schema: SchemaSpec;
1416
initialValue?: string;
1517
plugins?: Array<Module>;
1618
};
@@ -28,7 +30,10 @@ export class Editor extends Observable<Array<Command>> implements Module {
2830

2931
private plugins: Array<Module>;
3032

31-
static create(container: HTMLDivElement, opts: EditorOptions = {}) {
33+
static create(
34+
container: HTMLDivElement,
35+
opts: EditorOptions = { schema: BasicSchema },
36+
) {
3237
const editor = new Editor(container, opts);
3338
editor.initialize();
3439
return editor;
@@ -38,7 +43,7 @@ export class Editor extends Observable<Array<Command>> implements Module {
3843
super();
3944

4045
this.view = View.create(container);
41-
this.model = Model.create(opts.initialValue || '');
46+
this.model = Model.create(opts.schema, opts.initialValue || '');
4247
this.history = new History<Command>((command) =>
4348
execute(this.model, command),
4449
);
@@ -50,7 +55,7 @@ export class Editor extends Observable<Array<Command>> implements Module {
5055

5156
initialize() {
5257
// 01. Initialize the view with the model's content.
53-
this.view.setValue(this.model.getValue());
58+
this.view.setValue(this.model.toXML());
5459

5560
// 02. Upstream: view creates commands and then the editor executes them.
5661
this.unsubscribes.push(
@@ -63,13 +68,14 @@ export class Editor extends Observable<Array<Command>> implements Module {
6368

6469
// 03. Downstream: If the model changes, the view should be updated.
6570
this.unsubscribes.push(
66-
this.model.subscribe((value) => {
71+
this.model.subscribe(() => {
6772
// Prevent downstream updates if the view is the source of change.
6873
if (this.isUpstream) {
6974
return;
7075
}
7176

72-
this.view.setValue(value);
77+
// TODO(hackerwins): We need to figure out how to update the view.
78+
// this.view.setValue(value);
7379
}),
7480
);
7581

@@ -126,11 +132,11 @@ export class Editor extends Observable<Array<Command>> implements Module {
126132
* TODO(hackerwins): Find a better way to provide APIs.
127133
*/
128134
insertText(text: string) {
129-
this.execute({
130-
t: 'e',
131-
s: this.model.getValue().length,
132-
e: this.model.getValue().length,
133-
v: text,
134-
});
135+
let range = this.view.getSelection();
136+
if (!range) {
137+
range = this.model.getContentEndRange();
138+
}
139+
140+
this.execute(insertText(range, text));
135141
}
136142
}

src/main.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ import { Devtools } from './plugins/devtools';
66
const editor = Editor.create(
77
document.querySelector<HTMLDivElement>('#editor')!,
88
{
9-
initialValue: 'Hello,',
9+
initialValue: '<p>Hello,</p>',
10+
schema: {
11+
root: { children: 'p*' },
12+
p: { children: 'text*' },
13+
text: {},
14+
},
1015
plugins: [
1116
Toolbar.create(document.querySelector<HTMLDivElement>('#toolbar')!, {
1217
buttons: ['destroy', 'undo', 'redo'],

src/model/model.ts

Lines changed: 127 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,143 @@
11
import { Observable } from '../utils/observable';
2+
import { Node, Text, Element, Range, NodePos } from './types';
3+
import { Schema, SchemaSpec } from './schema';
4+
import { Operation } from './operations';
5+
import {
6+
insertAfter,
7+
insertBefore,
8+
nodesBetween,
9+
pathOf,
10+
removeNode,
11+
splitText,
12+
toNodePos,
13+
toXML,
14+
} from './nodes';
15+
import { firstOf, lastOf } from '../utils/array';
16+
import { isLeftMost } from './nodepos';
17+
import { isCollapsed } from './range';
218

3-
// TODO(hackerwins): Build a tree-based model with schema validation.
4-
export class Model extends Observable<string> {
5-
private value: string;
19+
export class Model extends Observable<Array<Operation>> {
20+
private schema: Schema;
21+
private root: Element;
622

7-
static create(initialValue: string): Model {
8-
return new Model(initialValue);
23+
static create(spec: SchemaSpec, initialValue: string): Model {
24+
const schema = new Schema(spec);
25+
const value = schema.fromXML(initialValue);
26+
return new Model(schema, value);
927
}
1028

11-
constructor(value: string) {
29+
constructor(schema: Schema, value: Node) {
1230
super();
13-
this.value = value;
31+
this.schema = schema;
32+
this.root = value;
1433
}
1534

16-
setValue(value: string): void {
17-
this.value = value;
18-
this.notify(this.value);
35+
createText(value: string): Text {
36+
return this.schema.create('text', value) as Text;
1937
}
2038

21-
getValue(from?: number, to?: number): string {
22-
if (from !== undefined && to !== undefined) {
23-
return this.value.slice(from, to);
39+
toXML(): string {
40+
return toXML(this.root);
41+
}
42+
43+
apply(op: Operation): Operation {
44+
switch (op.type) {
45+
case 'edit':
46+
return this.edit(op.range, op.value);
47+
case 'move':
48+
return this.move(/*op.source, op.target*/);
49+
default:
50+
throw new Error(`invalid operation type: ${op}`);
51+
}
52+
}
53+
54+
edit(range: Range, values: Array<Node>): Operation {
55+
// NOTE(hackerwins): To keep the node of the position, we need to split the
56+
// text node at the end of the range.
57+
const end = this.nodePosOf(range.e);
58+
const start = isCollapsed(range) ? end : this.nodePosOf(range.s);
59+
60+
const nodesToRemove: Array<Node> = [];
61+
for (const [node, type] of nodesBetween(this.root, start, end)) {
62+
if (type === 'open') {
63+
continue;
64+
}
65+
66+
nodesToRemove.push(node);
67+
};
68+
69+
for (const node of nodesToRemove) {
70+
removeNode(node);
71+
}
72+
73+
if (isLeftMost(start)) {
74+
insertBefore(start.node, ...values);
75+
} else {
76+
insertAfter(start.node, ...values);
77+
}
78+
79+
80+
const firstNode = firstOf(values)!;
81+
const lastNode = lastOf(values)!;
82+
const startPath = pathOf(firstNode, this.root);
83+
const endPath = pathOf(lastNode, this.root);
84+
85+
return {
86+
type: 'edit',
87+
range: { s: startPath, e: endPath },
88+
value: nodesToRemove
89+
};
90+
}
91+
92+
/**
93+
* `nodePosOf` returns the node position of the given path.
94+
*/
95+
nodePosOf(path: Array<number>, withSplitText: boolean = true): NodePos {
96+
const pos = toNodePos(path, this.root);
97+
if (!withSplitText) {
98+
return pos;
99+
}
100+
101+
const newNode = splitText(pos);
102+
if (!newNode) {
103+
return pos;
104+
}
105+
106+
return {
107+
node: newNode,
108+
offset: 0,
109+
};
110+
}
111+
112+
/**
113+
* `getContentEndRange` returns the range of the end of the content in the
114+
* model.
115+
*/
116+
getContentEndRange(): Range {
117+
const path = [];
118+
119+
let node = this.root;
120+
while (node) {
121+
if (node.type === 'text') {
122+
const text = node as Text;
123+
path.push(text.text.length);
124+
break;
125+
}
126+
127+
const elem = node as Element;
128+
const children = elem.children || [];
129+
if (children.length === 0) {
130+
break;
131+
}
132+
133+
path.push(children.length - 1);
134+
node = lastOf(children)!;
24135
}
25136

26-
return this.value;
137+
return { s: path, e: path };
27138
}
28139

29-
edit(start: number, end: number, text: string): void {
30-
this.value = this.value.slice(0, start) + text + this.value.slice(end);
31-
this.notify(this.value);
140+
move(/*source: Range, target: Range*/): Operation {
141+
throw new Error('Method not implemented.');
32142
}
33143
}

src/model/node.ts

Lines changed: 0 additions & 12 deletions
This file was deleted.

0 commit comments

Comments
 (0)