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<