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,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Inline } from '../inline';
|
||||
import {
|
||||
addMarkInline,
|
||||
deleteTextInline,
|
||||
inlineText,
|
||||
insertTextInline,
|
||||
marksAt,
|
||||
normalizeInline,
|
||||
removeMarkInline,
|
||||
} from '../inline';
|
||||
|
||||
const bold = { type: 'bold' };
|
||||
|
||||
describe('inline', () => {
|
||||
it('merges adjacent equal-mark runs and drops empties', () => {
|
||||
const input: Inline = [{ text: 'a', marks: [] }, { text: '', marks: [] }, { text: 'b', marks: [] }];
|
||||
expect(normalizeInline(input)).toEqual([{ text: 'ab', marks: [] }]);
|
||||
});
|
||||
|
||||
it('inserts text at an offset', () => {
|
||||
expect(inlineText(insertTextInline([{ text: 'ac', marks: [] }], 1, 'b', []))).toBe('abc');
|
||||
});
|
||||
|
||||
it('adds a mark over a range, splitting runs', () => {
|
||||
expect(addMarkInline([{ text: 'abc', marks: [] }], 1, 2, bold)).toEqual([
|
||||
{ text: 'a', marks: [] },
|
||||
{ text: 'b', marks: [bold] },
|
||||
{ text: 'c', marks: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes a mark over a range', () => {
|
||||
expect(removeMarkInline([{ text: 'abc', marks: [bold] }], 0, 3, 'bold')).toEqual([{ text: 'abc', marks: [] }]);
|
||||
});
|
||||
|
||||
it('deletes a range', () => {
|
||||
expect(inlineText(deleteTextInline([{ text: 'abcd', marks: [] }], 1, 3))).toBe('ad');
|
||||
});
|
||||
|
||||
it('reads marks at a caret as the preceding character marks', () => {
|
||||
const marked: Inline = [{ text: 'ab', marks: [bold] }, { text: 'c', marks: [] }];
|
||||
expect(marksAt(marked, 2)).toEqual([bold]);
|
||||
expect(marksAt(marked, 3)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Attribute values are JSON-serializable so documents round-trip losslessly and
|
||||
* a CRDT adapter can map them onto its own primitives without special-casing.
|
||||
*/
|
||||
export type AttrValue
|
||||
= | string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| readonly AttrValue[]
|
||||
| { readonly [key: string]: AttrValue };
|
||||
|
||||
export type Attrs = Readonly<Record<string, AttrValue>>;
|
||||
|
||||
/**
|
||||
* Structural equality for two attribute values. Order-insensitive for object
|
||||
* keys, deep for arrays/objects. Used by mark/attr deduplication and tests.
|
||||
*/
|
||||
export function attrValueEq(a: AttrValue | undefined, b: AttrValue | undefined): boolean {
|
||||
if (a === b)
|
||||
return true;
|
||||
|
||||
if (a === null || b === null || a === undefined || b === undefined)
|
||||
return a === b;
|
||||
|
||||
const aArr = Array.isArray(a);
|
||||
const bArr = Array.isArray(b);
|
||||
|
||||
if (aArr || bArr) {
|
||||
if (!aArr || !bArr || a.length !== b.length)
|
||||
return false;
|
||||
|
||||
return a.every((v, i) => attrValueEq(v, b[i]));
|
||||
}
|
||||
|
||||
if (typeof a === 'object' && typeof b === 'object')
|
||||
return attrsEq(a as Attrs, b as Attrs);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural equality for attribute bags. `undefined` and `{}` are equivalent
|
||||
* so `{ type: 'bold' }` equals `{ type: 'bold', attrs: {} }`.
|
||||
*/
|
||||
export function attrsEq(a?: Attrs, b?: Attrs): boolean {
|
||||
if (a === b)
|
||||
return true;
|
||||
|
||||
const aKeys = a ? Object.keys(a) : [];
|
||||
const bKeys = b ? Object.keys(b) : [];
|
||||
|
||||
if (aKeys.length !== bKeys.length)
|
||||
return false;
|
||||
|
||||
return aKeys.every(key => attrValueEq(a![key], b?.[key]));
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Node } from './node';
|
||||
|
||||
/**
|
||||
* The editor document: an ordered list of top-level blocks. Default blocks are
|
||||
* flat (lists use indent attributes, not nesting), so document helpers operate
|
||||
* on the top-level array.
|
||||
*/
|
||||
export interface EditorDocument {
|
||||
readonly type: 'doc';
|
||||
readonly content: readonly Node[];
|
||||
}
|
||||
|
||||
/** Construct a document from blocks. */
|
||||
export function createDoc(content: readonly Node[] = []): EditorDocument {
|
||||
return { type: 'doc', content };
|
||||
}
|
||||
|
||||
/** Index of a block by id, or `-1` if absent. */
|
||||
export function blockIndex(doc: EditorDocument, id: string): number {
|
||||
return doc.content.findIndex(block => block.id === id);
|
||||
}
|
||||
|
||||
/** A block and its index, or `null` if absent. */
|
||||
export function findBlock(doc: EditorDocument, id: string): { node: Node; index: number } | null {
|
||||
const index = blockIndex(doc, id);
|
||||
return index === -1 ? null : { node: doc.content[index]!, index };
|
||||
}
|
||||
|
||||
/** A block by id, or `null`. */
|
||||
export function blockById(doc: EditorDocument, id: string): Node | null {
|
||||
return doc.content.find(block => block.id === id) ?? null;
|
||||
}
|
||||
|
||||
/** The block before `id` in document order, or `null`. */
|
||||
export function previousBlock(doc: EditorDocument, id: string): Node | null {
|
||||
const index = blockIndex(doc, id);
|
||||
return index > 0 ? doc.content[index - 1]! : null;
|
||||
}
|
||||
|
||||
/** The block after `id` in document order, or `null`. */
|
||||
export function nextBlock(doc: EditorDocument, id: string): Node | null {
|
||||
const index = blockIndex(doc, id);
|
||||
return index !== -1 && index < doc.content.length - 1 ? doc.content[index + 1]! : null;
|
||||
}
|
||||
|
||||
/** First block, or `null` for an empty document. */
|
||||
export function firstBlock(doc: EditorDocument): Node | null {
|
||||
return doc.content[0] ?? null;
|
||||
}
|
||||
|
||||
/** Last block, or `null` for an empty document. */
|
||||
export function lastBlock(doc: EditorDocument): Node | null {
|
||||
return doc.content[doc.content.length - 1] ?? null;
|
||||
}
|
||||
|
||||
/** Return a copy of `doc` with a different block list. */
|
||||
export function replaceBlocks(doc: EditorDocument, content: readonly Node[]): EditorDocument {
|
||||
return { ...doc, content };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Stable, collision-resistant identifier for blocks. Block ids survive
|
||||
* split/merge/move and are how positions, selections, and the CRDT address a
|
||||
* block — so they must be unique and never reused.
|
||||
*/
|
||||
export function createId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
|
||||
return crypto.randomUUID();
|
||||
|
||||
// Fallback for exotic runtimes without WebCrypto (Node >= 19 and all target
|
||||
// browsers provide `crypto.randomUUID`, so this is effectively dead code).
|
||||
return `b-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './attrs';
|
||||
export * from './marks';
|
||||
export * from './inline';
|
||||
export * from './id';
|
||||
export * from './node';
|
||||
export * from './document';
|
||||
export * from './position';
|
||||
export * from './selection';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Attrs } from './attrs';
|
||||
import { attrsEq } from './attrs';
|
||||
|
||||
/**
|
||||
* An inline formatting mark applied to a run of text (bold, italic, link, ...).
|
||||
* `type` is the registry key; `attrs` holds mark-specific data (e.g. link href).
|
||||
*/
|
||||
export interface Mark {
|
||||
readonly type: string;
|
||||
readonly attrs?: Attrs;
|
||||
}
|
||||
|
||||
/** A normalized set of marks: at most one per type, sorted by `type`. */
|
||||
export type Marks = readonly Mark[];
|
||||
|
||||
/** Structural equality for two marks (type + attrs). */
|
||||
export function markEq(a: Mark, b: Mark): boolean {
|
||||
return a.type === b.type && attrsEq(a.attrs, b.attrs);
|
||||
}
|
||||
|
||||
/** Ordered structural equality for two normalized mark sets. */
|
||||
export function marksEq(a: Marks, b: Marks): boolean {
|
||||
if (a === b)
|
||||
return true;
|
||||
|
||||
if (a.length !== b.length)
|
||||
return false;
|
||||
|
||||
return a.every((mark, i) => markEq(mark, b[i]!));
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize a mark set: keep the last occurrence per `type` (so a re-applied
|
||||
* mark with new attrs wins) and sort by `type`. The deterministic order is what
|
||||
* makes {@link marksEq} an O(n) comparison and keeps the model diff-stable.
|
||||
*/
|
||||
export function normalizeMarks(marks: Marks): Marks {
|
||||
if (marks.length <= 1)
|
||||
return marks;
|
||||
|
||||
const byType = new Map<string, Mark>();
|
||||
|
||||
for (const mark of marks)
|
||||
byType.set(mark.type, mark);
|
||||
|
||||
return [...byType.values()].sort((a, b) => (a.type < b.type ? -1 : a.type > b.type ? 1 : 0));
|
||||
}
|
||||
|
||||
/** Whether `marks` contains a mark structurally equal to `mark`. */
|
||||
export function hasMark(marks: Marks, mark: Mark): boolean {
|
||||
return marks.some(m => markEq(m, mark));
|
||||
}
|
||||
|
||||
/** Whether `marks` contains any mark of the given `type`. */
|
||||
export function hasMarkType(marks: Marks, type: string): boolean {
|
||||
return marks.some(m => m.type === type);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Attrs } from './attrs';
|
||||
import type { Inline } from './inline';
|
||||
import { inlineText } from './inline';
|
||||
import { createId } from './id';
|
||||
|
||||
/**
|
||||
* A block's content. Three shapes, chosen by the block's schema:
|
||||
* - `Inline` for text blocks (paragraph, heading, list item),
|
||||
* - `readonly Node[]` for container blocks (reserved; no default block uses it),
|
||||
* - `null` for atom/void blocks (image, divider).
|
||||
*/
|
||||
export type Content = Inline | readonly Node[] | null;
|
||||
|
||||
/** A document block. `id` is stable across split/merge/move. */
|
||||
export interface Node {
|
||||
readonly id: string;
|
||||
readonly type: string;
|
||||
readonly attrs: Attrs;
|
||||
readonly content: Content;
|
||||
}
|
||||
|
||||
export interface CreateNodeOptions {
|
||||
readonly id?: string;
|
||||
readonly attrs?: Attrs;
|
||||
readonly content?: Content;
|
||||
}
|
||||
|
||||
/** Construct a {@link Node}, generating an id when not supplied. */
|
||||
export function createNode(type: string, options: CreateNodeOptions = {}): Node {
|
||||
return {
|
||||
id: options.id ?? createId(),
|
||||
type,
|
||||
attrs: options.attrs ?? {},
|
||||
content: options.content ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort runtime check for inline (text-block) content. The authoritative
|
||||
* answer comes from the schema; this is a convenience for model-level helpers.
|
||||
*/
|
||||
export function isInlineContent(content: Content): content is Inline {
|
||||
return Array.isArray(content) && (content.length === 0 || 'text' in (content[0] as object));
|
||||
}
|
||||
|
||||
/** Inline content of a node, or `[]` when the node is not a text block. */
|
||||
export function nodeInline(node: Node): Inline {
|
||||
return isInlineContent(node.content) ? node.content : [];
|
||||
}
|
||||
|
||||
/** Plain text of a node, or `''` when the node has no inline content. */
|
||||
export function nodeText(node: Node): string {
|
||||
return isInlineContent(node.content) ? inlineText(node.content) : '';
|
||||
}
|
||||
|
||||
/** Return a copy of `node` with new content. */
|
||||
export function withContent(node: Node, content: Content): Node {
|
||||
return { ...node, content };
|
||||
}
|
||||
|
||||
/** Return a copy of `node` with new attrs. */
|
||||
export function withAttrs(node: Node, attrs: Attrs): Node {
|
||||
return { ...node, attrs };
|
||||
}
|
||||
|
||||
/** Return a copy of `node` with a new type (and optionally new attrs). */
|
||||
export function withType(node: Node, type: string, attrs?: Attrs): Node {
|
||||
return { ...node, type, attrs: attrs ?? node.attrs };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* A position inside the document, addressed by block id + a UTF-16 character
|
||||
* offset into that block's inline content. Offsets are UTF-16 code units to line
|
||||
* up with the DOM `Selection`/`Range` API, so the view bridge maps 1:1.
|
||||
*/
|
||||
export interface Position {
|
||||
readonly blockId: string;
|
||||
readonly offset: number;
|
||||
}
|
||||
|
||||
/** Construct a {@link Position}. */
|
||||
export function position(blockId: string, offset: number): Position {
|
||||
return { blockId, offset };
|
||||
}
|
||||
|
||||
/** Whether two positions address the same block and offset. */
|
||||
export function positionEq(a: Position, b: Position): boolean {
|
||||
return a.blockId === b.blockId && a.offset === b.offset;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Position } from './position';
|
||||
import { positionEq } from './position';
|
||||
import type { EditorDocument } from './document';
|
||||
import { blockIndex } from './document';
|
||||
|
||||
/** A text selection: caret when `anchor === focus`, range otherwise. May span blocks. */
|
||||
export interface TextSelection {
|
||||
readonly kind: 'text';
|
||||
readonly anchor: Position;
|
||||
readonly focus: Position;
|
||||
}
|
||||
|
||||
/** A block-level selection of one or more whole blocks (atoms, Mod+A stage 2). */
|
||||
export interface NodeSelection {
|
||||
readonly kind: 'node';
|
||||
readonly ids: readonly string[];
|
||||
}
|
||||
|
||||
export type Selection = TextSelection | NodeSelection;
|
||||
|
||||
/** Construct a text selection (focus defaults to anchor → collapsed caret). */
|
||||
export function textSelection(anchor: Position, focus: Position = anchor): TextSelection {
|
||||
return { kind: 'text', anchor, focus };
|
||||
}
|
||||
|
||||
/** Construct a collapsed caret selection. */
|
||||
export function caret(blockId: string, offset: number): TextSelection {
|
||||
const point: Position = { blockId, offset };
|
||||
return { kind: 'text', anchor: point, focus: point };
|
||||
}
|
||||
|
||||
/** Construct a block-level selection. */
|
||||
export function nodeSelection(ids: readonly string[]): NodeSelection {
|
||||
return { kind: 'node', ids };
|
||||
}
|
||||
|
||||
export function isTextSelection(sel: Selection): sel is TextSelection {
|
||||
return sel.kind === 'text';
|
||||
}
|
||||
|
||||
export function isNodeSelection(sel: Selection): sel is NodeSelection {
|
||||
return sel.kind === 'node';
|
||||
}
|
||||
|
||||
/** Whether the selection is a collapsed caret. */
|
||||
export function isCollapsed(sel: Selection): boolean {
|
||||
return sel.kind === 'text' && positionEq(sel.anchor, sel.focus);
|
||||
}
|
||||
|
||||
/** Whether the selection spans more than one block. */
|
||||
export function isAcrossBlocks(sel: Selection): boolean {
|
||||
return sel.kind === 'text' && sel.anchor.blockId !== sel.focus.blockId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoints of a text selection in document order (`from` before `to`). Within
|
||||
* one block they are ordered by offset; across blocks by block index.
|
||||
*/
|
||||
export function orderedSelection(sel: TextSelection, doc: EditorDocument): { from: Position; to: Position } {
|
||||
const { anchor, focus } = sel;
|
||||
|
||||
if (anchor.blockId === focus.blockId)
|
||||
return anchor.offset <= focus.offset ? { from: anchor, to: focus } : { from: focus, to: anchor };
|
||||
|
||||
return blockIndex(doc, anchor.blockId) <= blockIndex(doc, focus.blockId)
|
||||
? { from: anchor, to: focus }
|
||||
: { from: focus, to: anchor };
|
||||
}
|
||||
|
||||
/** Structural equality for two selections. */
|
||||
export function selectionEq(a: Selection, b: Selection): boolean {
|
||||
if (a.kind !== b.kind)
|
||||
return false;
|
||||
|
||||
if (a.kind === 'text' && b.kind === 'text')
|
||||
return positionEq(a.anchor, b.anchor) && positionEq(a.focus, b.focus);
|
||||
|
||||
if (a.kind === 'node' && b.kind === 'node')
|
||||
return a.ids.length === b.ids.length && a.ids.every((id, i) => id === b.ids[i]);
|
||||
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user