diff --git a/src/json-crdt-extensions/peritext/__tests__/Peritext.render-block.spec.ts b/src/json-crdt-extensions/peritext/__tests__/Peritext.render-block.spec.ts index 0cceef724d..92bca487d1 100644 --- a/src/json-crdt-extensions/peritext/__tests__/Peritext.render-block.spec.ts +++ b/src/json-crdt-extensions/peritext/__tests__/Peritext.render-block.spec.ts @@ -220,17 +220,17 @@ runInlineSlicesTests('text with block split', (editor: Editor) => { runInlineSlicesTests('text with deletes', (editor: Editor) => { editor.insert('lmXXXnwYxyz'); editor.cursor.setAt(2, 3); - editor.cursor.del(); + editor.del(); editor.cursor.setAt(3); editor.insert('opqrstuv'); editor.cursor.setAt(12, 1); - editor.cursor.del(); + editor.del(); editor.cursor.setAt(0); editor.insert('ab1c3defghijk4444'); editor.cursor.setAt(2, 1); - editor.cursor.del(); + editor.del(); editor.cursor.setAt(3, 1); - editor.cursor.del(); + editor.del(); editor.cursor.setAt(11, 4); - editor.cursor.del(); + editor.del(); }); diff --git a/src/json-crdt-extensions/peritext/__tests__/Peritext.spec.ts b/src/json-crdt-extensions/peritext/__tests__/Peritext.spec.ts index 94e58966f0..e2fd04bee9 100644 --- a/src/json-crdt-extensions/peritext/__tests__/Peritext.spec.ts +++ b/src/json-crdt-extensions/peritext/__tests__/Peritext.spec.ts @@ -109,7 +109,7 @@ const run = (setup: () => Kit) => { const {peritext, model} = setup(); const {editor} = peritext; expect(editor.cursor.isCollapsed()).toBe(true); - editor.cursor.collapse(); + editor.collapseCursors(); expect(editor.cursor.isCollapsed()).toBe(true); expect((model.view() as any).text).toBe('hello world'); }); @@ -119,7 +119,7 @@ const run = (setup: () => Kit) => { const {editor} = peritext; editor.cursor.setAt(2, 3); expect(editor.cursor.isCollapsed()).toBe(false); - editor.cursor.collapse(); + editor.collapseCursors(); expect(editor.cursor.isCollapsed()).toBe(true); expect((model.view() as any).text).toBe('he world'); }); @@ -129,12 +129,12 @@ const run = (setup: () => Kit) => { const {editor} = peritext; peritext.editor.cursor.setAt(0, 1); expect(editor.cursor.isCollapsed()).toBe(false); - editor.cursor.collapse(); + editor.collapseCursors(); expect(editor.cursor.isCollapsed()).toBe(true); expect((model.view() as any).text).toBe('ello world'); editor.cursor.setAt(0, 1); expect(editor.cursor.isCollapsed()).toBe(false); - editor.cursor.collapse(); + editor.collapseCursors(); expect(editor.cursor.isCollapsed()).toBe(true); expect((model.view() as any).text).toBe('llo world'); }); @@ -144,12 +144,12 @@ const run = (setup: () => Kit) => { const {editor} = peritext; editor.cursor.setAt(peritext.str.length() - 1, 1); expect(editor.cursor.isCollapsed()).toBe(false); - editor.cursor.collapse(); + editor.collapseCursors(); expect(editor.cursor.isCollapsed()).toBe(true); expect((model.view() as any).text).toBe('hello worl'); peritext.editor.cursor.setAt(peritext.str.length() - 1, 1); expect(editor.cursor.isCollapsed()).toBe(false); - editor.cursor.collapse(); + editor.collapseCursors(); expect(editor.cursor.isCollapsed()).toBe(true); expect((model.view() as any).text).toBe('hello wor'); }); @@ -159,7 +159,7 @@ const run = (setup: () => Kit) => { const {editor} = peritext; editor.cursor.setAt(0, peritext.str.length()); expect(editor.cursor.isCollapsed()).toBe(false); - editor.cursor.collapse(); + editor.collapseCursors(); expect(editor.cursor.isCollapsed()).toBe(true); expect((model.view() as any).text).toBe(''); editor.insert('abc'); diff --git a/src/json-crdt-extensions/peritext/block/Block.ts b/src/json-crdt-extensions/peritext/block/Block.ts index 38bbd09e70..af226922f9 100644 --- a/src/json-crdt-extensions/peritext/block/Block.ts +++ b/src/json-crdt-extensions/peritext/block/Block.ts @@ -4,6 +4,7 @@ import {MarkerOverlayPoint} from '../overlay/MarkerOverlayPoint'; import type {OverlayPoint} from '../overlay/OverlayPoint'; import {UndefEndIter, type UndefIterator} from '../../../util/iterator'; import {Inline} from './Inline'; +import {formatType} from '../slice/util'; import type {Path} from '@jsonjoy.com/json-pointer'; import type {Printable} from 'tree-dump'; import type {Peritext} from '../Peritext'; @@ -144,7 +145,7 @@ export class Block implements IBlock, Printable, Stateful { } protected toStringHeader(): string { const hash = `#${this.hash.toString(36).slice(-4)}`; - const tag = `<${this.path.join('.')}>`; + const tag = this.path.map((step) => formatType(step)).join('.'); const header = `${this.toStringName()} ${hash} ${tag}`; return header; } diff --git a/src/json-crdt-extensions/peritext/editor/Cursor.ts b/src/json-crdt-extensions/peritext/editor/Cursor.ts index b0e8455fda..6ed642798a 100644 --- a/src/json-crdt-extensions/peritext/editor/Cursor.ts +++ b/src/json-crdt-extensions/peritext/editor/Cursor.ts @@ -71,60 +71,11 @@ export class Cursor extends PersistedSlice { } } - /** - * Ensures there is no range selection. If user has selected a range, - * the contents is removed and the cursor is set at the start of the range as cursor. - * - * @todo If block boundaries are withing the range, remove the blocks. - * @todo Stress test this method. - * - * @returns Returns the cursor position after the operation. - */ - public collapse(): void { - const deleted = this.txt.delStr(this); - if (deleted) this.collapseToStart(); - } - - /** - * Insert inline text at current cursor position. If cursor selects a range, - * the range is removed and the text is inserted at the start of the range. - */ - public insert(text: string): void { - if (!text) return; - this.collapse(); - const after = this.start.clone(); - after.refAfter(); - const textId = this.txt.ins(after.id, text); - const shift = text.length - 1; - this.setAfter(shift ? tick(textId, shift) : textId); - } - - /** - * Deletes the given number of characters from the current caret position. - * Negative values delete backwards. If the cursor selects a range, the - * range is removed and the cursor is set at the start of the range. - * - * @param step Number of characters to delete. Negative values delete - * backwards. - */ - public del(step: number = -1): void { - if (!this.isCollapsed()) { - this.collapse(); - return; - } - const point1 = this.start.clone(); - const point2 = point1.clone(); - if (step > 0) point2.step(1); - else if (step < 0) point1.step(-1); - else if (step === 0) { - point1.step(-1); - point2.step(1); - } - const txt = this.txt; - const range = txt.range(point1, point2); - txt.delStr(range); - point1.refAfter(); - this.set(point1); + public collapseToStart(anchorSide: CursorAnchor = CursorAnchor.Start): void { + const start = this.start.clone(); + start.refAfter(); + const end = start.clone(); + this.set(start, end, anchorSide); } // ---------------------------------------------------------------- Printable diff --git a/src/json-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts index fe349f07b9..31222e932b 100644 --- a/src/json-crdt-extensions/peritext/editor/Editor.ts +++ b/src/json-crdt-extensions/peritext/editor/Editor.ts @@ -11,13 +11,14 @@ import {UndefEndIter, type UndefIterator} from '../../../util/iterator'; import {PersistedSlice} from '../slice/PersistedSlice'; import {ValueSyncStore} from '../../../util/events/sync-store'; import {formatType} from '../slice/util'; -import type {CommonSliceType} from '../slice'; +import {CommonSliceType, type SliceType} from '../slice'; import type {ChunkSlice} from '../util/ChunkSlice'; import type {Peritext} from '../Peritext'; import type {Point} from '../rga/Point'; import type {Range} from '../rga/Range'; import type {CharIterator, CharPredicate, Position, TextRangeUnit} from './types'; import type {Printable} from 'tree-dump'; +import {tick} from '../../../json-crdt-patch'; /** * For inline boolean ("Overwrite") slices, both range endpoints should be @@ -123,16 +124,46 @@ export class Editor implements Printable { for (let cursor: Cursor | undefined, i = this.cursors0(); (cursor = i()); ) this.delCursor(cursor); } + /** + * Ensures there is no range selection. If user has selected a range, + * the contents is removed and the cursor is set at the start of the range + * as caret. + */ + public collapseCursor(cursor: Cursor): void { + this.delRange(cursor); + cursor.collapseToStart(); + } + + public collapseCursors(): void { + for (let i = this.cursors0(), cursor = i(); cursor; cursor = i()) this.collapseCursor(cursor); + } + // ------------------------------------------------------------- text editing /** * Insert inline text at current cursor position. If cursor selects a range, * the range is removed and the text is inserted at the start of the range. */ + public insert0(cursor: Cursor, text: string): void { + if (!text) return; + if (!cursor.isCollapsed()) this.delRange(cursor); + const after = cursor.start.clone(); + after.refAfter(); + const txt = this.txt; + const textId = txt.ins(after.id, text); + const shift = text.length - 1; + const point = txt.point(shift ? tick(textId, shift) : textId, Anchor.After); + cursor.set(point, point, CursorAnchor.Start); + } + + /** + * Inserts text at the cursor positions and collapses cursors, if necessary. + * The applies any pending inline formatting to the inserted text. + */ public insert(text: string): void { if (!this.hasCursor()) this.addCursor(); for (let cursor: Cursor | undefined, i = this.cursors0(); (cursor = i()); ) { - cursor.insert(text); + this.insert0(cursor, text); const pending = this.pending.value; if (pending.size) { this.pending.next(new Map()); @@ -149,7 +180,16 @@ export class Editor implements Printable { * select a range, deletes the whole range. */ public del(step: number = -1): void { - this.forCursor((cursor) => cursor.del(step)); + this.delete(step, 'char'); + } + + public delRange(range: Range): void { + const txt = this.txt; + const overlay = txt.overlay; + const contained = overlay.findContained(range); + for (const slice of contained) + if (slice instanceof PersistedSlice && slice.behavior !== SliceBehavior.Cursor) slice.del(); + txt.delStr(range); } /** @@ -160,9 +200,10 @@ export class Editor implements Printable { * @param unit A unit of deletion: "char", "word", "line". */ public delete(step: number, unit: 'char' | 'word' | 'line'): void { - this.forCursor((cursor) => { + const txt = this.txt; + for (let i = this.cursors0(), cursor = i(); cursor; cursor = i()) { if (!cursor.isCollapsed()) { - cursor.collapse(); + this.collapseCursor(cursor); return; } let point1 = cursor.start.clone(); @@ -173,12 +214,11 @@ export class Editor implements Printable { point1 = this.skip(point1, -1, unit); point2 = this.skip(point2, 1, unit); } - const txt = this.txt; const range = txt.range(point1, point2); - txt.delStr(range); + this.delRange(range); point1.refAfter(); cursor.set(point1); - }); + } } // ----------------------------------------------------------------- movement @@ -494,7 +534,7 @@ export class Editor implements Printable { public select(unit: TextRangeUnit): void { this.forCursor((cursor) => { const range = this.range(cursor.start, unit); - if (range) cursor.setRange(range); + if (range) cursor.set(range.start, range.end, CursorAnchor.Start); else this.delCursors; }); } @@ -506,14 +546,6 @@ export class Editor implements Printable { // --------------------------------------------------------------- formatting - protected getSliceStore(slice: PersistedSlice): EditorSlices | undefined { - const sid = slice.id.sid; - if (sid === this.saved.slices.set.doc.clock.sid) return this.saved; - if (sid === this.extra.slices.set.doc.clock.sid) return this.extra; - if (sid === this.local.slices.set.doc.clock.sid) return this.local; - return; - } - protected toggleRangeExclFmt( range: Range, type: CommonSliceType | string | number, @@ -527,12 +559,7 @@ export class Editor implements Printable { const needToRemoveFormatting = complete.has(type); makeRangeExtendable(range); const contained = overlay.findContained(range); - for (const slice of contained) { - if (slice instanceof PersistedSlice && slice.type === type) { - const deletionStore = this.getSliceStore(slice); - if (deletionStore) deletionStore.del(slice.id); - } - } + for (const slice of contained) if (slice instanceof PersistedSlice && slice.type === type) slice.del(); if (needToRemoveFormatting) { overlay.refresh(); const [complete2, partial2] = overlay.stat(range, 1e6); @@ -584,10 +611,8 @@ export class Editor implements Printable { switch (slice.behavior) { case SliceBehavior.One: case SliceBehavior.Many: - case SliceBehavior.Erase: { - const deletionStore = this.getSliceStore(slice); - if (deletionStore) deletionStore.del(slice.id); - } + case SliceBehavior.Erase: + slice.del(); } } } @@ -613,6 +638,18 @@ export class Editor implements Printable { } } + public split(type?: SliceType, data?: unknown, slices: EditorSlices = this.saved): void { + for (let i = this.cursors0(), cursor = i(); cursor; cursor = i()) { + this.collapseCursor(cursor); + if (type === void 0) { + // TODO: detect current block type + type = CommonSliceType.p; + } + slices.insMarker(type, data); + cursor.move(1); + } + } + // ------------------------------------------------------------------ various public point(at: Position): Point { diff --git a/src/json-crdt-extensions/peritext/editor/EditorSlices.ts b/src/json-crdt-extensions/peritext/editor/EditorSlices.ts index db1b96749b..e04ff2cd64 100644 --- a/src/json-crdt-extensions/peritext/editor/EditorSlices.ts +++ b/src/json-crdt-extensions/peritext/editor/EditorSlices.ts @@ -35,7 +35,7 @@ export class EditorSlices { public insMarker(type: SliceType, data?: unknown, separator?: string): MarkerSlice[] { return this.insAtCursors((cursor) => { - cursor.collapse(); + this.txt.editor.collapseCursor(cursor); const after = cursor.start.clone(); after.refAfter(); const marker = this.slices.insMarkerAfter(after.id, type, data, separator); diff --git a/src/json-crdt-extensions/peritext/overlay/MarkerOverlayPoint.ts b/src/json-crdt-extensions/peritext/overlay/MarkerOverlayPoint.ts index 079f6ebd11..855e6e4d23 100644 --- a/src/json-crdt-extensions/peritext/overlay/MarkerOverlayPoint.ts +++ b/src/json-crdt-extensions/peritext/overlay/MarkerOverlayPoint.ts @@ -33,7 +33,7 @@ export class MarkerOverlayPoint extends OverlayPoint implements H // ---------------------------------------------------------------- Printable public toStringName(): string { - return 'OverlayPoint'; + return 'MarkerOverlayPoint'; } public toStringHeader(tab: string, lite?: boolean): string { diff --git a/src/json-crdt-extensions/peritext/overlay/Overlay.ts b/src/json-crdt-extensions/peritext/overlay/Overlay.ts index aa988f058d..57fc717b32 100644 --- a/src/json-crdt-extensions/peritext/overlay/Overlay.ts +++ b/src/json-crdt-extensions/peritext/overlay/Overlay.ts @@ -1,7 +1,7 @@ import {printTree} from 'tree-dump/lib/printTree'; import {printBinary} from 'tree-dump/lib/printBinary'; import {first, insertLeft, insertRight, last, next, prev, remove} from 'sonic-forest/lib/util'; -import {first2, insert2, next2, remove2} from 'sonic-forest/lib/util2'; +import {first2, insert2, last2, next2, remove2} from 'sonic-forest/lib/util2'; import {splay} from 'sonic-forest/lib/splay/util'; import {Anchor} from '../rga/constants'; import {OverlayPoint} from './OverlayPoint'; @@ -64,6 +64,14 @@ export class Overlay implements Printable, Stateful { return this.root ? last(this.root) : undefined; } + public firstMarker(): MarkerOverlayPoint | undefined { + return this.root2 ? first2(this.root2) : undefined; + } + + public lastMarker(): MarkerOverlayPoint | undefined { + return this.root2 ? last2(this.root2) : undefined; + } + /** * Retrieve overlay point or the previous one, measured in spacial dimension. */ @@ -116,6 +124,33 @@ export class Overlay implements Printable, Stateful { return result; } + /** + * Retrieve a {@link MarkerOverlayPoint} at the specified point or the + * previous one, measured in spacial dimension. + */ + public getOrNextLowerMarker(point: Point): MarkerOverlayPoint | undefined { + if (point.isAbsStart()) { + const first = this.firstMarker(); + if (!first) return; + if (first.isAbsStart()) return first; + point = first; + } else if (point.isAbsEnd()) return this.lastMarker(); + let curr: MarkerOverlayPoint | undefined = this.root2; + let result: MarkerOverlayPoint | undefined = undefined; + while (curr) { + const cmp = curr.cmpSpatial(point); + if (cmp === 0) return curr; + if (cmp > 0) curr = curr.l2; + else { + const next = curr.r2; + result = curr; + if (!next) return result; + curr = next; + } + } + return result; + } + /** @todo Rename to `chunks()`. */ /** @todo Rewrite this as `UndefIterator`. */ public chunkSlices0( diff --git a/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts b/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts index b8d12c4b51..c73ff8ab33 100644 --- a/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts +++ b/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts @@ -14,10 +14,10 @@ import { } from './constants'; import {CONST} from '../../../json-hash'; import {Timestamp} from '../../../json-crdt-patch/clock'; -import type {VecNode} from '../../../json-crdt/nodes'; import {prettyOneLine} from '../../../json-pretty'; import {validateType} from './util'; import {s} from '../../../json-crdt-patch'; +import type {VecNode} from '../../../json-crdt/nodes'; import type {ITimestampStruct} from '../../../json-crdt-patch/clock'; import type {ArrChunk} from '../../../json-crdt/nodes'; import type {MutableSlice, SliceView, SliceType, SliceUpdateParams} from './types'; @@ -26,6 +26,7 @@ import type {Printable} from 'tree-dump/lib/types'; import type {AbstractRga} from '../../../json-crdt/nodes/rga'; import type {Model} from '../../../json-crdt/model'; import type {Peritext} from '../Peritext'; +import type {Slices} from './Slices'; /** * A persisted slice is a slice that is stored in a {@link Model}. It is used for @@ -145,6 +146,24 @@ export class PersistedSlice extends Range implements MutableSlice return node && this.model.api.wrap(node); } + public getStore(): Slices | undefined { + const txt = this.txt; + const sid = this.id.sid; + let store = txt.savedSlices; + if (sid === store.set.doc.clock.sid) return store; + store = txt.localSlices; + if (sid === store.set.doc.clock.sid) return store; + store = txt.extraSlices; + if (sid === store.set.doc.clock.sid) return store; + return; + } + + public del(): void { + const store = this.getStore(); + if (!store) return; + store.del(this.id); + } + public isDel(): boolean { return this.chunk.del; } diff --git a/src/json-crdt-extensions/peritext/slice/constants.ts b/src/json-crdt-extensions/peritext/slice/constants.ts index d1aa0add4e..5b29659a62 100644 --- a/src/json-crdt-extensions/peritext/slice/constants.ts +++ b/src/json-crdt-extensions/peritext/slice/constants.ts @@ -56,12 +56,13 @@ export enum CommonSliceType { font = -16, // col = -17, // bg = -18, // - hidden = -19, // - footnote = -20, // or with href="#footnote-..." and title="Footnote ..." - ref = -21, // with href="#ref-..." and title="Reference ..." - iaside = -22, // Inline