diff --git a/src/json-crdt-extensions/peritext/block/Inline.ts b/src/json-crdt-extensions/peritext/block/Inline.ts index 70bdc0dec7..5b9b8c96d9 100644 --- a/src/json-crdt-extensions/peritext/block/Inline.ts +++ b/src/json-crdt-extensions/peritext/block/Inline.ts @@ -160,12 +160,12 @@ export class Inline extends Range implements Printable { stack.push(this.createAttr(slice)); break; } - case SliceBehavior.Stack: { + case SliceBehavior.Many: { const stack: InlineAttrStack = attr[type] ?? (attr[type] = []); stack.push(this.createAttr(slice)); break; } - case SliceBehavior.Overwrite: { + case SliceBehavior.One: { attr[type] = [this.createAttr(slice)]; break; } diff --git a/src/json-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts index 3d699c4513..9ed5be3368 100644 --- a/src/json-crdt-extensions/peritext/editor/Editor.ts +++ b/src/json-crdt-extensions/peritext/editor/Editor.ts @@ -6,12 +6,32 @@ import {isLetter, isPunctuation, isWhitespace} from './util'; import {Anchor} from '../rga/constants'; import {MarkerOverlayPoint} from '../overlay/MarkerOverlayPoint'; import {UndefEndIter, type UndefIterator} from '../../../util/iterator'; +import {PersistedSlice} from '../slice/PersistedSlice'; +import 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'; +/** + * For inline boolean ("Overwrite") slices, both range endpoints should be + * attached to {@link Anchor.Before} as per the Peritext paper. This way, say + * bold text, automatically extends to include the next character typed as + * user types. + * + * @param range The range to be adjusted. + */ +const makeRangeExtendable = (range: Range): void => { + if (range.end.anchor !== Anchor.Before || range.start.anchor !== Anchor.Before) { + const start = range.start.clone(); + const end = range.end.clone(); + start.refBefore(); + end.refBefore(); + range.set(start, end); + } +}; + export class Editor { public readonly saved: EditorSlices; public readonly extra: EditorSlices; @@ -461,6 +481,82 @@ export class Editor { if (unit) this.select(unit); } + // --------------------------------------------------------------- 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; + } + + public toggleExclusiveFormatting(type: SliceType, data?: unknown, store: EditorSlices = this.saved): void { + // TODO: handle mutually exclusive slices (, ) + const overlay = this.txt.overlay; + overlay.refresh(); // TODO: Refresh for `overlay.stat()` calls. Is it actually needed? + for (let i = this.cursors0(), cursor = i(); cursor; cursor = i()) { + const [complete] = overlay.stat(cursor, 1e6); + const needToRemoveFormatting = complete.has(type); + makeRangeExtendable(cursor); + const contained = overlay.findContained(cursor); + for (const slice of contained) { + if (slice instanceof PersistedSlice && slice.type === type) { + const deletionStore = this.getSliceStore(slice); + if (deletionStore) deletionStore.del(slice.id); + } + } + if (needToRemoveFormatting) { + overlay.refresh(); + const [complete2, partial2] = overlay.stat(cursor, 1e6); + const needsErase = complete2.has(type) || partial2.has(type); + if (needsErase) store.insErase(type); + } else { + if (cursor.start.isAbs() || cursor.end.isAbs()) continue; + store.insOverwrite(type, data); + } + } + } + + public eraseFormatting(store: EditorSlices = this.saved): void { + const overlay = this.txt.overlay; + for (let i = this.cursors0(), cursor = i(); cursor; cursor = i()) { + overlay.refresh(); + const contained = overlay.findContained(cursor); + for (const slice of contained) { + if (slice instanceof PersistedSlice) { + switch (slice.behavior) { + case SliceBehavior.One: + case SliceBehavior.Many: + case SliceBehavior.Erase: { + const deletionStore = this.getSliceStore(slice); + if (deletionStore) deletionStore.del(slice.id); + } + } + } + } + overlay.refresh(); + const overlapping = overlay.findOverlapping(cursor); + for (const slice of overlapping) { + switch (slice.behavior) { + case SliceBehavior.One: + case SliceBehavior.Many: { + store.insErase(slice.type); + } + } + } + } + } + + public clearFormatting(store: EditorSlices = this.saved): void { + const overlay = this.txt.overlay; + overlay.refresh(); + for (let i = this.cursors0(), cursor = i(); cursor; cursor = i()) { + const overlapping = overlay.findOverlapping(cursor); + for (const slice of overlapping) store.del(slice.id); + } + } + // ------------------------------------------------------------------ various public point(at: Position): Point { diff --git a/src/json-crdt-extensions/peritext/overlay/Overlay.ts b/src/json-crdt-extensions/peritext/overlay/Overlay.ts index 7464d062b0..aa988f058d 100644 --- a/src/json-crdt-extensions/peritext/overlay/Overlay.ts +++ b/src/json-crdt-extensions/peritext/overlay/Overlay.ts @@ -355,7 +355,7 @@ export class Overlay implements Printable, Stateful { if (typeof type === 'object') continue LAYERS; const behavior = slice.behavior; BEHAVIOR: switch (behavior) { - case SliceBehavior.Overwrite: + case SliceBehavior.One: current.add(type); break BEHAVIOR; case SliceBehavior.Erase: diff --git a/src/json-crdt-extensions/peritext/rga/Point.ts b/src/json-crdt-extensions/peritext/rga/Point.ts index 77120630c4..7d3154fdd7 100644 --- a/src/json-crdt-extensions/peritext/rga/Point.ts +++ b/src/json-crdt-extensions/peritext/rga/Point.ts @@ -1,7 +1,7 @@ import {compare, type ITimestampStruct, printTs, equal, tick, containsId} from '../../../json-crdt-patch/clock'; import {Anchor} from './constants'; import {ChunkSlice} from '../util/ChunkSlice'; -import {updateId} from '../../../json-crdt/hash'; +import {hashId, updateId} from '../../../json-crdt/hash'; import {Position} from '../constants'; import type {AbstractRga, Chunk} from '../../../json-crdt/nodes/rga'; import type {Stateful} from '../types'; @@ -481,6 +481,10 @@ export class Point implements Pick, Printable { return this.step(length / 2); } + public key(): number { + return hashId(this.id) + (this.anchor ? 0 : 1); + } + // ----------------------------------------------------------------- Stateful public refresh(): number { diff --git a/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts b/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts index 0548e24705..b8d12c4b51 100644 --- a/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts +++ b/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts @@ -4,7 +4,14 @@ import {Range} from '../rga/Range'; import {updateNode} from '../../../json-crdt/hash'; import {printTree} from 'tree-dump/lib/printTree'; import type {Anchor} from '../rga/constants'; -import {SliceHeaderMask, SliceHeaderShift, SliceBehavior, SliceTupleIndex, SliceBehaviorName} from './constants'; +import { + SliceHeaderMask, + SliceHeaderShift, + SliceBehavior, + SliceTupleIndex, + SliceBehaviorName, + CommonSliceType, +} from './constants'; import {CONST} from '../../../json-hash'; import {Timestamp} from '../../../json-crdt-patch/clock'; import type {VecNode} from '../../../json-crdt/nodes'; @@ -165,7 +172,10 @@ export class PersistedSlice extends Range implements MutableSlice // ---------------------------------------------------------------- Printable public toStringName(): string { - return 'Range'; + if (typeof this.type === 'number' && Math.abs(this.type) <= 64 && CommonSliceType[this.type]) { + return `slice [${SliceBehaviorName[this.behavior]}] <${CommonSliceType[this.type]}>`; + } + return `slice [${SliceBehaviorName[this.behavior]}] ${JSON.stringify(this.type)}`; } protected toStringHeaderName(): string { diff --git a/src/json-crdt-extensions/peritext/slice/Slices.ts b/src/json-crdt-extensions/peritext/slice/Slices.ts index 9a94169987..901fffd3c9 100644 --- a/src/json-crdt-extensions/peritext/slice/Slices.ts +++ b/src/json-crdt-extensions/peritext/slice/Slices.ts @@ -107,11 +107,11 @@ export class Slices implements Stateful, Printable { } public insStack(range: Range, type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice { - return this.ins(range, SliceBehavior.Stack, type, data); + return this.ins(range, SliceBehavior.Many, type, data); } public insOverwrite(range: Range, type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice { - return this.ins(range, SliceBehavior.Overwrite, type, data); + return this.ins(range, SliceBehavior.One, type, data); } public insErase(range: Range, type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice { @@ -139,6 +139,7 @@ export class Slices implements Stateful, Printable { this.list.del(id); const set = this.set; const api = set.doc.api; + if (!set.findById(id)) return; // TODO: Is it worth checking if the slice is already deleted? api.builder.del(set.id, [tss(id.sid, id.time, 1)]); api.apply(); diff --git a/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts b/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts index 96cd84e769..50ee4c4df8 100644 --- a/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts +++ b/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts @@ -17,11 +17,11 @@ describe('.ins()', () => { test('can insert a slice', () => { const {peritext, slices} = setup(); const range = peritext.rangeAt(12, 7); - const slice = slices.ins(range, SliceBehavior.Stack, 'b', {bold: true}); + const slice = slices.ins(range, SliceBehavior.Many, 'b', {bold: true}); expect(peritext.savedSlices.size()).toBe(1); expect(slice.start).toStrictEqual(range.start); expect(slice.end).toStrictEqual(range.end); - expect(slice.behavior).toBe(SliceBehavior.Stack); + expect(slice.behavior).toBe(SliceBehavior.Many); expect(slice.type).toBe('b'); expect(slice.data()).toStrictEqual({bold: true}); }); @@ -80,7 +80,7 @@ describe('.ins()', () => { const ranges = [r1, r2, r3, r4]; const types = ['b', ['li', 'ul'], 0, 123, [1, 2, 3]]; const datas = [{bold: true}, {list: 'ul'}, 0, 123, [1, 2, 3], null, undefined]; - const behaviors = [SliceBehavior.Stack, SliceBehavior.Erase, SliceBehavior.Overwrite, SliceBehavior.Marker]; + const behaviors = [SliceBehavior.Many, SliceBehavior.Erase, SliceBehavior.One, SliceBehavior.Marker]; for (const range of ranges) { for (const type of types) { for (const data of datas) { @@ -176,8 +176,8 @@ describe('.refresh()', () => { }; testSliceUpdate('slice behavior change', ({slice}) => { - slice.update({behavior: SliceBehavior.Stack}); - expect(slice.behavior).toBe(SliceBehavior.Stack); + slice.update({behavior: SliceBehavior.Many}); + expect(slice.behavior).toBe(SliceBehavior.Many); }); testSliceUpdate('slice type change', ({slice}) => { diff --git a/src/json-crdt-extensions/peritext/slice/constants.ts b/src/json-crdt-extensions/peritext/slice/constants.ts index 4fd36bf551..a3adf9852c 100644 --- a/src/json-crdt-extensions/peritext/slice/constants.ts +++ b/src/json-crdt-extensions/peritext/slice/constants.ts @@ -13,7 +13,7 @@ export enum CursorAnchor { * Built-in slice types. */ export enum CommonSliceType { - // Block slices + // ---------------------------------------------------- block slices (0 to 64) p = 0, //

blockquote = 1, //

codeblock = 2, //

@@ -36,7 +36,7 @@ export enum CommonSliceType {
   aside = 19, //