Skip to content
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
4 changes: 1 addition & 3 deletions packages/json-joy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,8 @@
"very-small-parser": "^1.14.0"
},
"devDependencies": {
"@monaco-editor/react": "^4.7.0",
"@jsonjoy.com/json-random": "workspace:*",
"@radix-ui/react-icons": "^1.3.1",
"@types/node": "^24.8.1",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"clipboard-copy": "^4.0.1",
Expand All @@ -108,7 +107,6 @@
"jest": "^29.7.0",
"json-crdt-traces": "https://github.com/streamich/json-crdt-traces#ec825401dc05cbb74b9e0b3c4d6527399f54d54d",
"json-logic-js": "^2.0.2",
"monaco-editor": "^0.54.0",
"nano-theme": "^1.4.3",
"nice-ui": "^1.30.0",
"prosemirror-model": "^1.25.1",
Expand Down
3 changes: 2 additions & 1 deletion packages/json-joy/src/json-crdt-extensions/ModelWithExt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ extensions.register(ext.cnt);
extensions.register(ext.mval);
extensions.register(ext.peritext);
extensions.register(ext.quill);
extensions.register(ext.prosemirror);

export {ext};

Expand All @@ -31,7 +32,7 @@ export class ModelWithExt {
sid?: number,
schema?: S,
): Model<SchemaToJsonNode<S>> => {
const model = Model.load(data, sid, schema);
const model = Model.load<S>(data, sid, schema);
model.ext = extensions;
return model;
};
Expand Down
2 changes: 2 additions & 0 deletions packages/json-joy/src/json-crdt-extensions/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ export enum ExtensionId {
cnt = 1,
peritext = 2,
quill = 3,
prosemirror = 4,
}

export enum ExtensionName {
mval = ExtensionId.mval,
cnt = ExtensionId.cnt,
peritext = ExtensionId.peritext,
quill = ExtensionId.quill,
prosemirror = ExtensionId.prosemirror,
}
3 changes: 2 additions & 1 deletion packages/json-joy/src/json-crdt-extensions/ext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ import {cnt} from './cnt';
import {mval} from './mval';
import {peritext} from './peritext';
import {quill} from './quill-delta';
import {prosemirror} from './prosemirror';

export {cnt, mval, peritext, quill};
export {cnt, mval, peritext, quill, prosemirror};
1 change: 1 addition & 0 deletions packages/json-joy/src/json-crdt-extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from './mval';
export * from './cnt';
export * from './peritext';
export * from './quill-delta';
export * from './prosemirror';
export * from './ext';
export * from './ModelWithExt';
export * from './constants';
94 changes: 94 additions & 0 deletions packages/json-joy/src/json-crdt-extensions/prosemirror/FromPm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {Anchor} from '../peritext/rga/constants';
import {SliceHeaderShift, SliceStacking} from '../peritext/slice/constants';
import type {ViewRange, ViewSlice} from '../peritext/editor/types';
import type {PmFragment, PmNode, PmTextNode} from './types';
import type {SliceTypeStep, SliceTypeSteps} from '../peritext';

/**
* Converts ProseMirror raw nodes to a {@link ViewRange} flat string with
* annotation ranges, which is the natural view format for a Peritext model.
*
* Usage:
*
* ```typescript
* FromPm.convert(node);
* ```
*/
export class FromPm {
static readonly convert = (node: PmNode): ViewRange => new FromPm().convert(node);

private text = '';
private slices: ViewSlice[] = [];

private conv(node: PmNode, path: SliceTypeSteps, nodeDiscriminator: number): void {
const text = this.text;
const start = text.length;
let inlineText: string = '';
const type = node.type.name;
if (type === 'text' && (inlineText = (node as PmTextNode).text || '')) {
this.text += inlineText;
} else {
const content = node.content?.content;
const data = node.attrs;
const step: SliceTypeStep = nodeDiscriminator || data ? [type, nodeDiscriminator, data] : type;
const length = content?.length ?? 0;
const hasNoChildren = length === 0;
const isFirstChildInline = content?.[0]?.type.name === 'text';
const doEmitSplitMarker = hasNoChildren || isFirstChildInline;
if (doEmitSplitMarker) {
this.text += '\n';
const header =
(SliceStacking.Marker << SliceHeaderShift.Stacking) +
(Anchor.Before << SliceHeaderShift.X1Anchor) +
(Anchor.Before << SliceHeaderShift.X2Anchor);
const slice: ViewSlice = [header, start, start, [...path, step]];
this.slices.push(slice);
}
if (length > 0) this.cont([...path, step], content!);
}
const marks = node.marks;
let length = 0;
if (marks && (length = marks.length) > 0) {
const end = start + inlineText.length;
for (let i = 0; i < length; i++) {
const mark = marks[i];
const type = mark.type.name;
const data = mark.attrs;
let dataEmpty = true;
for (const _ in data) {
dataEmpty = false;
break;
}
const stacking: SliceStacking = dataEmpty ? SliceStacking.One : SliceStacking.Many;
const header =
(stacking << SliceHeaderShift.Stacking) +
(Anchor.Before << SliceHeaderShift.X1Anchor) +
(Anchor.After << SliceHeaderShift.X2Anchor);
const slice: ViewSlice = [header, start, end, type];
if (!dataEmpty) slice.push(data);
this.slices.push(slice);
}
}
}

private cont(path: SliceTypeSteps, content: PmFragment['content']): void {
let prevTag: string = '';
let discriminator: number = 0;
const length = content.length;
for (let i = 0; i < length; i++) {
const child = content[i];
const tag = child.type.name;
discriminator = tag === prevTag ? discriminator + 1 : 0;
this.conv(child, path, discriminator);
prevTag = tag;
}
}

public convert(node: PmNode): ViewRange {
const content = node.content?.content;
let length = 0;
if (content && (length = content.length) > 0) this.cont([], content);
Comment on lines +89 to +90
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The value assigned to length here is unused.

Suggested change
let length = 0;
if (content && (length = content.length) > 0) this.cont([], content);
if (content && content.length > 0) this.cont([], content);

Copilot uses AI. Check for mistakes.
const view: ViewRange = [this.text, 0, this.slices];
return view;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {NodeApi} from '../../json-crdt/model/api/nodes';
import {FromPm} from './FromPm';
import type {ProseMirrorNode} from './ProseMirrorNode';
import type {ArrApi, ArrNode, ExtApi, StrApi} from '../../json-crdt';
import type {SliceNode} from '../peritext/slice/types';
import type {PmNode} from './types';

export class ProseMirrorApi extends NodeApi<ProseMirrorNode> implements ExtApi<ProseMirrorNode> {
public text(): StrApi {
return this.api.wrap(this.node.text());
}

public slices(): ArrApi<ArrNode<SliceNode>> {
return this.api.wrap(this.node.slices());
}

public mergePmNode(node: PmNode) {
const txt = this.node.txt;
// TODO: to speed up convert directly to .merge() internal format.
const viewRange = FromPm.convert(node);
txt.editor.merge(viewRange);
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mergePmNode method should return the value from txt.editor.merge(viewRange). The test file setup.ts expects this method to return a tuple of patches (line 56), but the current implementation doesn't return anything. The underlying merge method returns [patch1: Patch | undefined, patch2: Patch | undefined, patch3: Patch | undefined] which should be propagated to the caller.

Suggested change
txt.editor.merge(viewRange);
return txt.editor.merge(viewRange);

Copilot uses AI. Check for mistakes.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import {type Block, type Inline, LeafBlock, Peritext} from '../peritext';
import {ExtensionId} from '../constants';
import {MNEMONIC} from './constants';
import {ExtNode} from '../../json-crdt/extensions/ExtNode';
import {Slice} from '../peritext/slice/Slice';
import type {StrNode} from '../../json-crdt/nodes/str/StrNode';
import type {ArrNode} from '../../json-crdt/nodes/arr/ArrNode';
import type {PmAttrs, PmDataNode, PmJsonMark, PmJsonNode, PmJsonTextNode} from './types';

export class ProseMirrorNode extends ExtNode<PmDataNode> {
public readonly txt: Peritext<string>;

constructor(public readonly data: PmDataNode) {
super(data);
this.txt = new Peritext<string>(data.doc, this.text(), this.slices());
}

public text(): StrNode<string> {
return this.data.get(0)!;
}

public slices(): ArrNode {
return this.data.get(1)!;
}

// ------------------------------------------------------------------ ExtNode
public readonly extId = ExtensionId.prosemirror;

public name(): string {
return MNEMONIC;
}

private _view: PmJsonNode | null = null;
private _viewHash: number = -1;

private toPM(block: Block | LeafBlock): PmJsonNode {
const content: PmJsonNode['content'] = [];
const node: PmJsonNode = {type: block.tag() + ''};
if (block instanceof LeafBlock) {
for (let iterator = block.texts0(), inline: Inline | undefined; (inline = iterator()); ) {
const text = inline.text();
if (!text) continue;
const textNode: PmJsonTextNode = {
type: 'text',
text,
};
const slices = inline.p1.layers;
const length = slices.length;
if (length > 0) {
const marks: PmJsonMark[] = [];
for (let i = 0; i < length; i++) {
const slice = slices[i];
if (slice instanceof Slice) {
const tag = slice.type() + '';
const data = slice.data();
const mark: PmJsonMark = {type: tag};
if (data && typeof data === 'object' && !Array.isArray(data)) mark.attrs = data as PmAttrs;
marks.push(mark);
}
}
textNode.marks = marks;
}
content.push(textNode);
}
} else {
const children = block.children;
const length = children.length;
for (let i = 0; i < length; i++) content.push(this.toPM(children[i]));
}
if (content.length) node.content = content;
const data = block.attr();
if (data && typeof data === 'object') {
for (const _ in data) {
node.attrs = data as PmAttrs;
break;
}
}
return node;
}

public view(): PmJsonNode {
const {txt} = this;
const hash = txt.refresh();
if (this._view && hash === this._viewHash) return this._view;
const content: PmJsonNode[] = [];
const node: PmJsonNode = {type: 'doc', content};
const block = txt.blocks.root;
const children = block.children;
const length = children.length;
for (let i = 0; i < length; i++) content.push(this.toPM(children[i]));
this._viewHash = hash;
this._view = node;
return node;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import * as fixtures from './fixtures';
import {assertCanConvert} from './setup';

for (const [name, fixture] of Object.entries(fixtures)) {
test(name, () => {
assertCanConvert(fixture);
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import {assertCanConvert} from './setup';
import {NodeToViewRangeFuzzer} from './fuzzer';

test('fuzzer', () => {
for (let i = 0; i < 100; i++) {
const doc = NodeToViewRangeFuzzer.doc();
assertCanConvert(doc);
assertCanConvert(doc);
}
});
Loading