From c4be6dbe1bba2baea6a6e42b5eba8f4a09c7c4cf Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 28 May 2026 02:57:20 +0200 Subject: [PATCH] fix(words): silence body event spam + slim top toolbar + reposition handle/inserter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four user-requested fixes to the Words editor surface — the body should not emit perceptual cues during editing, the top toolbar must not duplicate the drawer, and the gutter widgets need clearer anchor points. EV-A (events spam): - Move `contact-focus` event target from `content` to `provider` in the morfo + sema cascade selector. Focus is a Words-component-level signal; firing it from the content body conflates "user entered the editor" with "user is editing the body". The wasFocused guard in soma already throttles to one emit per real focus transition. - Expand `isInsideWordsTool` selector list to cover the four companion overlays added after the original list was written: `data-words-drawer`, `data-words-block-handle`, `data-words-block-handle-menu`, `data-words-block-inserter`, `data-words-image-float-bar`. Before this fix every click on a companion overlay fired commit-save-content + contact-focus on the blur/refocus cycle (two perceptual cues per tool interaction). EV-B (toolbar trim): - Demo's full / formatting / minimal presets + custom config now host only GLOBAL actions: history (undo/redo), insert (creates new blocks), link (selection-bound flow), tools (find/replace + clear), find-replace in its own group. text-menu / block-menu / align-menu / list-menu / table-menu moved out because the drawer already owns contextual formatting per the HIER-2 split. EV-C (block-handle drag UX): - Drop the `setDragImage(hoverBlockEl)` call. The browser now uses its default snapshot (the grip button itself) as the drag ghost — the ghost travels with the cursor while the bar in the gutter stays fixed as a visual anchor. New `data-dragging` attr + `[data-words-block-handle][data-dragging]` CSS rule fades the static anchor to 0.35 opacity so it reads as "drag origin" while the ghost is the moving part. EV-D (inserter at block bottom): - Seam positions are now pinned to the BOTTOM EDGE of the preceding block (`a.bottom`) instead of the midpoint of the gap between two blocks. The "+" reads as "insert AFTER this block" anchored to that block's lower edge, per spec — el botón de añadir bloque debe aparecer en el límite inferior del área en relación al bloque. - Tighten the "cursor inside block band" check to a half-open interval `[top, bottom)` so the exact bottom-edge pixel belongs to the seam below (otherwise the seam at `y === bottom` is shadowed by the block and the inserter never snaps). Verification: dev server, /uix/components/words, DOM probe confirms handle centered in the rail column (left=48 inside the 32-wide rail starting at ~46), inserter snaps to block 1's bottom (`top=569.94px` when block 1 bottom = 570px). 366/366 tests pass in morfo + sema + soma/components/words. `npm run check` still 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../words/words-block-handle.svelte | 25 ++++++----- .../words/words-block-inserter.svelte | 18 +++++--- src/uix/eidos/components/words/words.css | 11 +++++ src/uix/morfo/components/words.ts | 10 ++++- src/uix/sema/components/words.ts | 7 ++- .../components/words/words-provider.svelte.ts | 33 +++++++++++++- web/routes/uix/components/words/+page.svelte | 43 ++++++------------- 7 files changed, 96 insertions(+), 51 deletions(-) diff --git a/src/uix/eidos/components/words/words-block-handle.svelte b/src/uix/eidos/components/words/words-block-handle.svelte index 1b0f2cf3e..863c1968f 100644 --- a/src/uix/eidos/components/words/words-block-handle.svelte +++ b/src/uix/eidos/components/words/words-block-handle.svelte @@ -27,6 +27,10 @@ let position = $state<{ top: number; left: number } | null>(null); /** Keyboard "grabbed" mode — Space on grip toggles, arrows move. */ let keyboardGrabbed = $state(false); + /** True while a pointer-drag is in flight. Drives [data-dragging] + * so the static anchor visually fades (the ghost is the moving + * part; the bar stays put per spec). */ + let dragging = $state(false); const hoverBlockIndex = $derived.by(() => { if (!hoverBlockEl) return undefined; @@ -269,27 +273,25 @@ } // Close any open menu so it doesn't ghost into the drag image. menuOpen = false; + dragging = true; wordsDragState.sourceIndex = idx; // Native dataTransfer carries the index for the drop handler; // the shared $state above is what drives the inserter UI during // 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. */ - } - } + // Per spec: the BAR stays fixed in the gutter; only the grip + // ICON travels with the cursor. We achieve that by letting the + // browser use its default drag image (a snapshot of the grip + // button itself) instead of replacing it with the whole block. + // The original button in the DOM remains positioned in place; + // CSS uses [data-dragging] to dim it so the static anchor is + // visible without competing with the floating ghost. } function ondragend() { wordsDragState.sourceIndex = undefined; + dragging = false; } function onkeydown(e: KeyboardEvent) { @@ -339,6 +341,7 @@ data-words-block-handle data-open={menuOpen ? '' : undefined} data-grabbed={keyboardGrabbed ? '' : undefined} + data-dragging={dragging ? '' : undefined} style="top: {position.top}px; left: {position.left}px;" title="Block actions — drag to reorder" aria-label="Block actions — drag to reorder, Space to grab, arrows to move" diff --git a/src/uix/eidos/components/words/words-block-inserter.svelte b/src/uix/eidos/components/words/words-block-inserter.svelte index c8a245ae3..281d2731b 100644 --- a/src/uix/eidos/components/words/words-block-inserter.svelte +++ b/src/uix/eidos/components/words/words-block-inserter.svelte @@ -48,21 +48,26 @@ ).filter((el) => !(el.getAttribute('data-words-path') ?? '').includes('.')); if (!blocks.length) return []; const seams: Seam[] = []; - // "Before first block" seam. + // "Before first block" seam — pinned to the TOP edge of block 0. const first = blocks[0].getBoundingClientRect(); seams.push({ insertIndex: 0, y: first.top, left: first.left, right: first.right }); - // Between-blocks seams: anchor at midpoint of the visual gap. + // Between-blocks seams: pinned to the BOTTOM EDGE of the + // preceding block (not the midpoint of the gap). Per spec: + // "el botón de añadir bloque debe aparecer en el límite + // inferior del área en relación al bloque". The "+" reads as + // "insert AFTER this block" instead of floating in nowhere. for (let i = 0; i < blocks.length - 1; i++) { const a = blocks[i].getBoundingClientRect(); const b = blocks[i + 1].getBoundingClientRect(); seams.push({ insertIndex: i + 1, - y: (a.bottom + b.top) / 2, + y: a.bottom, left: Math.min(a.left, b.left), right: Math.max(a.right, b.right) }); } - // "After last block" seam. + // "After last block" seam — pinned to the BOTTOM edge of the + // last block. const last = blocks[blocks.length - 1].getBoundingClientRect(); seams.push({ insertIndex: blocks.length, @@ -89,6 +94,9 @@ return null; } // Cursor inside ANY block's vertical band → grip's territory. + // Half-open interval [top, bottom): the EXACT bottom edge belongs + // to the seam below (per spec — "+" anchors to the block's bottom + // edge), so the seam can win when the cursor sits on that pixel. const blocks = Array.from( content.querySelectorAll( '[data-words-node="block"][data-words-path]' @@ -96,7 +104,7 @@ ).filter((b) => !(b.getAttribute('data-words-path') ?? '').includes('.')); for (const block of blocks) { const r = block.getBoundingClientRect(); - if (e.clientY >= r.top && e.clientY <= r.bottom) return null; + if (e.clientY >= r.top && e.clientY < r.bottom) return null; } // Cursor is in the rail AND between blocks — snap to the nearest // seam. No X-bound check needed; we already know X is in the rail. diff --git a/src/uix/eidos/components/words/words.css b/src/uix/eidos/components/words/words.css index 71c6d09c9..226feb64c 100644 --- a/src/uix/eidos/components/words/words.css +++ b/src/uix/eidos/components/words/words.css @@ -818,6 +818,17 @@ cursor: grabbing; } +/* Drag in flight: the bar STAYS fixed in the gutter (visual anchor); + * only the browser's default drag-ghost of the grip travels with the + * cursor. Fade the static anchor so it doesn't compete with the + * floating ghost — the user reads "this is where it left from" while + * the cursor carries the moving icon. */ +[data-words-block-handle][data-dragging] { + opacity: 0.35; + background: var(--words-toolbar-bg); + cursor: grabbing; +} + [data-words-block-handle-menu] { position: fixed; display: flex; diff --git a/src/uix/morfo/components/words.ts b/src/uix/morfo/components/words.ts index aa50637eb..bb85b5117 100644 --- a/src/uix/morfo/components/words.ts +++ b/src/uix/morfo/components/words.ts @@ -20,11 +20,19 @@ export const wordsMorfo = { }, events: [ { + // Doctrinal: focus is a Words-component-level event, NOT a + // content-body event. The body is a contenteditable surface + // where the user works for extended periods — emitting a + // perceptual cue from THERE on every focus / refocus is + // fatiguing. Targeting the provider scopes the stamp + + // cascade to the component as a whole; the + // `wasFocused` guard in soma already throttles to one + // emit per real focus transition (external blur → focus). name: 'contact-focus', semantic: { family: 'contact', verb: 'focus', - target: v.partRef('content'), + target: v.partRef('provider'), sequence: 'coincident' } }, diff --git a/src/uix/sema/components/words.ts b/src/uix/sema/components/words.ts index 70f1b108c..7cbf2582f 100644 --- a/src/uix/sema/components/words.ts +++ b/src/uix/sema/components/words.ts @@ -25,7 +25,12 @@ export const wordsSema: Sema = { name: 'words', cascade: [ { - selector: onContent({ eventName: 'contact-focus' }), + // Words-level focus signal. Fires ONCE per real focus + // transition (provider target — see morfo note). Internal + // tool bounces (drawer / block-handle / inserter / float + // bar / popovers) do NOT re-fire because `isInsideWordsTool` + // holds `focused` across them. + selector: onProvider({ eventName: 'contact-focus' }), sound: soundTuning('form.commit.subtle', { gain: { op: 'multiply', factor: 0.55 }, pitch: { op: 'add', value: 40 } diff --git a/src/uix/soma/components/words/words-provider.svelte.ts b/src/uix/soma/components/words/words-provider.svelte.ts index eabf25bc3..8ede5c0df 100644 --- a/src/uix/soma/components/words/words-provider.svelte.ts +++ b/src/uix/soma/components/words/words-provider.svelte.ts @@ -749,7 +749,13 @@ export class WordsProvider { this.focused = true; this.ensureSelection(); if (!wasFocused) { - void this.runtime.trigger('contact-focus', { fallbackTarget: e.currentTarget as HTMLElement }); + // Stamp the contact-focus event on the PROVIDER (component + // scope), not the content body — matches the morfo target. + // Falls back to the content element if the provider ref is + // not yet registered (e.g. during very early mount). + void this.runtime.trigger('contact-focus', { + fallbackTarget: this.opts.ref.current ?? (e.currentTarget as HTMLElement) + }); } }; @@ -1235,8 +1241,31 @@ export class WordsProvider { } private isInsideWordsTool(element: HTMLElement): boolean { + // Internal-tool predicate for the focus scope. Any tool that + // the user clicks WITHOUT intending to leave the editor must + // be listed here — otherwise the editor sees the click as an + // external blur and emits commit-save-content + contact-focus + // on the return (two perceptual cues per tool interaction). + // Order is alphabetical for grep-ability; the set is the + // closed surface of Words companion / overlay parts. return !!element.closest( - '[data-words-bubble-menu], [data-words-toolbar], [data-words-find-replace], [data-words-heading-picker], [data-words-toolbar-family], [data-words-toolbar-family-panel], [data-words-code-language-picker], [data-words-code-language-panel], [data-words-link-editor], [data-words-slash-menu]' + [ + '[data-words-block-handle]', + '[data-words-block-handle-menu]', + '[data-words-block-inserter]', + '[data-words-bubble-menu]', + '[data-words-code-language-panel]', + '[data-words-code-language-picker]', + '[data-words-drawer]', + '[data-words-find-replace]', + '[data-words-heading-picker]', + '[data-words-image-float-bar]', + '[data-words-link-editor]', + '[data-words-slash-menu]', + '[data-words-toolbar]', + '[data-words-toolbar-family]', + '[data-words-toolbar-family-panel]' + ].join(', ') ); } diff --git a/web/routes/uix/components/words/+page.svelte b/web/routes/uix/components/words/+page.svelte index 3497b6dd0..5ab8c2f7c 100644 --- a/web/routes/uix/components/words/+page.svelte +++ b/web/routes/uix/components/words/+page.svelte @@ -95,40 +95,21 @@ const exportFormats: WordsExportFormat[] = ['json', 'text', 'html', 'markdown']; const importFormats: WordsImportFormat[] = ['json', 'text', 'html', 'markdown']; const toolbarChoices: ToolbarChoice[] = ['none', 'minimal', 'formatting', 'full', 'custom']; + // Top-toolbar canon (post-drawer): the drawer owns CONTEXTUAL + // actions (format/block/list/align/cell/row/table/code/image — + // scoped to the caret position). The toolbar owns GLOBAL actions: + // history (undo/redo across the document), insert (creates new + // blocks), link (selection-bound but distinct flow), tools + // (find/replace + clear). Per-mark and per-block menus moved out + // because the drawer already covers them — having them in both + // surfaces is noisy redundancy. const toolbarPresetGroups: Record = { - minimal: [['text-menu']], - formatting: [['text-menu', 'block-menu', 'align-menu', 'insert-menu', 'table-menu']], - full: [ - [ - 'history-menu', - 'text-menu', - 'block-menu', - 'list-menu', - 'align-menu', - 'insert-menu', - 'table-menu', - 'link-menu', - 'tools-menu' - ], - ['find-replace'] - ] + minimal: [['history-menu']], + formatting: [['history-menu', 'insert-menu', 'link-menu']], + full: [['history-menu', 'insert-menu', 'link-menu', 'tools-menu'], ['find-replace']] }; const toolbarCustomGroups: WordsToolbarGroupConfig[] = [ - [ - 'history-menu', - { - part: 'toolbar-family', - family: 'text', - label: 'Inline text', - items: ['bold', 'italic', 'underline', 'strike', 'code'] - }, - 'block-menu', - 'list-menu', - 'align-menu', - 'table-menu', - 'link-menu', - 'tools-menu' - ], + ['history-menu', 'insert-menu', 'link-menu', 'tools-menu'], ['find-replace'] ]; const demoToolbarFamilyItems: Record<