Skip to content

Commit 2969089

Browse files
authored
Merge pull request #800 from streamich/peritext-annotation-registry
Peritext annotation registry
2 parents e6adf08 + 0820643 commit 2969089

File tree

14 files changed

+455
-15
lines changed

14 files changed

+455
-15
lines changed

src/json-crdt-extensions/peritext/__tests__/Peritext.render-block.spec.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,6 @@ const runInlineSlicesTests = (
4747
editor.saved.insMarker(['p'], {foo: 'bar'});
4848
expect(view()).toMatchInlineSnapshot(`
4949
"<>
50-
<0>
51-
"" { }
5250
<p> { foo = "bar" }
5351
"abcdefghijklmnopqrstuvwxyz" { }
5452
"

src/json-crdt-extensions/peritext/block/Fragment.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ export class Fragment extends Range implements Printable, Stateful {
8080
let pair: ReturnType<typeof iterator>;
8181
while ((pair = iterator())) {
8282
const [p1, p2] = pair;
83+
const skipFirstVirtualBlock = !p1 && this.start.isAbsStart() && p2 && p2.viewPos() === 0;
84+
if (skipFirstVirtualBlock) continue;
8385
const type = p1 ? p1.type() : CommonSliceType.p;
8486
const path = type instanceof Array ? type : [type];
8587
const block = this.insertBlock(parent, path, p1, p2);
Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1+
import type {SliceBehavior} from '../slice/constants';
2+
13
export type PeritextMlNode = string | PeritextMlElement;
2-
export type PeritextMlElement = [
3-
tag: string | number,
4-
attrs: null | PeritextMlAttributes,
4+
5+
export type PeritextMlElement<Tag extends string | number = string | number, Data = unknown, Inline = boolean> = [
6+
tag: Tag,
7+
attrs: null | PeritextMlAttributes<Data, Inline>,
58
...children: PeritextMlNode[],
69
];
7-
export interface PeritextMlAttributes {
8-
inline?: boolean;
9-
data?: unknown;
10+
11+
export interface PeritextMlAttributes<Data = unknown, Inline = boolean> {
12+
data?: Data;
13+
inline?: Inline;
14+
behavior?: SliceBehavior;
1015
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import {setupKit} from '../../__tests__/setup';
2+
import {CommonSliceType} from '../../slice';
3+
import {fromHtml, toViewRange} from '../import-html';
4+
5+
test('a single paragraph', () => {
6+
const {peritext} = setupKit();
7+
const html = '<p>Hello world</p>';
8+
const peritextMl = fromHtml(html);
9+
const rangeView = toViewRange(peritextMl);
10+
peritext.editor.import(0, rangeView);
11+
peritext.refresh();
12+
const json = peritext.blocks.toJson();
13+
expect(json).toEqual(['', null, [CommonSliceType.p, null, 'Hello world']]);
14+
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import {Anchor} from '../../rga/constants';
2+
import {CommonSliceType} from '../../slice';
3+
import {SliceBehavior, SliceHeaderShift} from '../../slice/constants';
4+
import {fromHtml, toViewRange} from '../import-html';
5+
6+
describe('.fromHtml()', () => {
7+
test('a single paragraph', () => {
8+
const html = '<p>Hello world</p>';
9+
const peritextMl = fromHtml(html);
10+
expect(peritextMl).toEqual(['', null, [CommonSliceType.p, null, 'Hello world']]);
11+
});
12+
13+
test('a paragraph with trailing text', () => {
14+
const html = '<p>Hello world</p> more text';
15+
const peritextMl = fromHtml(html);
16+
expect(peritextMl).toEqual(['', null, [CommonSliceType.p, null, 'Hello world'], ' more text']);
17+
});
18+
19+
test('text formatted as italic', () => {
20+
const html = '<p>Hello world</p>\n<p><em>italic</em> text, <i>more italic</i></p>';
21+
const peritextMl = fromHtml(html);
22+
expect(peritextMl).toEqual([
23+
'',
24+
null,
25+
[CommonSliceType.p, null, 'Hello world'],
26+
'\n',
27+
[
28+
CommonSliceType.p,
29+
null,
30+
[CommonSliceType.i, {behavior: SliceBehavior.One, inline: true}, 'italic'],
31+
' text, ',
32+
[CommonSliceType.i, {behavior: SliceBehavior.One, inline: true}, 'more italic'],
33+
],
34+
]);
35+
});
36+
});
37+
38+
describe('.toViewRange()', () => {
39+
test('plain text', () => {
40+
const html = 'this is plain text';
41+
const peritextMl = fromHtml(html);
42+
const view = toViewRange(peritextMl);
43+
expect(view).toEqual(['this is plain text', 0, []]);
44+
});
45+
46+
test('a single paragraph', () => {
47+
const html = '<p>Hello world</p>';
48+
const peritextMl = fromHtml(html);
49+
const view = toViewRange(peritextMl);
50+
expect(view).toEqual(['\nHello world', 0, [[0, 0, 0, 0]]]);
51+
});
52+
53+
test('two consecutive paragraphs', () => {
54+
const html = '<p>Hello world</p><p>Goodbye world</p>';
55+
const peritextMl = fromHtml(html);
56+
const view = toViewRange(peritextMl);
57+
expect(view).toEqual([
58+
'\nHello world\nGoodbye world',
59+
0,
60+
[
61+
[0, 0, 0, 0],
62+
[0, 12, 12, 0],
63+
],
64+
]);
65+
});
66+
67+
test('two paragraphs with whitespace gap', () => {
68+
const html = ' <p>Hello world</p>\n <p>Goodbye world</p>';
69+
const peritextMl = fromHtml(html);
70+
const view = toViewRange(peritextMl);
71+
expect(view).toEqual([
72+
'\nHello world\nGoodbye world',
73+
0,
74+
[
75+
[0, 0, 0, 0],
76+
[0, 12, 12, 0],
77+
],
78+
]);
79+
});
80+
81+
test('single inline annotation', () => {
82+
const html = 'here is some <em>italic</em> text';
83+
const peritextMl = fromHtml(html);
84+
const view = toViewRange(peritextMl);
85+
expect(view).toEqual([
86+
'here is some italic text',
87+
0,
88+
[
89+
[
90+
(SliceBehavior.One << SliceHeaderShift.Behavior) +
91+
(Anchor.Before << SliceHeaderShift.X1Anchor) +
92+
(Anchor.After << SliceHeaderShift.X2Anchor),
93+
13,
94+
19,
95+
CommonSliceType.i,
96+
],
97+
],
98+
]);
99+
});
100+
});

src/json-crdt-extensions/peritext/lazy/export-markdown.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@ import type {PeritextMlNode} from '../block/types';
66

77
export const toMdast = (json: PeritextMlNode): IRoot => {
88
const hast = toHast(json);
9-
// console.log(hast);
109
const mdast = _toMdast(hast) as IRoot;
11-
// console.log(mdast);
1210
return mdast;
1311
};
1412

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import {html as _html} from 'very-small-parser/lib/html';
2+
import {fromHast as _fromHast} from 'very-small-parser/lib/html/json-ml/fromHast';
3+
import {SliceTypeName} from '../slice';
4+
import {registry as defaultRegistry} from '../registry/registry';
5+
import {SliceBehavior, SliceHeaderShift} from '../slice/constants';
6+
import {Anchor} from '../rga/constants';
7+
import type {JsonMlNode} from 'very-small-parser/lib/html/json-ml/types';
8+
import type {THtmlToken} from 'very-small-parser/lib/html/types';
9+
import type {PeritextMlNode} from '../block/types';
10+
import type {SliceRegistry} from '../registry/SliceRegistry';
11+
import type {ViewRange, ViewSlice} from '../editor/types';
12+
13+
/**
14+
* Flattens a {@link PeritextMlNode} tree structure into a {@link ViewRange}
15+
* flat string with annotation ranges.
16+
*/
17+
class ViewRangeBuilder {
18+
private text = '';
19+
private slices: ViewSlice[] = [];
20+
21+
constructor(private registry: SliceRegistry) {}
22+
23+
private build0(node: PeritextMlNode, depth = 0): void {
24+
const skipWhitespace = depth < 2;
25+
if (typeof node === 'string') {
26+
if (skipWhitespace && !node.trim()) return;
27+
this.text += node;
28+
return;
29+
}
30+
const [type, attr] = node;
31+
const start = this.text.length;
32+
const length = node.length;
33+
const inline = !!attr?.inline;
34+
if (!!type || type === 0) {
35+
let end: number = 0,
36+
header: number = 0;
37+
if (!inline) {
38+
this.text += '\n';
39+
end = start;
40+
header =
41+
(SliceBehavior.Marker << SliceHeaderShift.Behavior) +
42+
(Anchor.Before << SliceHeaderShift.X1Anchor) +
43+
(Anchor.Before << SliceHeaderShift.X2Anchor);
44+
const slice: ViewSlice = [header, start, end, type];
45+
const data = attr?.data;
46+
if (data) slice.push(data);
47+
this.slices.push(slice);
48+
}
49+
}
50+
for (let i = 2; i < length; i++) this.build0(node[i] as PeritextMlNode, depth + 1);
51+
if (!!type || type === 0) {
52+
let end: number = 0,
53+
header: number = 0;
54+
if (inline) {
55+
end = this.text.length;
56+
const behavior: SliceBehavior = attr?.behavior ?? SliceBehavior.Many;
57+
header =
58+
(behavior << SliceHeaderShift.Behavior) +
59+
(Anchor.Before << SliceHeaderShift.X1Anchor) +
60+
(Anchor.After << SliceHeaderShift.X2Anchor);
61+
const slice: ViewSlice = [header, start, end, type];
62+
const data = attr?.data;
63+
if (data) slice.push(data);
64+
this.slices.push(slice);
65+
}
66+
}
67+
}
68+
69+
public build(node: PeritextMlNode): ViewRange {
70+
this.build0(node);
71+
const view: ViewRange = [this.text, 0, this.slices];
72+
return view;
73+
}
74+
}
75+
76+
export const toViewRange = (node: PeritextMlNode, registry: SliceRegistry = defaultRegistry): ViewRange =>
77+
new ViewRangeBuilder(registry).build(node);
78+
79+
export const fromJsonMl = (jsonml: JsonMlNode, registry: SliceRegistry = defaultRegistry): PeritextMlNode => {
80+
if (typeof jsonml === 'string') return jsonml;
81+
const tag = jsonml[0];
82+
const length = jsonml.length;
83+
const node: PeritextMlNode = [tag, null];
84+
for (let i = 2; i < length; i++) node.push(fromJsonMl(jsonml[i] as JsonMlNode, registry));
85+
const res = registry.fromHtml(jsonml);
86+
if (res) {
87+
node[0] = res[0];
88+
node[1] = res[1];
89+
} else {
90+
node[0] = SliceTypeName[tag as any] ?? tag;
91+
const attr = jsonml[1] || {};
92+
let data = null;
93+
if (attr['data-attr'] !== void 0) {
94+
try {
95+
data = JSON.parse(attr['data-attr']);
96+
} catch {}
97+
}
98+
const inline = attr['data-inline'] === 'true';
99+
if (data || inline) node[1] = {data, inline};
100+
}
101+
if (typeof node[0] === 'number' && node[0] < 0) {
102+
const attr = node[1] || {};
103+
attr.inline = true;
104+
node[1] = attr;
105+
}
106+
return node;
107+
};
108+
109+
export const fromHast = (hast: THtmlToken, registry?: SliceRegistry): PeritextMlNode => {
110+
const jsonml = _fromHast(hast);
111+
return fromJsonMl(jsonml, registry);
112+
};
113+
114+
export const fromHtml = (html: string, registry?: SliceRegistry): PeritextMlNode => {
115+
const hast = _html.parsef(html);
116+
return fromHast(hast, registry);
117+
};
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import {toHast} from 'very-small-parser/lib/markdown/block/toHast';
2+
import {block} from 'very-small-parser/lib/markdown/block';
3+
import {fromHast as _fromHast} from 'very-small-parser/lib/html/json-ml/fromHast';
4+
import {registry as defaultRegistry} from '../registry/registry';
5+
import {fromHast} from './import-html';
6+
import type {IRoot} from 'very-small-parser/lib/markdown/block/types';
7+
import type {PeritextMlNode} from '../block/types';
8+
import type {SliceRegistry} from '../registry/SliceRegistry';
9+
10+
export const fromMdast = (mdast: IRoot, registry: SliceRegistry = defaultRegistry): PeritextMlNode => {
11+
const hast = toHast(mdast);
12+
const node = fromHast(hast, registry);
13+
return node;
14+
};
15+
16+
export const fromMarkdown = (markdown: string, registry?: SliceRegistry): PeritextMlNode => {
17+
const mdast = block.parsef(markdown);
18+
return fromMdast(mdast, registry);
19+
};
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type {PeritextMlElement} from '../block/types';
2+
import type {NodeBuilder} from '../../../json-crdt-patch';
3+
import {SliceBehavior} from '../slice/constants';
4+
import type {JsonMlElement} from 'very-small-parser/lib/html/json-ml/types';
5+
import type {FromHtmlConverter, SliceTypeDefinition, ToHtmlConverter} from './types';
6+
7+
export class SliceRegistry {
8+
private map: Map<string | number, SliceTypeDefinition<any, any, any>> = new Map();
9+
private toHtmlMap: Map<string | number, ToHtmlConverter<any>> = new Map();
10+
private fromHtmlMap: Map<string, [def: SliceTypeDefinition<any, any, any>, converter: FromHtmlConverter][]> =
11+
new Map();
12+
13+
public add<Type extends number | string, Schema extends NodeBuilder, Inline extends boolean = true>(
14+
def: SliceTypeDefinition<Type, Schema, Inline>,
15+
): void {
16+
const {type, toHtml, fromHtml} = def;
17+
this.map.set(type, def);
18+
if (toHtml) this.toHtmlMap.set(type, toHtml);
19+
if (fromHtml) {
20+
const fromHtmlMap = this.fromHtmlMap;
21+
for (const htmlTag in fromHtml) {
22+
const converter = fromHtml[htmlTag];
23+
const converters = fromHtmlMap.get(htmlTag) ?? [];
24+
converters.push([def, converter]);
25+
fromHtmlMap.set(htmlTag, converters);
26+
}
27+
}
28+
}
29+
30+
public def<Type extends number | string, Schema extends NodeBuilder, Inline extends boolean = true>(
31+
type: Type,
32+
schema: Schema,
33+
behavior: SliceBehavior,
34+
inline: boolean,
35+
rest: Omit<SliceTypeDefinition<Type, Schema, Inline>, 'type' | 'schema' | 'behavior' | 'inline'> = {},
36+
): void {
37+
this.add({type, schema, behavior, inline, ...rest});
38+
}
39+
40+
public toHtml(el: PeritextMlElement): ReturnType<ToHtmlConverter<any>> | undefined {
41+
const converter = this.toHtmlMap.get(el[0]);
42+
return converter ? converter(el) : undefined;
43+
}
44+
45+
public fromHtml(el: JsonMlElement): PeritextMlElement | undefined {
46+
const tag = el[0] + '';
47+
const converters = this.fromHtmlMap.get(tag);
48+
if (converters) {
49+
for (const [def, converter] of converters) {
50+
const result = converter(el);
51+
if (result) {
52+
const attr = result[1] ?? (result[1] = {});
53+
attr.inline = def.inline ?? def.type < 0;
54+
attr.behavior = !attr.inline ? SliceBehavior.Marker : (def.behavior ?? SliceBehavior.Many);
55+
return result;
56+
}
57+
}
58+
}
59+
return;
60+
}
61+
}

0 commit comments

Comments
 (0)