feat(editor): eslint/tsconfig migration + type fixes
@robonen/editor: migrate to eslint flat config + composite tsconfig; fix convergence test type annotations.
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user