import { Timestamp, timestampCompare } from "../common.ts"; import { AnyCausalTreeOp, CausalTree, CausalTreeOp } from "./causal-tree.ts"; export type PlainTextOperation = | { type: "insert"; sequence: string; } | { type: "delete" }; export class PlainTextORDT extends CausalTree { onevent?: ( op: CausalTreeOp, textIndex: number, affectedLength: number, ) => void; // caches for insert op <=> text transformation, // all these arrays should be the same length (SoA) opIndexCache: number[] = []; sequences: string[] = []; timestamps: Timestamp[] = []; deleted: boolean[] = []; override apply(op: AnyCausalTreeOp): number { const opIdx = super.apply(op); op = this.operations[opIdx]; const [idx, len] = this.#applyCacheUpdate(op, opIdx); this.onevent?.(op, idx, len); return opIdx; } #applyCacheUpdate(op: CausalTreeOp, opIdx: number): [number, number] { for (let i = 0; i < this.opIndexCache.length; i++) { if (this.opIndexCache[i] >= opIdx) { this.opIndexCache[i] += 1; } } const parentTimestamp = op.parent?.at; const parentCacheIdx = this.timestamps.findIndex(it => it === parentTimestamp); if (op.type === "insert") { let cacheIdx = parentCacheIdx + 1; for (; cacheIdx < this.timestamps.length; cacheIdx++) { const curr = this.timestamps[cacheIdx]; if (timestampCompare(curr, op.at) < 0) break; } this.sequences.splice(cacheIdx, 0, op.sequence); this.timestamps.splice(cacheIdx, 0, op.at); this.deleted.splice(cacheIdx, 0, false); this.opIndexCache.splice(cacheIdx, 0, opIdx); let textIndex = 0; for (let i = 0; i < cacheIdx; i++) { if (this.deleted[i]) continue; textIndex += this.sequences[i].length; } return [textIndex, op.sequence.length]; } if (op.type === "delete" && parentCacheIdx !== -1) { this.deleted[parentCacheIdx] = true; let textIndex = 0; for (let i = 0; i < parentCacheIdx; i++) { if (this.deleted[i]) continue; textIndex += this.sequences[i].length; } return [textIndex, -this.sequences[parentCacheIdx].length]; } return [-1, 0]; } findOpAtTextIndex(textIndex: number): number { let start = 0; for (let idx = 0; idx < this.sequences.length; idx++) { if (this.deleted[idx]) continue; const sequence = this.sequences[idx]; if (start < textIndex && textIndex <= start + sequence.length) return this.opIndexCache[idx]; start += this.sequences[idx].length; } return -1; } render() { let s = ""; let start = 0; const metadata = []; for (let idx = 0; idx < this.sequences.length; idx++) { if (this.deleted[idx]) continue; const sequence = this.sequences[idx]; s += sequence; metadata.push({ start, end: start + sequence.length, op: this.operations[this.opIndexCache[idx]], }); start += sequence.length; } return [s, metadata] as const; } /* very slow, uncached: findOpAtTextIndex(textIndex: number): number { const [_string, metadata] = this.render(); for (const meta of metadata) { if (meta.start < textIndex && textIndex <= meta.end) return this.operations.findIndex(it => it === meta.op); } return -1; } render() { const s: string[] = []; const metadata = []; let length = 0; for (const op of this.operations) { if (op.type === "insert") { s.push(op.sequence); metadata.push({ op, start: length, end: length + op.sequence.length, }); length += op.sequence.length; } if (op.type === "delete") { if (!op.parent) continue; if (op.parent.type !== "insert") continue; const len = op.parent.sequence.length; length -= len; // since we expect to be tacked on close to the referenced op, // we can quickly seek back from current position // (it should only take a few iterations - usually 1) for (let idx = metadata.length - 1; idx >= 0; idx--) { const m = metadata[idx]; m.start -= len; m.end -= len; if (m.op === op.parent) { s.splice(idx, 1); metadata.splice(idx, 1); break; } } } } return [s.join(""), metadata] as const; } */ }