feat(words): drag handle + block action menu per block (POLISH-2a)

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
dev 4 months ago
parent 5acee94777
commit 66225404e1

@ -15,6 +15,7 @@ import Status from './words-status.svelte';
import FindReplace from './words-find-replace.svelte';
import Drawer from './words-drawer.svelte';
import ImageFloatBar from './words-image-float-bar.svelte';
import BlockHandle from './words-block-handle.svelte';
type WordsNamespace = typeof WordsComponent & {
Content: typeof Content;
@ -33,6 +34,7 @@ type WordsNamespace = typeof WordsComponent & {
FindReplace: typeof FindReplace;
Drawer: typeof Drawer;
ImageFloatBar: typeof ImageFloatBar;
BlockHandle: typeof BlockHandle;
};
const Words = WordsComponent as WordsNamespace;
@ -52,6 +54,7 @@ Words.Status = Status;
Words.FindReplace = FindReplace;
Words.Drawer = Drawer;
Words.ImageFloatBar = ImageFloatBar;
Words.BlockHandle = BlockHandle;
export { Words };
export default Words;

@ -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}

@ -521,6 +521,88 @@
margin-inline: 0.15rem;
}
/* Block handle — Notion-style drag grip rendered to the left of the
hovered block. Hidden by default (`opacity: 0`) and fades in on
hover via opacity transition. Click reveals an action menu
(Move up / Move down / Duplicate / Delete). The button itself is
positioned via inline `top` / `left` set from getBoundingClientRect. */
[data-words-block-handle] {
position: fixed;
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 1.5rem;
block-size: 1.5rem;
padding: 0;
border: 0;
border-radius: var(--words-command-radius);
background: transparent;
color: var(--words-status-color);
cursor: grab;
opacity: 0.45;
transition: opacity 120ms ease, background-color 120ms ease, color 120ms ease;
z-index: var(--z-index-popover);
}
[data-words-block-handle]:hover,
[data-words-block-handle][data-open] {
opacity: 1;
background: var(--words-command-hover-bg);
color: var(--words-command-active-color);
}
[data-words-block-handle]:active {
cursor: grabbing;
}
[data-words-block-handle-menu] {
position: fixed;
display: flex;
flex-direction: column;
min-inline-size: 9rem;
padding: 0.25rem;
border-radius: var(--words-command-radius);
background: var(--words-toolbar-bg);
border: var(--words-border-width) solid var(--_words-accent-border);
box-shadow: var(--words-shadow);
z-index: var(--z-index-popover);
}
[data-words-block-handle-menu] button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.5rem;
border: 0;
border-radius: var(--words-command-radius);
background: transparent;
color: var(--words-command-color);
text-align: start;
font: inherit;
cursor: pointer;
}
[data-words-block-handle-menu] button:hover:not(:disabled) {
background: color-mix(in srgb, var(--_words-accent-solid) 12%, transparent);
color: var(--_words-accent-text);
}
[data-words-block-handle-menu] button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
[data-words-block-handle-menu] button[data-tone='risk']:hover:not(:disabled) {
background: color-mix(in srgb, var(--words-threat-text) 15%, transparent);
color: var(--words-threat-text);
}
[data-words-block-handle-menu-divider] {
block-size: 1px;
margin-block: 0.25rem;
background: var(--words-toolbar-border);
}
[data-words-drawer-section-title] {
display: flex;
align-items: baseline;

@ -29,10 +29,12 @@ import {
insertLineBreak,
insertParagraph,
deleteBlockAt,
duplicateBlockAt,
insertImage,
insertTable,
insertTableColumn,
insertTableRow,
moveBlockAt,
updateBlockAt,
insertText,
moveTableCell,
@ -119,6 +121,10 @@ export type WordsCommand =
/** Remove a block by index. Used by the image float bar delete
* action and any future block-action menus. */
| { type: 'deleteBlock'; blockIndex: number }
/** Swap a block with its neighbor in the given direction. */
| { type: 'moveBlock'; blockIndex: number; direction: 'up' | 'down' }
/** Insert a deep copy of the block immediately after it. */
| { type: 'duplicateBlock'; blockIndex: number }
| { type: 'insertTableRow'; position?: 'before' | 'after' }
| { type: 'insertTableColumn'; position?: 'before' | 'after' }
| { type: 'deleteTableRow' }
@ -175,6 +181,10 @@ export function applyWordsCommand(
return updateBlockAt(state, command.blockIndex, command.patch);
case 'deleteBlock':
return deleteBlockAt(state, command.blockIndex);
case 'moveBlock':
return moveBlockAt(state, command.blockIndex, command.direction);
case 'duplicateBlock':
return duplicateBlockAt(state, command.blockIndex);
case 'insertTableRow':
return insertTableRow(state, { position: command.position });
case 'insertTableColumn':

@ -807,6 +807,76 @@ export function updateBlockAt(
};
}
/**
* Move a block one step in `direction`. No-op when already at the
* boundary. After moving, selection follows the block to its new
* position so the user keeps the visual focus on what they moved.
*/
export function moveBlockAt(
state: WordsEditorState,
blockIndex: number,
direction: 'up' | 'down'
): WordsOperationResult {
const children = state.document.children;
if (blockIndex < 0 || blockIndex >= children.length) return { state, changed: false };
const target = direction === 'up' ? blockIndex - 1 : blockIndex + 1;
if (target < 0 || target >= children.length) return { state, changed: false };
const nextChildren = children.slice();
const tmp = nextChildren[blockIndex];
nextChildren[blockIndex] = nextChildren[target];
nextChildren[target] = tmp;
const normalized = normalizeDocument({
...state.document,
children: nextChildren
}).document;
const point = pointFromInlineTextOffset(normalized, [target], 0);
const selection = createCollapsedSelection(point.path, point.offset);
return {
state: {
document: normalized,
selection,
activeMarks: getActiveMarksForSelection(normalized, selection)
},
changed: true
};
}
/**
* Insert a deep copy of the block at `blockIndex` immediately after
* it. Selection moves to the duplicate so the user can edit the new
* copy without losing context.
*/
export function duplicateBlockAt(
state: WordsEditorState,
blockIndex: number
): WordsOperationResult {
const block = state.document.children[blockIndex];
if (!block) return { state, changed: false };
// Structured clone preserves the deep readonly shape — JSON
// round-trip is portable and avoids importing structuredClone.
const copy = JSON.parse(JSON.stringify(block)) as WordsBlock;
const nextChildren = [
...state.document.children.slice(0, blockIndex + 1),
copy,
...state.document.children.slice(blockIndex + 1)
];
const normalized = normalizeDocument({
...state.document,
children: nextChildren
}).document;
const newIndex = blockIndex + 1;
const point = pointFromInlineTextOffset(normalized, [newIndex], 0);
const selection = createCollapsedSelection(point.path, point.offset);
return {
state: {
document: normalized,
selection,
activeMarks: getActiveMarksForSelection(normalized, selection)
},
changed: true
};
}
/**
* Remove the block at `blockIndex`. If the doc would be empty,
* leaves an empty paragraph behind so the editor still has something

@ -8,6 +8,7 @@ import type {
PrimitiveSpanAttributes
} from '../../types';
import type {
WordsCommand,
WordsDocument,
WordsExportFormat,
WordsHeadingLevel,
@ -138,7 +139,16 @@ export type WordsProviderSnippetProps = {
readonly focus: () => void;
readonly selectAll: () => void;
readonly selectBlock: () => void;
readonly selectAtomicBlock: (blockIndex: number) => void;
readonly clearSelectedBlock: () => void;
readonly runCommand: (command: WordsCommandName) => void;
/**
* Apply a command directly without going through the sema runtime
* (no event emit, no semantic side-effects). Use this for discrete
* imperatives invoked by overlays / drag handles / float bars —
* not for sema-tuned text edits.
*/
readonly applyCommand: (command: WordsCommand) => boolean;
readonly setCodeLanguage: (language?: string) => boolean;
readonly insertLink: (href: string, title?: string) => boolean;
readonly unlink: () => boolean;

@ -1078,6 +1078,7 @@
<Words.Drawer />
{/if}
<Words.ImageFloatBar snippet={snippetProps} />
<Words.BlockHandle snippet={snippetProps} />
{#if showStatus}
<Words.Status>
{#if invalid}

Loading…
Cancel
Save

Powered by TurnKey Linux.