feat(words): HTML5 drag-drop block reorder + keyboard grab on grip

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) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 305fd70129
commit a62b23538d

@ -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<HTMLElement | null>(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}
>
<GripVertical size="sm" decorative />
</button>

@ -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}
<div
data-words-block-inserter
data-mode={isDragging ? 'drop' : 'insert'}
style="top: {seam.y}px; left: {seam.left}px; width: {seam.right - seam.left}px;"
role="presentation"
>
<button
type="button"
data-words-block-inserter-button
title="Insert paragraph here"
aria-label="Insert paragraph here"
onclick={insertHere}
onmousedown={(e) => e.preventDefault()}
>
<Plus size="xs" decorative />
</button>
{#if !isDragging}
<button
type="button"
data-words-block-inserter-button
title="Insert paragraph here"
aria-label="Insert paragraph here"
onclick={insertHere}
onmousedown={(e) => e.preventDefault()}
>
<Plus size="xs" decorative />
</button>
{/if}
<span data-words-block-inserter-line aria-hidden="true"></span>
</div>
{/if}

@ -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<number | undefined>(undefined);
}
export const wordsDragState = new WordsDragState();

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

@ -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':

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

Loading…
Cancel
Save

Powered by TurnKey Linux.