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
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}
|
||||||
Loading…
Reference in new issue