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