words: wire HTML5 drag-reorder for blocks via the gutter grip

The gutter grip rendered (cursor: grab) but the drag-to-move
functionality was lost when the soma-DragDrop migration was abandoned —
clicking it only opened the action menu (Move up / Move down). Restore
the reorder as native HTML5 drag-drop:

- words-block-drag-type.ts: private MIME `application/x-words-block` the
  grip writes (its top-level index) and the controller reads, so block
  reorders are told apart from native text drags.
- words-block-gutter.svelte: the grip's DropdownMenu.Trigger is now
  `draggable`; `ondragstart` stamps the block index on the transfer. The
  menu still opens on click (the dropdown opens on click, not
  pointerdown — no collision with the drag gesture).
- words-block-drag.svelte: the drop sink. Listens on the contenteditable
  for dragover/drop, paints a horizontal indicator at the nearest
  inter-block seam, and repositions via the engine's `moveBlockTo`
  (target is post-removal: `to = from < n ? n - 1 : n`). Bails unless the
  drag carries our private type, leaving native text drag untouched.
  A document-capture `dragend` clears the indicator wherever the drag ends.
- words.svelte: mount the controller next to the gutter.
- words.css: `[data-words-block-drop-line]` accent indicator.

Reorder is top-level only (the gutter is top-level only), mirroring the
gutter's `[data-words-node='block']` block detection exactly.

Verified in-browser: grip is draggable=true; dragstart writes index;
dragover paints the indicator + preventDefaults; drop reorders correctly
downward (0→end) and upward (14→1) with no block lost; native text drags
are not hijacked; no console errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 3a845f0454
commit 7256f53e0c

@ -0,0 +1,8 @@
/**
* Private clipboard MIME the block grip writes on `dragstart` (carrying the
* dragged block's top-level index as its data). The drag-reorder controller
* reads it on `dragover` / `drop` to tell a block reorder apart from a native
* text drag — every drop handler bails unless this type is present, so
* selecting text and dragging it keeps the browser's default behaviour.
*/
export const WORDS_BLOCK_DRAG_TYPE = 'application/x-words-block';

@ -0,0 +1,118 @@
<script lang="ts">
/**
* Block drag-reorder controller (the drop sink).
*
* The left-gutter grip is `draggable`; grabbing it starts an HTML5 drag
* that carries the block's top-level index on a private MIME type
* (`WORDS_BLOCK_DRAG_TYPE`). This controller listens on the contenteditable,
* paints a horizontal indicator at the nearest inter-block seam during the
* drag, and on drop repositions the block via the engine's `moveBlockTo`
* command.
*
* Native text drag-drop is untouched: every handler bails unless the drag
* carries our private type, so selecting text and dragging it still goes
* through the browser's default path.
*
* Block detection mirrors the gutter exactly — top-level blocks are the
* content's direct `[data-words-node='block']` children, and `fromIndex`
* (written by the grip) indexes the same array. Reorder is therefore
* top-level only; nested blocks (inside columns / cells) are not draggable.
*/
import type { ActiveDom } from '$adom';
import type { ProviderSnippetProps } from '$soma/components/words';
import { WORDS_BLOCK_DRAG_TYPE } from './words-block-drag-type';
let {
api,
content,
dom
}: {
api: ProviderSnippetProps;
content: HTMLElement;
dom: ActiveDom;
} = $props();
// The `[data-words]` frame is a positioned ancestor; the indicator is
// placed relative to it, same as the gutter.
const frame = $derived(content.closest('[data-words]') as HTMLElement | null);
let line = $state<{ top: number; left: number; width: number } | null>(null);
function topLevelBlocks(): HTMLElement[] {
return Array.from(content.children).filter(
(el): el is HTMLElement =>
el instanceof HTMLElement && el.matches("[data-words-node='block']")
);
}
function isBlockDrag(e: DragEvent): boolean {
return !!e.dataTransfer && Array.from(e.dataTransfer.types).includes(WORDS_BLOCK_DRAG_TYPE);
}
// Nearest inter-block seam for a viewport-Y cursor. `n` is the insertion
// point in 0..length (n === length ⇒ after the last block). Coords come
// back frame-relative for the indicator.
function dropAt(
clientY: number
): { n: number; top: number; left: number; width: number } | null {
if (!frame) return null;
const blocks = topLevelBlocks();
if (blocks.length === 0) return null;
const fr = frame.getBoundingClientRect();
for (let i = 0; i < blocks.length; i++) {
const r = blocks[i].getBoundingClientRect();
if (clientY < r.top + r.height / 2) {
return { n: i, top: r.top - fr.top, left: r.left - fr.left, width: r.width };
}
}
const last = blocks[blocks.length - 1].getBoundingClientRect();
return {
n: blocks.length,
top: last.bottom - fr.top,
left: last.left - fr.left,
width: last.width
};
}
$effect(() => {
const doc = dom.getDocument(content) ?? content;
const disposers = [
dom.listen(content, 'dragover', (event) => {
const e = event as DragEvent;
if (!isBlockDrag(e)) return; // leave native text drag alone
e.preventDefault(); // a drop only fires when dragover is prevented
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
const d = dropAt(e.clientY);
line = d ? { top: d.top, left: d.left, width: d.width } : null;
}),
dom.listen(content, 'drop', (event) => {
const e = event as DragEvent;
if (!isBlockDrag(e)) return;
e.preventDefault();
const from = Number(e.dataTransfer?.getData(WORDS_BLOCK_DRAG_TYPE));
const d = dropAt(e.clientY);
line = null;
if (!Number.isInteger(from) || from < 0 || !d) return;
// `moveBlockTo`'s target is post-removal: dropping at seam `n`
// when the source sits before it shifts everything down one.
const to = from < d.n ? d.n - 1 : d.n;
api.applyCommand({ type: 'moveBlockTo', fromIndex: from, toIndex: to });
}),
// `dragend` fires on the SOURCE (the grip) and does not bubble, but
// the capture phase still reaches a document-level listener — clears
// the indicator wherever the drag ends, including a drop cancelled
// outside the content.
dom.listen(doc, 'dragend', () => (line = null), { capture: true })
];
return () => {
for (const dispose of disposers) dispose?.();
};
});
</script>
{#if line}
<div
data-words-block-drop-line
style="top: {line.top}px; left: {line.left}px; width: {line.width}px;"
></div>
{/if}

@ -29,6 +29,7 @@
} from '$soma/components/words';
import { pickImageFile } from './words-image-file';
import { WORDS_MENU_LANGS as L } from './langs-menu';
import { WORDS_BLOCK_DRAG_TYPE } from './words-block-drag-type';
let {
api,
@ -200,6 +201,15 @@
// the next selectionchange).
api.selectAtomicBlock(index, [index]);
}
// Start an HTML5 drag carrying this block's top-level index. The drop
// controller (`words-block-drag.svelte`) reads it on drop and repositions
// the block. The grip stays a click target too (the menu opens on click,
// the drag on a press-and-move) — they don't collide.
function onGripDragStart(event: DragEvent) {
if (index < 0 || !event.dataTransfer) return;
event.dataTransfer.setData(WORDS_BLOCK_DRAG_TYPE, String(index));
event.dataTransfer.effectAllowed = 'move';
}
function moveUp() {
if (index > 0) api.applyCommand({ type: 'moveBlock', blockIndex: index, direction: 'up' });
}
@ -326,6 +336,8 @@
rounded="md"
aria-label="Block actions"
data-words-block-handle
draggable={true}
ondragstart={onGripDragStart}
>
{#snippet icon()}
<svg width="15" height="15" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">

@ -140,6 +140,21 @@
}
}
/* Drop indicator painted while a block is dragged by its grip. The drag
controller positions it frame-relative at the nearest inter-block seam;
the bar spans the target block's width. `pointer-events: none` keeps it
clear of the drag's own dragover hit-testing. */
[data-words-block-drop-line] {
position: absolute;
z-index: 5;
height: 2px;
margin-block-start: -1px;
border-radius: 1px;
background: var(--color-primary-solid);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary-solid) 30%, transparent);
pointer-events: none;
}
/* ── Selection bubble toolbar ─────────────────────────────────────────── */
/* Floating card, viewport-anchored (the soma BubbleMenu writes

@ -19,6 +19,7 @@
import { Popover } from '$uix/eidos/components/popover';
import { SlidersHorizontal, X } from '$uix/eidos/components/icon';
import WordsActivate from './words-activate.svelte';
import WordsBlockDrag from './words-block-drag.svelte';
import WordsBlockGutter from './words-block-gutter.svelte';
import WordsBubble from './words-bubble.svelte';
import WordsInspector from './words-inspector.svelte';
@ -75,6 +76,7 @@
{#if contentEl}
<WordsActivate {api} content={contentEl} {dom} />
<WordsBlockGutter {api} content={contentEl} {dom} />
<WordsBlockDrag {api} content={contentEl} {dom} />
<!-- Column-content insertion lives in the Inspector's Columns
panel ("Añadir bloque" dropdown per column), React-Bricks-
style. Floating in-canvas overlay was removed: its

Loading…
Cancel
Save

Powered by TurnKey Linux.