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.
svelte-kit-vice/src/uix/eidos/components/words/words-block-gutter.svelte

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}

Powered by TurnKey Linux.