diff --git a/src/json-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts index 46e13551e1..86fa8d7209 100644 --- a/src/json-crdt-extensions/peritext/editor/Editor.ts +++ b/src/json-crdt-extensions/peritext/editor/Editor.ts @@ -688,9 +688,12 @@ export class Editor implements Printable { const offset = r.start.viewPos(); const viewSlices: ViewSlice[] = []; const view: ViewRange = [text, offset, viewSlices]; - const overlay = this.txt.overlay; + const txt = this.txt; + const overlay = txt.overlay; const slices = overlay.findOverlapping(r); for (const slice of slices) { + const isSavedSlice = slice.id.sid === txt.model.clock.sid; + if (!isSavedSlice) continue; const behavior = slice.behavior; switch (behavior) { case SliceBehavior.One: @@ -715,19 +718,60 @@ export class Editor implements Printable { public import(pos: number, view: ViewRange): void { const [text, offset, slices] = view; const txt = this.txt; - txt.insAt(pos, text); const length = slices.length; + const splits: ViewSlice[] = []; + const annotations: ViewSlice[] = []; + const texts: string[] = []; + let start = 0; for (let i = 0; i < length; i++) { const slice = slices[i]; + const [header, x1] = slice; + const behavior: SliceBehavior = (header & SliceHeaderMask.Behavior) >>> SliceHeaderShift.Behavior; + if (behavior === SliceBehavior.Marker) { + const end = x1 - offset; + texts.push(text.slice(start, end)); + start = end + 1; + splits.push(slice); + } else annotations.push(slice); + } + const lastText = text.slice(start); + const splitLength = splits.length; + start = pos; + for (let i = 0; i < splitLength; i++) { + const str = texts[i]; + const split = splits[i]; + if (str) { + txt.insAt(start, str); + start += str.length; + } + if (split) { + const [, , , type, data] = split; + const after = txt.pointAt(start); + after.refAfter(); + txt.savedSlices.insMarkerAfter(after.id, type, data); + start += 1; + } + } + if (lastText) txt.insAt(start, lastText); + const annotationsLength = annotations.length; + for (let i = 0; i < annotationsLength; i++) { + const slice = annotations[i]; const [header, x1, x2, type, data] = slice; const anchor1: Anchor = (header & SliceHeaderMask.X1Anchor) >>> SliceHeaderShift.X1Anchor; const anchor2: Anchor = (header & SliceHeaderMask.X2Anchor) >>> SliceHeaderShift.X2Anchor; const behavior: SliceBehavior = (header & SliceHeaderMask.Behavior) >>> SliceHeaderShift.Behavior; - const range = txt.rangeAt(Math.max(0, x1 - offset + pos), x2 - x1); - if (anchor1 === Anchor.Before) range.start.refBefore(); - else range.start.refAfter(); + const x1Src = x1 - offset; + const x2Src = x2 - offset; + const x1Capped = Math.max(0, x1Src); + const x2Capped = Math.min(text.length, x2Src); + const x1Dest = x1Capped + pos; + const annotationLength = x2Capped - x1Capped; + const range = txt.rangeAt(x1Dest, annotationLength); + if (!!x1Dest && anchor1 === Anchor.After) range.start.refAfter(); + // else range.start.refBefore(); if (anchor2 === Anchor.Before) range.end.refBefore(); - else range.end.refAfter(); + // else range.end.refAfter(); + if (range.end.isAbs()) range.end.refAfter(); txt.savedSlices.ins(range, behavior, type, data); } } diff --git a/src/json-crdt-extensions/peritext/editor/__tests__/Editor-export.spec.ts b/src/json-crdt-extensions/peritext/editor/__tests__/Editor-export.spec.ts index 7590f9668b..e6b4441749 100644 --- a/src/json-crdt-extensions/peritext/editor/__tests__/Editor-export.spec.ts +++ b/src/json-crdt-extensions/peritext/editor/__tests__/Editor-export.spec.ts @@ -1,5 +1,7 @@ import {type Kit, runAlphabetKitTestSuite} from '../../__tests__/setup'; +import {Anchor} from '../../rga/constants'; import {CommonSliceType} from '../../slice'; +import {SliceBehavior, SliceHeaderShift} from '../../slice/constants'; const testSuite = (setup: () => Kit) => { describe('.export()', () => { @@ -10,6 +12,13 @@ const testSuite = (setup: () => Kit) => { expect(json).toEqual(['abcdefghijklmnopqrstuvwxyz', 0, []]); }); + test('can export part of un-annotated document', () => { + const {editor} = setup(); + editor.cursor.setAt(5, 5); + const json = editor.export(editor.cursor); + expect(json).toEqual(['fghij', 5, []]); + }); + test('range which contains bold text', () => { const {editor, peritext} = setup(); editor.cursor.setAt(3, 3); @@ -20,6 +29,18 @@ const testSuite = (setup: () => Kit) => { expect(json).toEqual(['cdefg', 2, [[expect.any(Number), 3, 6, 'bold']]]); }); + test('exports only "saved" slices', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(3, 3); + editor.local.insOverwrite('italic'); + editor.saved.insOverwrite('bold'); + editor.extra.insOverwrite('underline'); + const range = peritext.rangeAt(2, 5); + peritext.refresh(); + const json = editor.export(range); + expect(json).toEqual(['cdefg', 2, [[expect.any(Number), 3, 6, 'bold']]]); + }); + test('range which start in bold text', () => { const {editor, peritext} = setup(); editor.cursor.setAt(3, 10); @@ -39,6 +60,54 @@ const testSuite = (setup: () => Kit) => { const json = editor.export(range); expect(json).toEqual(['abcde', 0, [[expect.any(Number), 3, 13, CommonSliceType.b]]]); }); + + test('can export

marker', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.p); + const range = peritext.rangeAt(8, 5); + peritext.refresh(); + const json = editor.export(range); + const header = + (SliceBehavior.Marker << SliceHeaderShift.Behavior) + + (Anchor.Before << SliceHeaderShift.X1Anchor) + + (Anchor.Before << SliceHeaderShift.X2Anchor); + expect(json).toEqual(['ij\nkl', 8, [[header, 10, 10, CommonSliceType.p]]]); + }); + + test('can export

marker,

marker, and italic text', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(15); + editor.saved.insMarker(CommonSliceType.blockquote); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.p); + editor.cursor.setAt(12, 2); + editor.saved.insOverwrite(CommonSliceType.i); + const range = peritext.rangeAt(8, 12); + peritext.refresh(); + const json = editor.export(range); + const pHeader = + (SliceBehavior.Marker << SliceHeaderShift.Behavior) + + (Anchor.Before << SliceHeaderShift.X1Anchor) + + (Anchor.Before << SliceHeaderShift.X2Anchor); + const iHeader = + (SliceBehavior.One << SliceHeaderShift.Behavior) + + (Anchor.Before << SliceHeaderShift.X1Anchor) + + (Anchor.After << SliceHeaderShift.X2Anchor); + const blockquoteHeader = + (SliceBehavior.Marker << SliceHeaderShift.Behavior) + + (Anchor.Before << SliceHeaderShift.X1Anchor) + + (Anchor.Before << SliceHeaderShift.X2Anchor); + expect(json).toEqual([ + 'ij\nklmno\npqr', + 8, + [ + [pHeader, 10, 10, CommonSliceType.p], + [iHeader, 12, 14, CommonSliceType.i], + [blockquoteHeader, 16, 16, CommonSliceType.blockquote], + ], + ]); + }); }); describe('.import()', () => { @@ -66,6 +135,159 @@ const testSuite = (setup: () => Kit) => { expect(i2.text()).toBe('fghij'); expect(!!i2.attr().bold).toBe(true); }); + + test('can import a contained annotation', () => { + const kit1 = setup(); + kit1.editor.cursor.setAt(0, 3); + kit1.editor.saved.insOverwrite(CommonSliceType.b); + kit1.peritext.refresh(); + const range = kit1.peritext.rangeAt(1, 1); + const view = kit1.editor.export(range); + kit1.editor.import(5, view); + kit1.peritext.refresh(); + const jsonml = kit1.peritext.blocks.toJson(); + expect(jsonml).toEqual([ + '', + null, + [ + CommonSliceType.p, + expect.any(Object), + [CommonSliceType.b, expect.any(Object), 'abc'], + 'de', + [CommonSliceType.b, expect.any(Object), 'b'], + 'fghijklmnopqrstuvwxyz', + ], + ]); + const block = kit1.peritext.blocks.root.children[0]; + const inlines = [...block.texts()]; + const inline = inlines.find((i) => i.text() === 'b')!; + expect(inline.start.anchor).toBe(Anchor.Before); + expect(inline.end.anchor).toBe(Anchor.After); + }); + + test('can import a contained annotation (with end edge anchored to neighbor chars)', () => { + const kit1 = setup(); + kit1.editor.cursor.setAt(0, 3); + const start = kit1.editor.cursor.start.clone(); + const end = kit1.editor.cursor.end.clone(); + start.refAfter(); + end.refBefore(); + kit1.editor.cursor.set(start, end); + kit1.editor.saved.insOverwrite(CommonSliceType.b); + kit1.peritext.refresh(); + const range = kit1.peritext.rangeAt(1, 1); + const view = kit1.editor.export(range); + kit1.editor.import(5, view); + kit1.peritext.refresh(); + const jsonml = kit1.peritext.blocks.toJson(); + expect(jsonml).toEqual([ + '', + null, + [ + CommonSliceType.p, + expect.any(Object), + [CommonSliceType.b, expect.any(Object), 'abc'], + 'de', + [CommonSliceType.b, expect.any(Object), 'b'], + 'fghijklmnopqrstuvwxyz', + ], + ]); + const block = kit1.peritext.blocks.root.children[0]; + const inlines = [...block.texts()]; + const inline = inlines.find((i) => i.text() === 'b')!; + expect(inline.start.anchor).toBe(Anchor.After); + expect(inline.end.anchor).toBe(Anchor.Before); + }); + + test('annotation start edge cannot point to ABS start', () => { + const kit1 = setup(); + kit1.editor.cursor.setAt(1, 2); + const start = kit1.editor.cursor.start.clone(); + const end = kit1.editor.cursor.end.clone(); + start.refAfter(); + end.refBefore(); + kit1.editor.cursor.set(start, end); + kit1.editor.saved.insOverwrite(CommonSliceType.b); + kit1.editor.delCursors(); + kit1.peritext.refresh(); + const range = kit1.peritext.rangeAt(1, 1); + const view = kit1.editor.export(range); + kit1.editor.import(0, view); + kit1.peritext.refresh(); + const jsonml = kit1.peritext.blocks.toJson(); + expect(jsonml).toEqual([ + '', + null, + [ + CommonSliceType.p, + expect.any(Object), + [CommonSliceType.b, expect.any(Object), 'b'], + 'a', + [CommonSliceType.b, expect.any(Object), 'bc'], + 'defghijklmnopqrstuvwxyz', + ], + ]); + const block = kit1.peritext.blocks.root.children[0]; + const inlines = [...block.texts()]; + const inline = inlines.find((i) => i.text() === 'b')!; + expect(inline.start.anchor).toBe(Anchor.Before); + expect(inline.end.anchor).toBe(Anchor.Before); + }); + + test('annotation end edge cannot point to ABS end', () => { + const kit1 = setup(); + kit1.editor.cursor.setAt(1, 2); + const start = kit1.editor.cursor.start.clone(); + const end = kit1.editor.cursor.end.clone(); + start.refAfter(); + end.refBefore(); + kit1.editor.cursor.set(start, end); + kit1.editor.saved.insOverwrite(CommonSliceType.b); + kit1.editor.delCursors(); + kit1.peritext.refresh(); + const range = kit1.peritext.rangeAt(1, 1); + const view = kit1.editor.export(range); + const length = kit1.peritext.strApi().length(); + kit1.editor.import(length, view); + kit1.peritext.refresh(); + const jsonml = kit1.peritext.blocks.toJson(); + expect(jsonml).toEqual([ + '', + null, + [ + CommonSliceType.p, + expect.any(Object), + 'a', + [CommonSliceType.b, expect.any(Object), 'bc'], + 'defghijklmnopqrstuvwxyz', + [CommonSliceType.b, expect.any(Object), 'b'], + ], + ]); + const block = kit1.peritext.blocks.root.children[0]; + const inlines = [...block.texts()]; + const inline = inlines.find((i) => i.text() === 'b')!; + expect(inline.start.anchor).toBe(Anchor.After); + expect(inline.end.anchor).toBe(Anchor.After); + }); + + test('can copy a paragraph split', () => { + const kit1 = setup(); + const kit2 = setup(); + kit1.editor.cursor.setAt(5); + kit1.editor.saved.insMarker(CommonSliceType.p); + kit1.editor.cursor.setAt(3, 5); + kit1.peritext.refresh(); + const json = kit1.editor.export(kit1.editor.cursor); + kit2.editor.import(0, json); + kit2.peritext.refresh(); + const json2 = kit2.peritext.blocks.toJson(); + expect(json2).toEqual([ + '', + null, + [CommonSliceType.p, null, 'de'], + [CommonSliceType.p, null, 'fgabcdefghijklmnopqrstuvwxyz'], + ]); + }); }); };