Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Apply schema to model #1

Merged
merged 1 commit into from
Oct 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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' }],
},
],
};
}
hackerwins marked this conversation as resolved.
Show resolved Hide resolved

/**
* `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}`);
hackerwins marked this conversation as resolved.
Show resolved Hide resolved
}
}

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.');
hackerwins marked this conversation as resolved.
Show resolved Hide resolved
}
}
12 changes: 0 additions & 12 deletions src/model/node.ts

This file was deleted.

Loading