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
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue';
import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue';
import { isCollapsed } from '../../model';
import { isMarkActive, toggleMark } from '../../commands';
import { useEditorContext } from '../context';
import { useEventListener } from '../composables';
export interface EditorBubbleMenuProps {
/** Marks shown in the default toolbar (ignored when the default slot is used). */
marks?: string[];
}
const { marks = ['bold', 'italic', 'underline', 'strike', 'code'] } = defineProps<EditorBubbleMenuProps>();
const ctx = useEditorContext();
const reference = ref<{ getBoundingClientRect: () => DOMRect } | null>(null);
const floatingEl = ref<HTMLElement | null>(null);
const open = ref(false);
const rev = ref(0);
const { floatingStyles, update } = useFloating(reference, floatingEl, {
placement: 'top',
middleware: [offset(8), flip(), shift({ padding: 8 })],
whileElementsMounted: autoUpdate,
});
function selectionRect(): DOMRect | null {
const selection = typeof globalThis.window === 'undefined' ? null : globalThis.getSelection();
if (!selection || selection.rangeCount === 0)
return null;
const rect = selection.getRangeAt(0).getBoundingClientRect();
return rect.width || rect.height ? rect : null;
}
function refresh(): void {
rev.value += 1;
const sel = ctx.editor.state.selection;
const rect = selectionRect();
open.value = sel.kind === 'text' && !isCollapsed(sel) && !ctx.composing.value && rect !== null;
if (open.value) {
reference.value = { getBoundingClientRect: () => selectionRect() ?? new DOMRect() };
void update();
}
}
ctx.editor.on('transaction', refresh);
useEventListener(() => (typeof document === 'undefined' ? undefined : document), 'selectionchange', refresh);
onBeforeUnmount(() => ctx.editor.off('transaction', refresh));
function active(type: string): boolean {
return Boolean(rev.value >= 0 && isMarkActive(ctx.editor.state, type));
}
function toggle(type: string): void {
ctx.editor.command(toggleMark(type));
}
</script>
<template>
<Teleport to="body">
<div
v-if="open"
ref="floatingEl"
:style="floatingStyles"
class="editor-bubble-menu"
role="toolbar"
data-editor-bubble-menu=""
>
<slot :active="active" :toggle="toggle" :editor="ctx.editor">
<button
v-for="mark in marks"
:key="mark"
type="button"
:data-mark="mark"
:data-active="active(mark) || undefined"
@mousedown.prevent="toggle(mark)"
>
{{ mark }}
</button>
</slot>
</div>
</Teleport>
</template>
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import type { RemoteCursor } from '../../crdt';
import { useEditorContext } from '../context';
export interface EditorRemoteCursorsProps {
cursors: readonly RemoteCursor[];
}
const props = defineProps<EditorRemoteCursorsProps>();
const ctx = useEditorContext();
interface Box {
top: number;
left: number;
width: number;
height: number;
}
interface RenderedCursor {
clientId: string;
name: string;
color: string;
caret: Box | null;
highlights: Box[];
}
const container = ref<HTMLElement | null>(null);
const rendered = ref<RenderedCursor[]>([]);
function domPoint(blockId: string, offset: number): { node: Node; offset: number } | null {
const host = ctx.blockElements.get(blockId);
return host ? ctx.selection.offsetToDomPoint(host, offset) : null;
}
function relativize(rect: DOMRect, base: DOMRect): Box {
return { top: rect.top - base.top, left: rect.left - base.left, width: rect.width, height: rect.height || 18 };
}
function recompute(): void {
const root = container.value;
if (!root || typeof document === 'undefined') {
rendered.value = [];
return;
}
// Measure against the overlay itself — it is the positioned ancestor the
// caret/highlight children are laid out in, so coordinates line up exactly.
const base = root.getBoundingClientRect();
const next: RenderedCursor[] = [];
for (const cursor of props.cursors) {
const selection = cursor.selection;
if (!selection || selection.kind !== 'text')
continue;
const focusPoint = domPoint(selection.focus.blockId, selection.focus.offset);
if (!focusPoint)
continue;
const caretRange = document.createRange();
caretRange.setStart(focusPoint.node, focusPoint.offset);
caretRange.collapse(true);
const caret = relativize(caretRange.getBoundingClientRect(), base);
const highlights: Box[] = [];
const anchorPoint = domPoint(selection.anchor.blockId, selection.anchor.offset);
const collapsed = selection.anchor.blockId === selection.focus.blockId && selection.anchor.offset === selection.focus.offset;
if (anchorPoint && !collapsed) {
const range = document.createRange();
range.setStart(anchorPoint.node, anchorPoint.offset);
range.setEnd(focusPoint.node, focusPoint.offset);
if (range.collapsed) {
// Selection runs backwards (focus before anchor) — swap the ends.
range.setStart(focusPoint.node, focusPoint.offset);
range.setEnd(anchorPoint.node, anchorPoint.offset);
}
for (const rect of Array.from(range.getClientRects())) {
if (rect.width > 0)
highlights.push(relativize(rect, base));
}
}
next.push({
clientId: cursor.clientId,
name: cursor.user?.name ?? 'Anon',
color: cursor.user?.color ?? '#ef4444',
caret,
highlights,
});
}
rendered.value = next;
}
function schedule(): void {
void nextTick(recompute);
}
watch(() => props.cursors, schedule, { deep: true });
ctx.editor.on('transaction', schedule);
onMounted(recompute);
onBeforeUnmount(() => ctx.editor.off('transaction', schedule));
</script>
<template>
<div ref="container" class="editor-remote-cursors" aria-hidden="true">
<template v-for="cursor in rendered" :key="cursor.clientId">
<div
v-for="(hl, i) in cursor.highlights"
:key="`${cursor.clientId}-hl-${i}`"
class="editor-remote-selection"
:style="{ top: `${hl.top}px`, left: `${hl.left}px`, width: `${hl.width}px`, height: `${hl.height}px`, '--cursor-color': cursor.color }"
/>
<div
v-if="cursor.caret"
class="editor-remote-caret"
:style="{ top: `${cursor.caret.top}px`, left: `${cursor.caret.left}px`, height: `${cursor.caret.height}px`, '--cursor-color': cursor.color }"
>
<span class="editor-remote-caret-label">{{ cursor.name }}</span>
</div>
</template>
</div>
</template>
+187
View File
@@ -0,0 +1,187 @@
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue';
import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue';
import { blockById, caret, createNode, inlineText, isCollapsed, nodeInline, nodeSelection } from '../../model';
import { createTransaction } from '../../state';
import { useEditorContext } from '../context';
import { useEventListener } from '../composables';
import type { SlashItem } from './slash-items';
import { getSlashItems } from './slash-items';
export interface EditorSlashMenuProps {
/** Character that opens the menu (default `'/'`). */
trigger?: string;
}
const { trigger = '/' } = defineProps<EditorSlashMenuProps>();
const ctx = useEditorContext();
const open = ref(false);
const items = ref<SlashItem[]>([]);
const highlighted = ref(0);
const reference = ref<{ getBoundingClientRect: () => DOMRect } | null>(null);
const floatingEl = ref<HTMLElement | null>(null);
let triggerBlockId = '';
let triggerStart = 0;
let caretOffset = 0;
const { floatingStyles, update } = useFloating(reference, floatingEl, {
placement: 'bottom-start',
middleware: [offset(6), flip(), shift({ padding: 8 })],
whileElementsMounted: autoUpdate,
});
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function caretRect(): DOMRect | null {
const selection = typeof globalThis.window === 'undefined' ? null : globalThis.getSelection();
if (!selection || selection.rangeCount === 0)
return null;
const range = selection.getRangeAt(0);
const rects = range.getClientRects();
const rect = rects.length > 0 ? rects[0]! : range.getBoundingClientRect();
return rect.width || rect.height ? rect : null;
}
function close(): void {
open.value = false;
}
function refresh(): void {
const sel = ctx.editor.state.selection;
if (sel.kind !== 'text' || !isCollapsed(sel) || ctx.composing.value) {
close();
return;
}
const block = blockById(ctx.editor.state.doc, sel.focus.blockId);
const spec = block && ctx.editor.state.schema.nodeSpec(block.type);
if (!block || spec?.content.kind !== 'text' || spec.code) {
close();
return;
}
const before = inlineText(nodeInline(block)).slice(0, sel.focus.offset);
const match = new RegExp(`(?:^|\\s)${escapeRegExp(trigger)}([\\p{L}\\p{N}]*)$`, 'u').exec(before);
if (!match) {
close();
return;
}
const query = match[1] ?? '';
const next = getSlashItems(ctx.editor.state.registry, query);
if (next.length === 0) {
close();
return;
}
triggerBlockId = block.id;
caretOffset = sel.focus.offset;
triggerStart = caretOffset - query.length - trigger.length;
items.value = next;
highlighted.value = open.value ? Math.min(highlighted.value, next.length - 1) : 0;
if (!caretRect()) {
close();
return;
}
reference.value = { getBoundingClientRect: () => caretRect() ?? new DOMRect() };
open.value = true;
void update();
}
function selectItem(item: SlashItem): void {
const editor = ctx.editor;
const block = blockById(editor.state.doc, triggerBlockId);
if (!block) {
close();
return;
}
const def = editor.state.registry.getBlock(item.type);
const tr = createTransaction(editor.state).deleteText(triggerBlockId, triggerStart, caretOffset);
if (def?.spec.content.kind === 'atom') {
const node = createNode(item.type, { attrs: editor.state.schema.defaultAttrs(item.type) });
const index = editor.state.doc.content.findIndex(candidate => candidate.id === triggerBlockId);
tr.insertBlock(node, index + 1).setSelection(nodeSelection([node.id]));
}
else {
tr.setBlockType(triggerBlockId, item.type, editor.state.schema.defaultAttrs(item.type));
tr.setSelection(caret(triggerBlockId, triggerStart));
}
editor.dispatch(tr);
close();
}
function onKeydownCapture(event: KeyboardEvent): void {
if (!open.value || items.value.length === 0)
return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
event.stopImmediatePropagation();
highlighted.value = (highlighted.value + 1) % items.value.length;
break;
case 'ArrowUp':
event.preventDefault();
event.stopImmediatePropagation();
highlighted.value = (highlighted.value - 1 + items.value.length) % items.value.length;
break;
case 'Enter':
event.preventDefault();
event.stopImmediatePropagation();
selectItem(items.value[highlighted.value]!);
break;
case 'Escape':
event.preventDefault();
event.stopImmediatePropagation();
close();
break;
}
}
ctx.editor.on('transaction', refresh);
useEventListener(() => (typeof document === 'undefined' ? undefined : document), 'selectionchange', refresh);
useEventListener(() => (typeof document === 'undefined' ? undefined : document), 'keydown', onKeydownCapture as (event: Event) => void, { capture: true });
onBeforeUnmount(() => ctx.editor.off('transaction', refresh));
</script>
<template>
<Teleport to="body">
<div
v-if="open"
ref="floatingEl"
:style="floatingStyles"
class="editor-slash-menu"
role="listbox"
data-editor-slash-menu=""
>
<button
v-for="(item, index) in items"
:key="item.type"
type="button"
role="option"
:data-highlighted="index === highlighted || undefined"
:aria-selected="index === highlighted"
@mousedown.prevent="selectItem(item)"
@mousemove="highlighted = index"
>
<span class="slash-title">{{ item.title }}</span>
<span class="slash-group">{{ item.group }}</span>
</button>
</div>
</Teleport>
</template>
+8
View File
@@ -0,0 +1,8 @@
export * from './slash-items';
export { default as EditorBubbleMenu } from './EditorBubbleMenu.vue';
export type { EditorBubbleMenuProps } from './EditorBubbleMenu.vue';
export { default as EditorSlashMenu } from './EditorSlashMenu.vue';
export type { EditorSlashMenuProps } from './EditorSlashMenu.vue';
export { default as EditorRemoteCursors } from './EditorRemoteCursors.vue';
export type { EditorRemoteCursorsProps } from './EditorRemoteCursors.vue';
+33
View File
@@ -0,0 +1,33 @@
import type { Registry } from '../../registry';
/** A slash-menu entry derived from a block definition's metadata. */
export interface SlashItem {
type: string;
title: string;
group: string;
keywords: readonly string[];
}
/**
* Build slash-menu items from the registry, filtered by `query` against each
* block's title and keywords. Data-driven: any newly registered block with
* `meta` shows up automatically.
*/
export function getSlashItems(registry: Registry, query = ''): SlashItem[] {
const items: SlashItem[] = registry.listBlocks()
.filter(def => def.meta !== undefined)
.map(def => ({
type: def.type,
title: def.meta!.title,
group: def.meta!.group ?? 'blocks',
keywords: def.meta!.keywords ?? [],
}));
const q = query.trim().toLowerCase();
if (!q)
return items;
return items.filter(item =>
item.title.toLowerCase().includes(q) || item.keywords.some(keyword => keyword.toLowerCase().includes(q)),
);
}