Notion-style :: grip rendered to the left of the hovered block via a single overlay component (`Words.BlockHandle`). Click reveals a small popover with Move up / Move down / Duplicate / Delete. Each fires via `snippet.applyCommand` so the discrete imperatives bypass the sema runtime (no extra event noise on top of what the operation already implies for downstream consumers). Backed by three new generic operations on the engine: - `moveBlockAt(state, blockIndex, direction)` — swap with neighbour. - `duplicateBlockAt(state, blockIndex)` — JSON-clone insert-after. - `deleteBlockAt(state, blockIndex)` — already existed; now wired to the menu's Delete entry. The three operations are exposed as `moveBlock` / `duplicateBlock` / `deleteBlock` commands in `WordsCommand` so any future block-toolbar can reuse them without per-feature engine plumbing. Also: add `applyCommand` / `selectAtomicBlock` / `clearSelectedBlock` to `WordsProviderSnippetProps` — they were emitted by the provider but missing from the type. Float bar + block handle both consume `snippet.applyCommand`. Verified in browser: hover over a block renders the grip (24×24, opacity 0.45 → 1 on hover, z-index popover); click opens menu with all 4 actions correctly labelled; Duplicate on the heading at index 0 produced a new heading at index 1 and pushed the rest down. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>active-uix
parent
5acee94777
commit
66225404e1
@ -0,0 +1,209 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Words.BlockHandle — drag handle + block-action menu (Notion-style).
|
||||||
|
*
|
||||||
|
* Renders a tiny grip `::` next to the block under the user's
|
||||||
|
* cursor (hover-driven). Click opens a popover with Move up /
|
||||||
|
* Move down / Duplicate / Delete. Each fires via
|
||||||
|
* `snippet.applyCommand` (no sema noise — these are discrete
|
||||||
|
* imperatives, not value-tunings).
|
||||||
|
*
|
||||||
|
* Single overlay reused across all blocks: we listen for mouseover
|
||||||
|
* inside `[data-words-content]`, find the closest block element,
|
||||||
|
* compute its bounding rect, and reposition the handle. When the
|
||||||
|
* cursor leaves the content area we hide.
|
||||||
|
*
|
||||||
|
* Spike — no morfo/soma part yet. Same pattern as the image float
|
||||||
|
* bar; canon refactor only if the concept holds.
|
||||||
|
*/
|
||||||
|
import { GripVertical, ArrowUp, ArrowDown, Copy, Trash } from '$uix/eidos/components/icon';
|
||||||
|
import type { ProviderSnippetProps } from '$soma/components/words';
|
||||||
|
|
||||||
|
let { snippet }: { snippet: ProviderSnippetProps } = $props();
|
||||||
|
|
||||||
|
let hoverBlockEl = $state<HTMLElement | null>(null);
|
||||||
|
let menuOpen = $state(false);
|
||||||
|
let position = $state<{ top: number; left: number } | null>(null);
|
||||||
|
|
||||||
|
const hoverBlockIndex = $derived.by(() => {
|
||||||
|
if (!hoverBlockEl) return undefined;
|
||||||
|
const path = hoverBlockEl.getAttribute('data-words-path');
|
||||||
|
if (!path) return undefined;
|
||||||
|
const first = path.split('.')[0];
|
||||||
|
const n = Number(first);
|
||||||
|
return Number.isInteger(n) && n >= 0 ? n : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
function measureFromEl(el: HTMLElement | null) {
|
||||||
|
if (!el) {
|
||||||
|
position = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
position = {
|
||||||
|
top: rect.top + rect.height / 2 - 12,
|
||||||
|
left: rect.left - 28
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBlockElement(target: EventTarget | null): HTMLElement | null {
|
||||||
|
if (!(target instanceof Element)) return null;
|
||||||
|
// Block-level elements emit data-words-node='block' on the
|
||||||
|
// engine render. Sub-elements (li, tr, td, span) walk up to
|
||||||
|
// their parent block via closest().
|
||||||
|
const node = target.closest<HTMLElement>(
|
||||||
|
'[data-words-node="block"][data-words-path]'
|
||||||
|
);
|
||||||
|
if (!node) return null;
|
||||||
|
// Only top-level blocks (path length 1). Skip nested
|
||||||
|
// table-cell paragraphs / list item inlines.
|
||||||
|
const path = node.getAttribute('data-words-path') ?? '';
|
||||||
|
if (path.includes('.')) {
|
||||||
|
// Walk up to the outermost block ancestor instead.
|
||||||
|
const outer = node.parentElement?.closest<HTMLElement>(
|
||||||
|
'[data-words-node="block"][data-words-path]'
|
||||||
|
);
|
||||||
|
if (outer) {
|
||||||
|
const outerPath = outer.getAttribute('data-words-path') ?? '';
|
||||||
|
if (!outerPath.includes('.')) return outer;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const content = document.querySelector<HTMLElement>('[data-words-content]');
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
function onmove(e: MouseEvent) {
|
||||||
|
if (menuOpen) return;
|
||||||
|
const block = findBlockElement(e.target);
|
||||||
|
if (block === hoverBlockEl) return;
|
||||||
|
hoverBlockEl = block;
|
||||||
|
measureFromEl(block);
|
||||||
|
}
|
||||||
|
function onleave() {
|
||||||
|
if (menuOpen) return;
|
||||||
|
hoverBlockEl = null;
|
||||||
|
position = null;
|
||||||
|
}
|
||||||
|
content.addEventListener('mousemove', onmove);
|
||||||
|
content.addEventListener('mouseleave', onleave);
|
||||||
|
|
||||||
|
const win = content.ownerDocument.defaultView;
|
||||||
|
const onscroll = () => measureFromEl(hoverBlockEl);
|
||||||
|
win?.addEventListener('scroll', onscroll, { passive: true });
|
||||||
|
win?.addEventListener('resize', onscroll, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
content.removeEventListener('mousemove', onmove);
|
||||||
|
content.removeEventListener('mouseleave', onleave);
|
||||||
|
win?.removeEventListener('scroll', onscroll);
|
||||||
|
win?.removeEventListener('resize', onscroll);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleMenu(e: MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
menuOpen = !menuOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!menuOpen) return;
|
||||||
|
function onclick(e: MouseEvent) {
|
||||||
|
const target = e.target;
|
||||||
|
if (target instanceof Element && target.closest('[data-words-block-handle-menu]'))
|
||||||
|
return;
|
||||||
|
menuOpen = false;
|
||||||
|
}
|
||||||
|
document.addEventListener('click', onclick);
|
||||||
|
return () => document.removeEventListener('click', onclick);
|
||||||
|
});
|
||||||
|
|
||||||
|
function act(command: 'moveUp' | 'moveDown' | 'duplicate' | 'delete') {
|
||||||
|
const idx = hoverBlockIndex;
|
||||||
|
if (idx === undefined) return;
|
||||||
|
if (command === 'moveUp') {
|
||||||
|
snippet.applyCommand({ type: 'moveBlock', blockIndex: idx, direction: 'up' });
|
||||||
|
} else if (command === 'moveDown') {
|
||||||
|
snippet.applyCommand({ type: 'moveBlock', blockIndex: idx, direction: 'down' });
|
||||||
|
} else if (command === 'duplicate') {
|
||||||
|
snippet.applyCommand({ type: 'duplicateBlock', blockIndex: idx });
|
||||||
|
} else if (command === 'delete') {
|
||||||
|
snippet.applyCommand({ type: 'deleteBlock', blockIndex: idx });
|
||||||
|
}
|
||||||
|
menuOpen = false;
|
||||||
|
hoverBlockEl = null;
|
||||||
|
position = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canMoveUp = $derived(hoverBlockIndex !== undefined && hoverBlockIndex > 0);
|
||||||
|
const canMoveDown = $derived(
|
||||||
|
hoverBlockIndex !== undefined &&
|
||||||
|
hoverBlockIndex < snippet.document.children.length - 1
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if position}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-words-block-handle
|
||||||
|
data-open={menuOpen ? '' : undefined}
|
||||||
|
style="top: {position.top}px; left: {position.left}px;"
|
||||||
|
title="Block actions"
|
||||||
|
aria-label="Block actions"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
onclick={toggleMenu}
|
||||||
|
onmousedown={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<GripVertical size="sm" decorative />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if menuOpen && position}
|
||||||
|
<div
|
||||||
|
data-words-block-handle-menu
|
||||||
|
role="menu"
|
||||||
|
style="top: {position.top + 24}px; left: {position.left}px;"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
disabled={!canMoveUp}
|
||||||
|
onclick={() => act('moveUp')}
|
||||||
|
>
|
||||||
|
<ArrowUp size="xs" decorative />
|
||||||
|
<span>Move up</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
disabled={!canMoveDown}
|
||||||
|
onclick={() => act('moveDown')}
|
||||||
|
>
|
||||||
|
<ArrowDown size="xs" decorative />
|
||||||
|
<span>Move down</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onclick={() => act('duplicate')}
|
||||||
|
>
|
||||||
|
<Copy size="xs" decorative />
|
||||||
|
<span>Duplicate</span>
|
||||||
|
</button>
|
||||||
|
<div data-words-block-handle-menu-divider aria-hidden="true"></div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
data-tone="risk"
|
||||||
|
onclick={() => act('delete')}
|
||||||
|
>
|
||||||
|
<Trash size="xs" decorative />
|
||||||
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
Loading…
Reference in new issue