263c32002f
Rename the rich-text editor package and all Editor* exports to Writekit*; remove the old vue/editor tree.
126 lines
3.9 KiB
Vue
126 lines
3.9 KiB
Vue
<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>
|