Skip to content

Commit 9f4613b

Browse files
authored
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.
1 parent 79151f7 commit 9f4613b

File tree

24 files changed

+1033
-129
lines changed

24 files changed

+1033
-129
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 optimize this part.
78+
this.view.setValue(this.model.toXML());
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: 144 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,160 @@
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+
lengthOf,
9+
pathOf,
10+
removeNode,
11+
splitText,
12+
toXML,
13+
} from './nodes';
14+
import { firstOf, lastOf } from '../utils/array';
15+
import { isLeftMost, toNodePos, nodesBetween } from './nodepos';
16+
import { isCollapsed } from './range';
217

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

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

11-
constructor(value: string) {
28+
constructor(schema: Schema, value: Node) {
1229
super();
13-
this.value = value;
30+
this.schema = schema;
31+
this.root = value;
1432
}
1533

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

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

26-
return this.value;
154+
return { s: path, e: path };
27155
}
28156

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);
157+
move(/*source: Range, target: Range*/): Operation {
158+
throw new Error('Method not implemented.');
32159
}
33160
}

src/model/node.ts

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

0 commit comments

Comments
 (0)