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:
2026-06-07 16:30:05 +07:00
parent 626fbc70d8
commit 09272dffeb
136 changed files with 7248 additions and 0 deletions
+177
View File
@@ -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;
}