feat(writekit): rename @robonen/editor to @robonen/writekit
Rename the rich-text editor package and all Editor* exports to Writekit*; remove the old vue/editor tree.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref } from 'vue';
|
||||
import { DismissableLayer, PopperContent, PopperRoot, Portal } from '@robonen/primitives';
|
||||
import { isCollapsed } from '../../model';
|
||||
import { isMarkActive, toggleMark } from '../../commands';
|
||||
import { useWritekitContext } from '../context';
|
||||
import { useEventListener } from '../composables';
|
||||
|
||||
export interface WritekitBubbleMenuProps {
|
||||
/** Marks shown in the default toolbar (ignored when the default slot is used). */
|
||||
marks?: string[];
|
||||
}
|
||||
|
||||
const { marks = ['bold', 'italic', 'underline', 'strike', 'code'] } = defineProps<WritekitBubbleMenuProps>();
|
||||
|
||||
const ctx = useWritekitContext();
|
||||
// Virtual reference (a `Measurable`) anchored to the selection rect — Popper
|
||||
// positions against it with no trigger element. Reassigned on every refresh so
|
||||
// PopperContent re-resolves position as the selection moves.
|
||||
const reference = ref<{ getBoundingClientRect: () => DOMRect } | undefined>();
|
||||
const open = ref(false);
|
||||
const rev = ref(0);
|
||||
|
||||
function selectionRect(): DOMRect | null {
|
||||
const selection = 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.writekit.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() };
|
||||
}
|
||||
|
||||
ctx.writekit.on('transaction', refresh);
|
||||
useEventListener(() => (typeof document === 'undefined' ? undefined : document), 'selectionchange', refresh);
|
||||
onBeforeUnmount(() => ctx.writekit.off('transaction', refresh));
|
||||
|
||||
function active(type: string): boolean {
|
||||
return Boolean(rev.value >= 0 && isMarkActive(ctx.writekit.state, type));
|
||||
}
|
||||
|
||||
function toggle(type: string): void {
|
||||
ctx.writekit.command(toggleMark(type));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Portal to="body">
|
||||
<PopperRoot>
|
||||
<PopperContent
|
||||
v-if="open && reference"
|
||||
:reference="reference"
|
||||
side="top"
|
||||
:side-offset="8"
|
||||
:collision-padding="8"
|
||||
>
|
||||
<DismissableLayer
|
||||
class="writekit-bubble-menu"
|
||||
role="toolbar"
|
||||
data-writekit-bubble-menu=""
|
||||
@dismiss="open = false"
|
||||
>
|
||||
<slot :active="active" :toggle="toggle" :writekit="ctx.writekit">
|
||||
<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>
|
||||
</DismissableLayer>
|
||||
</PopperContent>
|
||||
</PopperRoot>
|
||||
</Portal>
|
||||
</template>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import type { RemoteCursor } from '../../crdt';
|
||||
import { useWritekitContext } from '../context';
|
||||
|
||||
export interface WritekitRemoteCursorsProps {
|
||||
cursors: readonly RemoteCursor[];
|
||||
}
|
||||
|
||||
const props = defineProps<WritekitRemoteCursorsProps>();
|
||||
const ctx = useWritekitContext();
|
||||
|
||||
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.writekit.on('transaction', schedule);
|
||||
onMounted(recompute);
|
||||
onBeforeUnmount(() => ctx.writekit.off('transaction', schedule));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="writekit-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="writekit-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="writekit-remote-caret"
|
||||
:style="{ top: `${cursor.caret.top}px`, left: `${cursor.caret.left}px`, height: `${cursor.caret.height}px`, '--cursor-color': cursor.color }"
|
||||
>
|
||||
<span class="writekit-remote-caret-label">{{ cursor.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref } from 'vue';
|
||||
import { DismissableLayer, PopperContent, PopperRoot, Portal } from '@robonen/primitives';
|
||||
import { blockById, caret, createNode, inlineText, isCollapsed, nodeInline, nodeSelection } from '../../model';
|
||||
import { createTransaction } from '../../state';
|
||||
import { useWritekitContext } from '../context';
|
||||
import { useEventListener } from '../composables';
|
||||
import type { SlashItem } from './slash-items';
|
||||
import { getSlashItems } from './slash-items';
|
||||
|
||||
export interface WritekitSlashMenuProps {
|
||||
/** Character that opens the menu (default `'/'`). */
|
||||
trigger?: string;
|
||||
}
|
||||
|
||||
const { trigger = '/' } = defineProps<WritekitSlashMenuProps>();
|
||||
|
||||
const ctx = useWritekitContext();
|
||||
const open = ref(false);
|
||||
const items = ref<SlashItem[]>([]);
|
||||
const highlighted = ref(0);
|
||||
// Virtual reference (a `Measurable`) anchored to the caret rect; Popper positions
|
||||
// against it with no trigger element. Focus stays in the contenteditable (so the
|
||||
// user keeps typing to filter), so nav is driven by the capture-phase keydown
|
||||
// below and the highlight is an index — not roving focus / listbox focus.
|
||||
const reference = ref<{ getBoundingClientRect: () => DOMRect } | undefined>();
|
||||
|
||||
let triggerBlockId = '';
|
||||
let triggerStart = 0;
|
||||
let caretOffset = 0;
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function caretRect(): DOMRect | null {
|
||||
const selection = 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.writekit.state.selection;
|
||||
|
||||
if (sel.kind !== 'text' || !isCollapsed(sel) || ctx.composing.value) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
const block = blockById(ctx.writekit.state.doc, sel.focus.blockId);
|
||||
const spec = block && ctx.writekit.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.writekit.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;
|
||||
}
|
||||
|
||||
function selectItem(item: SlashItem): void {
|
||||
const writekit = ctx.writekit;
|
||||
const block = blockById(writekit.state.doc, triggerBlockId);
|
||||
|
||||
if (!block) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
const def = writekit.state.registry.getBlock(item.type);
|
||||
const tr = createTransaction(writekit.state).deleteText(triggerBlockId, triggerStart, caretOffset);
|
||||
|
||||
if (def?.spec.content.kind === 'atom') {
|
||||
const node = createNode(item.type, { attrs: writekit.state.schema.defaultAttrs(item.type) });
|
||||
const index = writekit.state.doc.content.findIndex(candidate => candidate.id === triggerBlockId);
|
||||
tr.insertBlock(node, index + 1).setSelection(nodeSelection([node.id]));
|
||||
}
|
||||
else {
|
||||
tr.setBlockType(triggerBlockId, item.type, writekit.state.schema.defaultAttrs(item.type));
|
||||
tr.setSelection(caret(triggerBlockId, triggerStart));
|
||||
}
|
||||
|
||||
writekit.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.writekit.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.writekit.off('transaction', refresh));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Portal to="body">
|
||||
<PopperRoot>
|
||||
<PopperContent
|
||||
v-if="open && reference"
|
||||
:reference="reference"
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="6"
|
||||
:collision-padding="8"
|
||||
>
|
||||
<DismissableLayer
|
||||
class="writekit-slash-menu"
|
||||
role="listbox"
|
||||
data-writekit-slash-menu=""
|
||||
@dismiss="close"
|
||||
@focus-outside.prevent
|
||||
>
|
||||
<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>
|
||||
</DismissableLayer>
|
||||
</PopperContent>
|
||||
</PopperRoot>
|
||||
</Portal>
|
||||
</template>
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './slash-items';
|
||||
|
||||
export { default as WritekitBubbleMenu } from './WritekitBubbleMenu.vue';
|
||||
export type { WritekitBubbleMenuProps } from './WritekitBubbleMenu.vue';
|
||||
export { default as WritekitSlashMenu } from './WritekitSlashMenu.vue';
|
||||
export type { WritekitSlashMenuProps } from './WritekitSlashMenu.vue';
|
||||
export { default as WritekitRemoteCursors } from './WritekitRemoteCursors.vue';
|
||||
export type { WritekitRemoteCursorsProps } from './WritekitRemoteCursors.vue';
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user