You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
342 lines
12 KiB
342 lines
12 KiB
<script lang="ts">
|
|
/**
|
|
* Left gutter — the block handle.
|
|
*
|
|
* A grip button that tracks the block under the cursor (in the left
|
|
* margin) and, on click, opens a menu: Move up / Move down / Duplicate /
|
|
* Delete + an Insert-below submenu of block types. Built entirely from
|
|
* the eidos `DropdownMenu` (Trigger renders a `Button`; `Sub` drives the
|
|
* submenu) — no bespoke widgets.
|
|
*
|
|
* Positioning: the gutter is a sibling of the contenteditable inside the
|
|
* `[data-words]` frame (a positioned ancestor). It measures the hovered
|
|
* top-level block's viewport rect and places itself frame-relative, so
|
|
* internal content scroll is handled by re-measuring on `scroll`.
|
|
*
|
|
* The handle + portalled menu carry `data-words-block-handle` /
|
|
* `data-words-block-handle-menu` so the provider's focus-scope predicate
|
|
* (`isInsideWordsTool`) treats interactions as internal — no spurious
|
|
* blur / commit / refocus cycle.
|
|
*/
|
|
import type { ActiveDom } from '$adom';
|
|
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
|
|
import { GripVertical } from '$uix/eidos/components/icon';
|
|
import {
|
|
defaultWordsSchema,
|
|
type ProviderSnippetProps,
|
|
type WordsBlockMenuEntry
|
|
} from '$soma/components/words';
|
|
import { pickImageFile } from './words-image-file';
|
|
|
|
let {
|
|
api,
|
|
content,
|
|
dom
|
|
}: {
|
|
api: ProviderSnippetProps;
|
|
content: HTMLElement;
|
|
dom: ActiveDom;
|
|
} = $props();
|
|
|
|
const frame = $derived(content.closest('[data-words]') as HTMLElement | null);
|
|
|
|
let index = $state(-1);
|
|
let rect = $state<{ top: number; left: number; width: number; height: number } | null>(null);
|
|
let show = $state(false);
|
|
let menuOpen = $state(false);
|
|
// True while the cursor is in the left gutter band (the margin column),
|
|
// not over the text. Drives the dashed outline that emphasises the
|
|
// target block — showing it for the whole gutter (not just the small
|
|
// grip) makes it a forgiving target and keeps the grip clear of the line.
|
|
let inGutter = $state(false);
|
|
|
|
let raf = 0;
|
|
|
|
function topLevelBlocks(): HTMLElement[] {
|
|
return Array.from(content.children).filter(
|
|
(el): el is HTMLElement =>
|
|
el instanceof HTMLElement && el.matches("[data-words-node='block']")
|
|
);
|
|
}
|
|
|
|
function place(el: HTMLElement, i: number) {
|
|
if (!frame) return;
|
|
const br = el.getBoundingClientRect();
|
|
const fr = frame.getBoundingClientRect();
|
|
const cr = content.getBoundingClientRect();
|
|
// Hide when the block is scrolled out of the content viewport.
|
|
if (br.bottom < cr.top + 4 || br.top > cr.bottom - 4) {
|
|
show = false;
|
|
return;
|
|
}
|
|
index = i;
|
|
rect = {
|
|
top: br.top - fr.top,
|
|
left: br.left - fr.left,
|
|
width: br.width,
|
|
height: br.height
|
|
};
|
|
show = true;
|
|
}
|
|
|
|
function locate(clientX: number, clientY: number) {
|
|
const blocks = topLevelBlocks();
|
|
if (blocks.length === 0) {
|
|
show = false;
|
|
inGutter = false;
|
|
return;
|
|
}
|
|
|
|
// Voronoi-on-Y: each block claims a `[from, to)` zone where the
|
|
// boundary with a neighbour is the midpoint of the inter-block
|
|
// gap. Every vertical pixel belongs to exactly one block. This is
|
|
// the only way thin blocks (`<hr data-words-block='divider'>` has
|
|
// `block-size: 0` — the visible line is a border, the element's
|
|
// rect is ~1px tall) become hover-targetable. The old code
|
|
// matched "cursor inside the block's rect OR the last block whose
|
|
// top is ≤ cursor.y", which left dividers with a single-pixel
|
|
// hit zone.
|
|
const rects = blocks.map((el) => el.getBoundingClientRect());
|
|
let target: HTMLElement | null = null;
|
|
let targetIndex = -1;
|
|
let targetRect: DOMRect | null = null;
|
|
for (let i = 0; i < blocks.length; i++) {
|
|
const r = rects[i];
|
|
const prev = rects[i - 1];
|
|
const next = rects[i + 1];
|
|
const from = prev ? (prev.bottom + r.top) / 2 : -Infinity;
|
|
const to = next ? (r.bottom + next.top) / 2 : Infinity;
|
|
if (clientY >= from && clientY < to) {
|
|
target = blocks[i];
|
|
targetIndex = i;
|
|
targetRect = r;
|
|
break;
|
|
}
|
|
}
|
|
if (target && targetRect) {
|
|
place(target, targetIndex);
|
|
// The outline shows only while the cursor is in the gutter band —
|
|
// i.e. left of the block's text column, where the grip lives.
|
|
inGutter = clientX < targetRect.left;
|
|
} else {
|
|
show = false;
|
|
inGutter = false;
|
|
}
|
|
}
|
|
|
|
function reposition() {
|
|
if (index < 0) return;
|
|
const el = topLevelBlocks()[index];
|
|
if (el) place(el, index);
|
|
else show = false;
|
|
}
|
|
|
|
function onPointerMove(event: Event) {
|
|
if (menuOpen) return;
|
|
if (raf) return;
|
|
const e = event as PointerEvent;
|
|
const x = e.clientX;
|
|
const y = e.clientY;
|
|
raf = requestAnimationFrame(() => {
|
|
raf = 0;
|
|
locate(x, y);
|
|
});
|
|
}
|
|
|
|
function onPointerLeave() {
|
|
if (!menuOpen) {
|
|
show = false;
|
|
inGutter = false;
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
const disposers = [
|
|
dom.listen(content, 'pointermove', onPointerMove),
|
|
dom.listen(content, 'pointerleave', onPointerLeave),
|
|
dom.listen(content, 'scroll', reposition, { capture: true })
|
|
];
|
|
return () => {
|
|
for (const dispose of disposers) dispose?.();
|
|
if (raf) cancelAnimationFrame(raf);
|
|
};
|
|
});
|
|
|
|
// Re-measure after edits change the block layout.
|
|
$effect(() => {
|
|
void api.document;
|
|
if (show || menuOpen) requestAnimationFrame(reposition);
|
|
});
|
|
|
|
// The soma menu owns close-on-select (closeOnSelect defaults true); the
|
|
// closed panel hides via opacity, so mutating the document here doesn't
|
|
// interfere with the menu's dismissal.
|
|
function inspect() {
|
|
// Hand the active block to the inspector by marking it as the
|
|
// visually-selected atomic. Works for ALL top-level block types
|
|
// — image / divider that genuinely can't host a caret, AND blocks
|
|
// like `columns` / `table` / `list` whose Block panel offers
|
|
// editor surfaces (add column, header toggles, etc.) that the
|
|
// caret-driven path wouldn't normally surface.
|
|
//
|
|
// Passing `[index]` as the path ensures `selectedBlockPath` is set
|
|
// too — keeps the inspector's path-aware activeBlock derivation
|
|
// happy and lines up with the "still inside same top-level
|
|
// wrapper" guard in `syncSelectionFromDom` (no spurious clear on
|
|
// the next selectionchange).
|
|
api.selectAtomicBlock(index, [index]);
|
|
}
|
|
function moveUp() {
|
|
if (index > 0) api.applyCommand({ type: 'moveBlock', blockIndex: index, direction: 'up' });
|
|
}
|
|
function moveDown() {
|
|
api.applyCommand({ type: 'moveBlock', blockIndex: index, direction: 'down' });
|
|
}
|
|
function duplicate() {
|
|
api.applyCommand({ type: 'duplicateBlock', blockIndex: index });
|
|
}
|
|
function remove() {
|
|
api.applyCommand({ type: 'deleteBlock', blockIndex: index });
|
|
show = false;
|
|
}
|
|
function insert(block: Record<string, unknown>) {
|
|
api.applyCommand({ type: 'insertBlock', blockIndex: index + 1, block });
|
|
}
|
|
|
|
// Image entries can't ship a valid `create()` (their `src` is unknown
|
|
// until the user picks a file or types a URL). The gutter inserter
|
|
// opens a file picker → FileReader → data URL when an image entry
|
|
// is selected, then inserts the block with a valid `src`. URLs from
|
|
// real backends would replace this with a fetch + upload. The picker
|
|
// flow is shared with the inspector's image panel (`pickImageFile`).
|
|
function insertImageInteractive() {
|
|
const doc =
|
|
(typeof document !== 'undefined' ? document : null) ?? content?.ownerDocument ?? null;
|
|
if (!doc) return;
|
|
pickImageFile(doc, (src, alt) => insert({ type: 'image', src, alt }));
|
|
}
|
|
|
|
function handleInsert(entry: WordsBlockMenuEntry) {
|
|
if (entry.id === 'image') {
|
|
insertImageInteractive();
|
|
return;
|
|
}
|
|
// Headings inserted from the gutter ship a literal "Title" text
|
|
// so the user sees what they got — relying on CSS `:empty:before`
|
|
// placeholders is brittle (contenteditable browsers strip the
|
|
// pseudo on caret entry, the rule needs `position: relative` on
|
|
// the block, etc.). Literal text always renders; the user
|
|
// select-all + type to replace. Slash menu users keep getting an
|
|
// empty heading (they explicitly typed `/h1` and start typing
|
|
// straight away). List gets the same treatment.
|
|
if (entry.id === 'heading') {
|
|
insert({
|
|
type: 'heading',
|
|
level: 1,
|
|
children: [{ type: 'text', text: 'Title' }]
|
|
});
|
|
return;
|
|
}
|
|
if (entry.id === 'list') {
|
|
insert({
|
|
type: 'list',
|
|
kind: 'unordered',
|
|
items: [{ children: [{ type: 'text', text: 'List item' }] }]
|
|
});
|
|
return;
|
|
}
|
|
insert(entry.create());
|
|
}
|
|
|
|
// Insert-menu entries come from the block registry — the single source
|
|
// of truth shared with the slash menu, so the two lists can't drift.
|
|
// Image is `insertable: false` because its `create()` returns an empty
|
|
// `src`; we surface it anyway and route through the file picker above
|
|
// so the user can actually pick an image from the gutter menu.
|
|
//
|
|
// Collapse multi-variant entries into ONE canonical chip in the
|
|
// gutter (creates the default variant — the inspector then exposes
|
|
// a level/kind toggle to switch to the others). The slash menu keeps
|
|
// every variant because typing `/h2` or `/check` is the natural way
|
|
// to pick the variant there; a click-pick menu doesn't get that
|
|
// ergonomic, so a flat "Heading" / "List" reads cleaner.
|
|
//
|
|
// Today the registry exposes:
|
|
// heading-1 / heading-2 / heading-3 → "Heading" (creates H1)
|
|
// unordered-list / ordered-list / check-list → "List" (creates bullets)
|
|
const COLLAPSE_GROUPS: Record<string, { id: string; label: string }> = {
|
|
'heading-1': { id: 'heading', label: 'Heading' },
|
|
'unordered-list': { id: 'list', label: 'List' }
|
|
};
|
|
const COLLAPSED_DROPS = new Set(['heading-2', 'heading-3', 'ordered-list', 'check-list']);
|
|
const inserts = $derived.by(() => {
|
|
const all = defaultWordsSchema.insertable();
|
|
return all
|
|
.filter((entry) => !COLLAPSED_DROPS.has(entry.id))
|
|
.map((entry) => {
|
|
const replacement = COLLAPSE_GROUPS[entry.id];
|
|
if (replacement) return { ...entry, ...replacement };
|
|
return entry;
|
|
});
|
|
});
|
|
</script>
|
|
|
|
{#if (show || menuOpen) && rect}
|
|
{#if inGutter || menuOpen}
|
|
<div
|
|
data-words-block-outline
|
|
style="top: {rect.top - 3}px; left: {rect.left - 3}px; width: {rect.width +
|
|
6}px; height: {rect.height + 6}px;"
|
|
></div>
|
|
{/if}
|
|
<!-- Center the handle vertically against the block's height: position
|
|
it at the block midpoint and let `transform: translateY(-50%)`
|
|
in CSS pull it up by half its own height. The previous
|
|
`margin-block-start` hack only worked for the FIRST line of
|
|
short blocks (paragraph, heading) — tall blocks (code, table,
|
|
image) had the handle pinned to the top, far from the cursor's
|
|
natural target. -->
|
|
<div data-words-block-gutter style="top: {rect.top + rect.height / 2}px;">
|
|
<DropdownMenu bind:open={menuOpen}>
|
|
<DropdownMenu.Trigger
|
|
variant="ghost"
|
|
size="xs"
|
|
iconOnly
|
|
rounded="md"
|
|
aria-label="Block actions"
|
|
data-words-block-handle
|
|
>
|
|
{#snippet icon()}
|
|
<svg width="15" height="15" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
<circle cx="5.5" cy="3.5" r="1.35" />
|
|
<circle cx="10.5" cy="3.5" r="1.35" />
|
|
<circle cx="5.5" cy="8" r="1.35" />
|
|
<circle cx="10.5" cy="8" r="1.35" />
|
|
<circle cx="5.5" cy="12.5" r="1.35" />
|
|
<circle cx="10.5" cy="12.5" r="1.35" />
|
|
</svg>
|
|
{/snippet}
|
|
Block actions
|
|
</DropdownMenu.Trigger>
|
|
<DropdownMenu.Content side="bottom" align="start" data-words-block-handle-menu>
|
|
<DropdownMenu.Item onSelect={inspect}>Inspect</DropdownMenu.Item>
|
|
<DropdownMenu.Separator />
|
|
<DropdownMenu.Item onSelect={moveUp} disabled={index <= 0}>Move up</DropdownMenu.Item>
|
|
<DropdownMenu.Item onSelect={moveDown}>Move down</DropdownMenu.Item>
|
|
<DropdownMenu.Item onSelect={duplicate}>Duplicate</DropdownMenu.Item>
|
|
<DropdownMenu.Separator />
|
|
<DropdownMenu.Sub>
|
|
<DropdownMenu.SubTrigger>Insert below</DropdownMenu.SubTrigger>
|
|
<DropdownMenu.SubContent data-words-block-handle-menu>
|
|
{#each inserts as ins (ins.id)}
|
|
<DropdownMenu.Item onSelect={() => handleInsert(ins)}>{ins.label}</DropdownMenu.Item>
|
|
{/each}
|
|
</DropdownMenu.SubContent>
|
|
</DropdownMenu.Sub>
|
|
<DropdownMenu.Separator />
|
|
<DropdownMenu.Item onSelect={remove}>Delete</DropdownMenu.Item>
|
|
</DropdownMenu.Content>
|
|
</DropdownMenu>
|
|
</div>
|
|
{/if}
|