09272dffeb
@robonen/editor: migrate to eslint flat config + composite tsconfig; fix convergence test type annotations.
178 lines
5.3 KiB
TypeScript
178 lines
5.3 KiB
TypeScript
import type { Mark, Marks } from './marks';
|
|
import { marksEq, normalizeMarks } from './marks';
|
|
|
|
/**
|
|
* A run of text sharing the same marks. The chosen inline representation
|
|
* ("marked runs") renders to per-block contenteditable as a span list and maps
|
|
* isomorphically onto a character-sequence CRDT with formatting.
|
|
*/
|
|
export interface InlineNode {
|
|
readonly text: string;
|
|
readonly marks: Marks;
|
|
}
|
|
|
|
/** A block's inline content: an ordered, normalized list of runs. */
|
|
export type Inline = readonly InlineNode[];
|
|
|
|
/** Total length of inline content in UTF-16 code units (DOM-offset compatible). */
|
|
export function inlineLength(inline: Inline): number {
|
|
let length = 0;
|
|
|
|
for (const run of inline)
|
|
length += run.text.length;
|
|
|
|
return length;
|
|
}
|
|
|
|
/** Concatenated plain text of inline content. */
|
|
export function inlineText(inline: Inline): string {
|
|
let text = '';
|
|
|
|
for (const run of inline)
|
|
text += run.text;
|
|
|
|
return text;
|
|
}
|
|
|
|
/**
|
|
* One UTF-16 code unit carrying its marks. Operations explode inline content to
|
|
* chars, splice, then regroup — obviously correct and cheap for small blocks.
|
|
* UTF-16 units (not code points) keep offsets aligned with the DOM.
|
|
*/
|
|
interface Char {
|
|
readonly ch: string;
|
|
readonly marks: Marks;
|
|
}
|
|
|
|
function toChars(inline: Inline): Char[] {
|
|
const chars: Char[] = [];
|
|
|
|
for (const run of inline) {
|
|
const marks = normalizeMarks(run.marks);
|
|
|
|
for (let i = 0; i < run.text.length; i++)
|
|
chars.push({ ch: run.text[i]!, marks });
|
|
}
|
|
|
|
return chars;
|
|
}
|
|
|
|
function fromChars(chars: readonly Char[]): Inline {
|
|
const runs: InlineNode[] = [];
|
|
|
|
for (const { ch, marks } of chars) {
|
|
const last = runs[runs.length - 1];
|
|
|
|
if (last && marksEq(last.marks, marks))
|
|
runs[runs.length - 1] = { text: last.text + ch, marks: last.marks };
|
|
else
|
|
runs.push({ text: ch, marks });
|
|
}
|
|
|
|
return runs;
|
|
}
|
|
|
|
/**
|
|
* Canonical form: drop empty runs, merge adjacent runs with equal mark sets,
|
|
* normalize each run's marks. Must be applied after every inline mutation so the
|
|
* model stays diff-stable and equality stays cheap.
|
|
*/
|
|
export function normalizeInline(inline: Inline): Inline {
|
|
return fromChars(toChars(inline));
|
|
}
|
|
|
|
/** Inline slice between two character offsets `[from, to)`. */
|
|
export function sliceInline(inline: Inline, from: number, to: number): Inline {
|
|
return fromChars(toChars(inline).slice(from, to));
|
|
}
|
|
|
|
/** Insert `text` (carrying `marks`) at character `offset`. */
|
|
export function insertTextInline(inline: Inline, offset: number, text: string, marks: Marks): Inline {
|
|
if (text.length === 0)
|
|
return normalizeInline(inline);
|
|
|
|
const chars = toChars(inline);
|
|
const normalized = normalizeMarks(marks);
|
|
const inserted: Char[] = [];
|
|
|
|
for (let i = 0; i < text.length; i++)
|
|
inserted.push({ ch: text[i]!, marks: normalized });
|
|
|
|
chars.splice(offset, 0, ...inserted);
|
|
return fromChars(chars);
|
|
}
|
|
|
|
/** Insert inline `content` (preserving its marks) at character `offset`. */
|
|
export function insertInline(inline: Inline, offset: number, content: Inline): Inline {
|
|
const chars = toChars(inline);
|
|
chars.splice(offset, 0, ...toChars(content));
|
|
return fromChars(chars);
|
|
}
|
|
|
|
/** Replace the character range `[from, to)` with inline `content`. */
|
|
export function replaceInline(inline: Inline, from: number, to: number, content: Inline): Inline {
|
|
const chars = toChars(inline);
|
|
chars.splice(from, to - from, ...toChars(content));
|
|
return fromChars(chars);
|
|
}
|
|
|
|
/** Delete the character range `[from, to)`. */
|
|
export function deleteTextInline(inline: Inline, from: number, to: number): Inline {
|
|
const chars = toChars(inline);
|
|
chars.splice(from, to - from);
|
|
return fromChars(chars);
|
|
}
|
|
|
|
/** Add `mark` across `[from, to)`, replacing any existing mark of the same type. */
|
|
export function addMarkInline(inline: Inline, from: number, to: number, mark: Mark): Inline {
|
|
const chars = toChars(inline);
|
|
|
|
for (let i = from; i < to && i < chars.length; i++) {
|
|
const current = chars[i]!;
|
|
chars[i] = { ch: current.ch, marks: normalizeMarks([...current.marks.filter(m => m.type !== mark.type), mark]) };
|
|
}
|
|
|
|
return fromChars(chars);
|
|
}
|
|
|
|
/** Remove every mark of `markType` across `[from, to)`. */
|
|
export function removeMarkInline(inline: Inline, from: number, to: number, markType: string): Inline {
|
|
const chars = toChars(inline);
|
|
|
|
for (let i = from; i < to && i < chars.length; i++) {
|
|
const current = chars[i]!;
|
|
chars[i] = { ch: current.ch, marks: current.marks.filter(m => m.type !== markType) };
|
|
}
|
|
|
|
return fromChars(chars);
|
|
}
|
|
|
|
/**
|
|
* Marks active at a collapsed caret `offset` — used to seed stored marks and to
|
|
* decide toggle state. Defaults to the marks of the character before the caret.
|
|
*/
|
|
export function marksAt(inline: Inline, offset: number): Marks {
|
|
const chars = toChars(inline);
|
|
|
|
if (chars.length === 0)
|
|
return [];
|
|
|
|
const index = offset > 0 ? offset - 1 : 0;
|
|
return chars[Math.min(index, chars.length - 1)]?.marks ?? [];
|
|
}
|
|
|
|
/** Whether the whole range `[from, to)` carries a mark of `markType`. */
|
|
export function rangeHasMarkType(inline: Inline, from: number, to: number, markType: string): boolean {
|
|
const chars = toChars(inline);
|
|
|
|
if (from >= to)
|
|
return false;
|
|
|
|
for (let i = from; i < to && i < chars.length; i++) {
|
|
if (!chars[i]!.marks.some(m => m.type === markType))
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|