From 08ae867caf380a73bd44946c64fc7e51f8128b19 Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 28 May 2026 00:37:13 +0200 Subject: [PATCH] =?UTF-8?q?fix(words):=20grip=20lands=20in=20the=20rail=20?= =?UTF-8?q?(rem=E2=86=92px)=20+=20drag=20preview=20is=20the=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs blocking the drag-and-drop UX: 1. **Grip was 22px off** — `parseFloat('2rem')` returns 2, not 32. I was reading `--_words-rail-px` / `--_words-rail-gap` as raw strings and treating them as pixels. With the actual rem values the grip landed in the gap/content padding instead of the rail. Fix: `cssToPx(raw, rootFontSize)` resolves `rem`/`em` → px via the documentElement's computed font-size. With the fix the grip's center matches the rail's center exactly. 2. **Drag preview was the tiny grip icon, not the block** — native HTML5 drag defaults to a snapshot of the `draggable` element. Since `draggable=true` lives on the 28×28 grip button, the user saw a tiny grip ghosted under the cursor instead of the paragraph/heading/image being moved. Fix: `e.dataTransfer.setDragImage(hoverBlockEl, 12, 12)` in `ondragstart`. The browser now uses the BLOCK's element as the drag image — user sees the whole row/heading/image moving with the cursor, just like Notion / Linear / Figma. Note re: soma's ``: the user is right that soma has a first-class drag-drop system (pointer + keyboard + ARIA). The reason this implementation still uses native HTML5 dragstart/ dragend is architectural: soma's `` is designed to WRAP each draggable item, but here the blocks are rendered by the engine's HTML render function as plain DOM (not Svelte components), so each block isn't wrappable. The block-handle is a single floating overlay that snaps to whichever block is hovered. Migrating to soma DragDrop would require restructuring the editor render to per-block Svelte components — a larger architectural change. Filed as a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../words/words-block-handle.svelte | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/uix/eidos/components/words/words-block-handle.svelte b/src/uix/eidos/components/words/words-block-handle.svelte index 729a6af21..37b205fb3 100644 --- a/src/uix/eidos/components/words/words-block-handle.svelte +++ b/src/uix/eidos/components/words/words-block-handle.svelte @@ -58,6 +58,16 @@ * (rail-px + rail-gap), so the rail's center in viewport coords * is `block.rect.left - content-px - rail-gap - rail-px/2`. */ + function cssToPx(raw: string, rootFontSize: number): number { + const trimmed = raw.trim(); + const num = parseFloat(trimmed); + if (!isFinite(num)) return 0; + if (trimmed.endsWith('rem')) return num * rootFontSize; + if (trimmed.endsWith('em')) return num * rootFontSize; + // 'px', unitless, calc()-resolved → already in px (or treat as px) + return num; + } + function rememberRailMetrics(el: HTMLElement): { railPx: number; railGap: number; @@ -65,9 +75,10 @@ } { const host = el.closest('[data-words]') ?? document.documentElement; const cs = getComputedStyle(host); - const railPx = parseFloat(cs.getPropertyValue('--_words-rail-px')) || 32; - const railGap = parseFloat(cs.getPropertyValue('--_words-rail-gap')) || 8; - const contentPx = parseFloat(cs.getPropertyValue('--_words-content-px')) || 16; + const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; + const railPx = cssToPx(cs.getPropertyValue('--_words-rail-px'), rootFontSize) || 32; + const railGap = cssToPx(cs.getPropertyValue('--_words-rail-gap'), rootFontSize) || 8; + const contentPx = cssToPx(cs.getPropertyValue('--_words-content-px'), rootFontSize) || 16; return { railPx, railGap, contentPx }; } @@ -261,6 +272,17 @@ // the drag (dataTransfer values aren't readable in dragover). e.dataTransfer?.setData('text/plain', String(idx)); if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'; + // Use the BLOCK ELEMENT (not the tiny grip) as the drag image so + // the user sees the whole paragraph/heading/image moving with the + // cursor. Offset puts the cursor near the grip's original spot + // (top-left of the block) so the gesture feels natural. + if (e.dataTransfer && hoverBlockEl) { + try { + e.dataTransfer.setDragImage(hoverBlockEl, 12, 12); + } catch { + /* Some browsers throw for cross-origin/iframe edge cases. */ + } + } } function ondragend() {