From a62b23538ddd36c751a4f43aaa2cadf49af83802 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 27 May 2026 23:24:12 +0200 Subject: [PATCH] feat(words): HTML5 drag-drop block reorder + keyboard grab on grip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block-handle grip is now actually grabbable. Native HTML5 drag: mousedown + drag → dragstart fires → wordsDragState.sourceIndex is set → the BlockInserter switches to drop-target mode (line goes prominent, "+" hides) → dragover snaps to the nearest seam → drop commits `moveBlockTo({fromIndex, toIndex})`. Keyboard equivalent: focus the grip → Space toggles "grabbed" mode (grip lights up accent-filled, aria-pressed) → ArrowUp / ArrowDown swaps with the neighbour (via the existing moveBlock command, so each press is committed individually) → Space or Escape exits. Architecture: - `words-drag-state.svelte.ts` — module-level `$state` shared between handle (writer) and inserter (reader). Lives in eidos because this is a visual interaction pattern, not a soma/morfo contract. - `moveBlockToAt(state, from, to)` in operations.ts — splice out, then splice in at the post-removal destination. Normalizes + drops caret at the moved block's new position. - `moveBlockTo` case in commands.ts. The existing `moveBlock` (swap-with-neighbor) stays because the click-menu uses it. - BlockInserter dragover/drop handlers reuse `findSeamForCursor` — same seam math as the regular insert hover, so the drop indicator lands exactly where the "+" would have. `seam.insertIndex > fromIndex ? -1` correction accounts for the source's removal shifting subsequent indices down by one. CSS: - `[data-words-block-inserter][data-mode='drop']` makes the line solid + thicker + with an outer glow during drag, and hides the "+" button entirely (the line IS the drop indicator). - `[data-words-block-handle][data-grabbed]` paints the grip accent- filled with a focus ring for keyboard-grab state. aria: `aria-pressed={keyboardGrabbed}`, descriptive aria-label on the grip mentioning Space + arrows. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../words/words-block-handle.svelte | 68 +++++++++++++++- .../words/words-block-inserter.svelte | 80 ++++++++++++++++--- .../words/words-drag-state.svelte.ts | 26 ++++++ src/uix/eidos/components/words/words.css | 20 +++++ .../soma/components/words/engine/commands.ts | 8 ++ .../components/words/engine/operations.ts | 42 ++++++++++ 6 files changed, 231 insertions(+), 13 deletions(-) create mode 100644 src/uix/eidos/components/words/words-drag-state.svelte.ts diff --git a/src/uix/eidos/components/words/words-block-handle.svelte b/src/uix/eidos/components/words/words-block-handle.svelte index bd24324c1..7ef0a0177 100644 --- a/src/uix/eidos/components/words/words-block-handle.svelte +++ b/src/uix/eidos/components/words/words-block-handle.svelte @@ -18,12 +18,15 @@ */ import { GripVertical, ArrowUp, ArrowDown, Copy, Trash } from '$uix/eidos/components/icon'; import type { ProviderSnippetProps } from '$soma/components/words'; + import { wordsDragState } from './words-drag-state.svelte'; let { snippet }: { snippet: ProviderSnippetProps } = $props(); let hoverBlockEl = $state(null); let menuOpen = $state(false); let position = $state<{ top: number; left: number } | null>(null); + /** Keyboard "grabbed" mode — Space on grip toggles, arrows move. */ + let keyboardGrabbed = $state(false); const hoverBlockIndex = $derived.by(() => { if (!hoverBlockEl) return undefined; @@ -139,6 +142,60 @@ position = null; } + function ondragstart(e: DragEvent) { + const idx = hoverBlockIndex; + if (idx === undefined) { + e.preventDefault(); + return; + } + // Close any open menu so it doesn't ghost into the drag image. + menuOpen = false; + wordsDragState.sourceIndex = idx; + // Native dataTransfer carries the index for the drop handler; + // the shared $state above is what drives the inserter UI during + // the drag (dataTransfer values aren't readable in dragover). + e.dataTransfer?.setData('text/plain', String(idx)); + if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'; + } + + function ondragend() { + wordsDragState.sourceIndex = undefined; + } + + function onkeydown(e: KeyboardEvent) { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + if (menuOpen) { + menuOpen = false; + return; + } + keyboardGrabbed = !keyboardGrabbed; + wordsDragState.sourceIndex = keyboardGrabbed ? hoverBlockIndex : undefined; + return; + } + if (!keyboardGrabbed) return; + const idx = hoverBlockIndex; + if (idx === undefined) return; + if (e.key === 'Escape') { + e.preventDefault(); + keyboardGrabbed = false; + wordsDragState.sourceIndex = undefined; + return; + } + if (e.key === 'ArrowUp' && idx > 0) { + e.preventDefault(); + snippet.applyCommand({ type: 'moveBlock', blockIndex: idx, direction: 'up' }); + // Track follows the moved block so consecutive arrows keep + // chaining; the overlay re-measures on the next mousemove. + } else if ( + e.key === 'ArrowDown' && + idx < snippet.document.children.length - 1 + ) { + e.preventDefault(); + snippet.applyCommand({ type: 'moveBlock', blockIndex: idx, direction: 'down' }); + } + } + const canMoveUp = $derived(hoverBlockIndex !== undefined && hoverBlockIndex > 0); const canMoveDown = $derived( hoverBlockIndex !== undefined && @@ -151,13 +208,18 @@ type="button" data-words-block-handle data-open={menuOpen ? '' : undefined} + data-grabbed={keyboardGrabbed ? '' : undefined} style="top: {position.top}px; left: {position.left}px;" - title="Block actions" - aria-label="Block actions" + title="Block actions — drag to reorder" + aria-label="Block actions — drag to reorder, Space to grab, arrows to move" aria-haspopup="menu" aria-expanded={menuOpen} + aria-pressed={keyboardGrabbed} + draggable="true" onclick={toggleMenu} - onmousedown={(e) => e.preventDefault()} + ondragstart={ondragstart} + ondragend={ondragend} + onkeydown={onkeydown} > diff --git a/src/uix/eidos/components/words/words-block-inserter.svelte b/src/uix/eidos/components/words/words-block-inserter.svelte index da1bb1273..1da00639c 100644 --- a/src/uix/eidos/components/words/words-block-inserter.svelte +++ b/src/uix/eidos/components/words/words-block-inserter.svelte @@ -20,9 +20,12 @@ */ import { Plus } from '$uix/eidos/components/icon'; import type { ProviderSnippetProps } from '$soma/components/words'; + import { wordsDragState } from './words-drag-state.svelte'; let { snippet }: { snippet: ProviderSnippetProps } = $props(); + const isDragging = $derived(wordsDragState.sourceIndex !== undefined); + type Seam = { readonly insertIndex: number; readonly y: number; @@ -124,6 +127,58 @@ } document.addEventListener('mousemove', onmove); + // During a drag, the browser fires `dragover` instead of + // `mousemove`. Track both surfaces so the same seam-snapping + // logic doubles as the drop indicator. + function ondragover(e: DragEvent) { + if (wordsDragState.sourceIndex === undefined) return; + // Restrict to dragovers happening over the content area + // (or its margin). Outside that, don't show. + const r = content.getBoundingClientRect(); + if ( + e.clientX < r.left - 48 || + e.clientX > r.right + 16 || + e.clientY < r.top - 12 || + e.clientY > r.bottom + 12 + ) { + if (seam !== null) seam = null; + return; + } + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + const next = findSeamForCursor(e, content); + if (next === null && seam === null) return; + if (next !== null && seam !== null && next.insertIndex === seam.insertIndex) { + return; + } + seam = next; + } + + function ondrop(e: DragEvent) { + if (wordsDragState.sourceIndex === undefined) return; + if (!seam) return; + e.preventDefault(); + const fromIndex = wordsDragState.sourceIndex; + // `seam.insertIndex` is in the pre-removal indexing. If the + // destination is AFTER the source, the source's removal + // shifts everything down by one, so subtract 1. + let toIndex = seam.insertIndex; + if (toIndex > fromIndex) toIndex -= 1; + // Dropping back on yourself or directly adjacent (same slot + // either before or after) is a no-op. + if (toIndex === fromIndex) { + seam = null; + wordsDragState.sourceIndex = undefined; + return; + } + snippet.applyCommand({ type: 'moveBlockTo', fromIndex, toIndex }); + seam = null; + wordsDragState.sourceIndex = undefined; + } + + document.addEventListener('dragover', ondragover); + document.addEventListener('drop', ondrop); + const win = content.ownerDocument.defaultView; const onscroll = () => { // Cursor stays put on scroll; recompute under the LAST known @@ -136,6 +191,8 @@ return () => { document.removeEventListener('mousemove', onmove); + document.removeEventListener('dragover', ondragover); + document.removeEventListener('drop', ondrop); win?.removeEventListener('scroll', onscroll); win?.removeEventListener('resize', onscroll); }; @@ -158,19 +215,22 @@ {#if seam}
- + {#if !isDragging} + + {/if}
{/if} diff --git a/src/uix/eidos/components/words/words-drag-state.svelte.ts b/src/uix/eidos/components/words/words-drag-state.svelte.ts new file mode 100644 index 000000000..2ece1fd30 --- /dev/null +++ b/src/uix/eidos/components/words/words-drag-state.svelte.ts @@ -0,0 +1,26 @@ +/** + * Words drag-state — shared reactive store for the eidos-layer + * drag-drop reorder (block handle ↔ block inserter). + * + * BlockHandle writes `sourceIndex` on dragstart / grabbed-via-keyboard; + * BlockInserter reads it to know when to behave as a drop target + * (paint the indicator line, accept the drop). Cleared on dragend / + * Escape / commit. + * + * Lives in the eidos layer because drag-drop is a visual interaction + * pattern, not a contract surface — soma's runtime / morfo never + * observe it. Module-level `$state` (in a `.svelte.ts` file) gives + * both sibling components a shared reactive handle without going + * through soma. + */ + +class WordsDragState { + /** + * Index of the block currently being dragged via the block-handle + * grip, or undefined when no drag is in progress. Set on dragstart + * / keyboard-grabbed; cleared on dragend / drop / Escape. + */ + sourceIndex = $state(undefined); +} + +export const wordsDragState = new WordsDragState(); diff --git a/src/uix/eidos/components/words/words.css b/src/uix/eidos/components/words/words.css index 4ee2539bf..53464a1f2 100644 --- a/src/uix/eidos/components/words/words.css +++ b/src/uix/eidos/components/words/words.css @@ -790,6 +790,26 @@ opacity: 1; } +/* Drop-target mode: while a block is being dragged the inserter + doubles as the drop indicator. The line stays prominent (no hover + needed) and grows a touch so the user can see exactly where the + block will land. */ +[data-words-block-inserter][data-mode='drop'] [data-words-block-inserter-line] { + opacity: 1; + block-size: 3px; + background: var(--_words-accent-solid); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--_words-accent-solid) 25%, transparent); +} + +/* Grabbed state on the block-handle grip — visible when the user has + pressed Space on the focused grip to enter keyboard-grab mode. */ +[data-words-block-handle][data-grabbed] { + opacity: 1; + background: var(--_words-accent-solid); + color: var(--_words-accent-on); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--_words-accent-solid) 25%, transparent); +} + [data-words-drawer-section-title] { display: flex; align-items: baseline; diff --git a/src/uix/soma/components/words/engine/commands.ts b/src/uix/soma/components/words/engine/commands.ts index 308021911..68fa8f483 100644 --- a/src/uix/soma/components/words/engine/commands.ts +++ b/src/uix/soma/components/words/engine/commands.ts @@ -36,6 +36,7 @@ import { insertTableColumn, insertTableRow, moveBlockAt, + moveBlockToAt, updateBlockAt, insertText, moveTableCell, @@ -124,6 +125,11 @@ export type WordsCommand = | { type: 'deleteBlock'; blockIndex: number } /** Swap a block with its neighbor in the given direction. */ | { type: 'moveBlock'; blockIndex: number; direction: 'up' | 'down' } + /** + * Move a block to an arbitrary position (drag-drop reorder). + * `toIndex` is the destination AFTER the source is removed. + */ + | { type: 'moveBlockTo'; fromIndex: number; toIndex: number } /** Insert a deep copy of the block immediately after it. */ | { type: 'duplicateBlock'; blockIndex: number } /** @@ -195,6 +201,8 @@ export function applyWordsCommand( return deleteBlockAt(state, command.blockIndex); case 'moveBlock': return moveBlockAt(state, command.blockIndex, command.direction); + case 'moveBlockTo': + return moveBlockToAt(state, command.fromIndex, command.toIndex); case 'duplicateBlock': return duplicateBlockAt(state, command.blockIndex); case 'insertBlock': diff --git a/src/uix/soma/components/words/engine/operations.ts b/src/uix/soma/components/words/engine/operations.ts index 38d100f20..b3a0d1b70 100644 --- a/src/uix/soma/components/words/engine/operations.ts +++ b/src/uix/soma/components/words/engine/operations.ts @@ -907,6 +907,48 @@ export function deleteBlockAt( }; } +/** + * Move a block from `fromIndex` to `toIndex` (post-removal index). + * `toIndex` is interpreted in the array WITHOUT the source — so to + * move block 0 to the very end of a 5-block doc, `toIndex` is 4. Used + * by the drag-drop reorder; the click-menu "move up / down" stays on + * `moveBlockAt` (swap with neighbor). + */ +export function moveBlockToAt( + state: WordsEditorState, + fromIndex: number, + toIndex: number +): WordsOperationResult { + const children = state.document.children; + if (fromIndex < 0 || fromIndex >= children.length) { + return { state, changed: false }; + } + if (toIndex < 0 || toIndex >= children.length) { + return { state, changed: false }; + } + if (fromIndex === toIndex) return { state, changed: false }; + const without = [...children.slice(0, fromIndex), ...children.slice(fromIndex + 1)]; + const nextChildren = [ + ...without.slice(0, toIndex), + children[fromIndex], + ...without.slice(toIndex) + ]; + const normalized = normalizeDocument({ + ...state.document, + children: nextChildren + }).document; + const point = pointFromInlineTextOffset(normalized, [toIndex], 0); + const selection = createCollapsedSelection(point.path, point.offset); + return { + state: { + document: normalized, + selection, + activeMarks: getActiveMarksForSelection(normalized, selection) + }, + changed: true + }; +} + /** * Insert an arbitrary block at `blockIndex`. The new block takes that * index; everything from `blockIndex` onwards shifts down one slot.