fix(words): grip lands in the rail (rem→px) + drag preview is the block

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 `<DragDrop.Provider>`: 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 `<DragDrop.Draggable>` 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) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent ccd9d3e0ab
commit 08ae867caf

@ -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<HTMLElement>('[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() {

Loading…
Cancel
Save

Powered by TurnKey Linux.