-
-
Notifications
You must be signed in to change notification settings - Fork 20
Initial ProseMirror editor support #973
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
Changes from all commits
4b089dc
39a336d
84f0022
f37685d
29af130
3cd1df5
a07b77a
bb12265
ce041e6
65d0346
541f775
42820ec
b2191f7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| 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); | ||||||
|
||||||
| txt.editor.merge(viewRange); | |
| return txt.editor.merge(viewRange); |
| 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); | ||
| } | ||
| }); |
There was a problem hiding this comment.
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.