fix(words): silence body event spam + slim top toolbar + reposition handle/inserter

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) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent c250f7d790
commit c4be6dbe1b

@ -27,6 +27,10 @@
let position = $state<{ top: number; left: number } | null>(null); let position = $state<{ top: number; left: number } | null>(null);
/** Keyboard "grabbed" mode — Space on grip toggles, arrows move. */ /** Keyboard "grabbed" mode — Space on grip toggles, arrows move. */
let keyboardGrabbed = $state(false); 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(() => { const hoverBlockIndex = $derived.by(() => {
if (!hoverBlockEl) return undefined; if (!hoverBlockEl) return undefined;
@ -269,27 +273,25 @@
} }
// Close any open menu so it doesn't ghost into the drag image. // Close any open menu so it doesn't ghost into the drag image.
menuOpen = false; menuOpen = false;
dragging = true;
wordsDragState.sourceIndex = idx; wordsDragState.sourceIndex = idx;
// Native dataTransfer carries the index for the drop handler; // Native dataTransfer carries the index for the drop handler;
// the shared $state above is what drives the inserter UI during // the shared $state above is what drives the inserter UI during
// the drag (dataTransfer values aren't readable in dragover). // the drag (dataTransfer values aren't readable in dragover).
e.dataTransfer?.setData('text/plain', String(idx)); e.dataTransfer?.setData('text/plain', String(idx));
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'; if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
// Use the BLOCK ELEMENT (not the tiny grip) as the drag image so // Per spec: the BAR stays fixed in the gutter; only the grip
// the user sees the whole paragraph/heading/image moving with the // ICON travels with the cursor. We achieve that by letting the
// cursor. Offset puts the cursor near the grip's original spot // browser use its default drag image (a snapshot of the grip
// (top-left of the block) so the gesture feels natural. // button itself) instead of replacing it with the whole block.
if (e.dataTransfer && hoverBlockEl) { // The original button in the DOM remains positioned in place;
try { // CSS uses [data-dragging] to dim it so the static anchor is
e.dataTransfer.setDragImage(hoverBlockEl, 12, 12); // visible without competing with the floating ghost.
} catch {
/* Some browsers throw for cross-origin/iframe edge cases. */
}
}
} }
function ondragend() { function ondragend() {
wordsDragState.sourceIndex = undefined; wordsDragState.sourceIndex = undefined;
dragging = false;
} }
function onkeydown(e: KeyboardEvent) { function onkeydown(e: KeyboardEvent) {
@ -339,6 +341,7 @@
data-words-block-handle data-words-block-handle
data-open={menuOpen ? '' : undefined} data-open={menuOpen ? '' : undefined}
data-grabbed={keyboardGrabbed ? '' : undefined} data-grabbed={keyboardGrabbed ? '' : undefined}
data-dragging={dragging ? '' : undefined}
style="top: {position.top}px; left: {position.left}px;" style="top: {position.top}px; left: {position.left}px;"
title="Block actions — drag to reorder" title="Block actions — drag to reorder"
aria-label="Block actions — drag to reorder, Space to grab, arrows to move" aria-label="Block actions — drag to reorder, Space to grab, arrows to move"

@ -48,21 +48,26 @@
).filter((el) => !(el.getAttribute('data-words-path') ?? '').includes('.')); ).filter((el) => !(el.getAttribute('data-words-path') ?? '').includes('.'));
if (!blocks.length) return []; if (!blocks.length) return [];
const seams: Seam[] = []; const seams: Seam[] = [];
// "Before first block" seam. // "Before first block" seam — pinned to the TOP edge of block 0.
const first = blocks[0].getBoundingClientRect(); const first = blocks[0].getBoundingClientRect();
seams.push({ insertIndex: 0, y: first.top, left: first.left, right: first.right }); 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++) { for (let i = 0; i < blocks.length - 1; i++) {
const a = blocks[i].getBoundingClientRect(); const a = blocks[i].getBoundingClientRect();
const b = blocks[i + 1].getBoundingClientRect(); const b = blocks[i + 1].getBoundingClientRect();
seams.push({ seams.push({
insertIndex: i + 1, insertIndex: i + 1,
y: (a.bottom + b.top) / 2, y: a.bottom,
left: Math.min(a.left, b.left), left: Math.min(a.left, b.left),
right: Math.max(a.right, b.right) 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(); const last = blocks[blocks.length - 1].getBoundingClientRect();
seams.push({ seams.push({
insertIndex: blocks.length, insertIndex: blocks.length,
@ -89,6 +94,9 @@
return null; return null;
} }
// Cursor inside ANY block's vertical band → grip's territory. // 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( const blocks = Array.from(
content.querySelectorAll<HTMLElement>( content.querySelectorAll<HTMLElement>(
'[data-words-node="block"][data-words-path]' '[data-words-node="block"][data-words-path]'
@ -96,7 +104,7 @@
).filter((b) => !(b.getAttribute('data-words-path') ?? '').includes('.')); ).filter((b) => !(b.getAttribute('data-words-path') ?? '').includes('.'));
for (const block of blocks) { for (const block of blocks) {
const r = block.getBoundingClientRect(); 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 // 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. // seam. No X-bound check needed; we already know X is in the rail.

@ -818,6 +818,17 @@
cursor: grabbing; 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] { [data-words-block-handle-menu] {
position: fixed; position: fixed;
display: flex; display: flex;

@ -20,11 +20,19 @@ export const wordsMorfo = {
}, },
events: [ 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', name: 'contact-focus',
semantic: { semantic: {
family: 'contact', family: 'contact',
verb: 'focus', verb: 'focus',
target: v.partRef('content'), target: v.partRef('provider'),
sequence: 'coincident' sequence: 'coincident'
} }
}, },

@ -25,7 +25,12 @@ export const wordsSema: Sema = {
name: 'words', name: 'words',
cascade: [ 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', { sound: soundTuning('form.commit.subtle', {
gain: { op: 'multiply', factor: 0.55 }, gain: { op: 'multiply', factor: 0.55 },
pitch: { op: 'add', value: 40 } pitch: { op: 'add', value: 40 }

@ -749,7 +749,13 @@ export class WordsProvider {
this.focused = true; this.focused = true;
this.ensureSelection(); this.ensureSelection();
if (!wasFocused) { 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 { 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( 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(', ')
); );
} }

@ -95,40 +95,21 @@
const exportFormats: WordsExportFormat[] = ['json', 'text', 'html', 'markdown']; const exportFormats: WordsExportFormat[] = ['json', 'text', 'html', 'markdown'];
const importFormats: WordsImportFormat[] = ['json', 'text', 'html', 'markdown']; const importFormats: WordsImportFormat[] = ['json', 'text', 'html', 'markdown'];
const toolbarChoices: ToolbarChoice[] = ['none', 'minimal', 'formatting', 'full', 'custom']; 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<WordsToolbarPreset, WordsToolbarGroupConfig[]> = { const toolbarPresetGroups: Record<WordsToolbarPreset, WordsToolbarGroupConfig[]> = {
minimal: [['text-menu']], minimal: [['history-menu']],
formatting: [['text-menu', 'block-menu', 'align-menu', 'insert-menu', 'table-menu']], formatting: [['history-menu', 'insert-menu', 'link-menu']],
full: [ full: [['history-menu', 'insert-menu', 'link-menu', 'tools-menu'], ['find-replace']]
[
'history-menu',
'text-menu',
'block-menu',
'list-menu',
'align-menu',
'insert-menu',
'table-menu',
'link-menu',
'tools-menu'
],
['find-replace']
]
}; };
const toolbarCustomGroups: WordsToolbarGroupConfig[] = [ const toolbarCustomGroups: WordsToolbarGroupConfig[] = [
[ ['history-menu', 'insert-menu', 'link-menu', 'tools-menu'],
'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'
],
['find-replace'] ['find-replace']
]; ];
const demoToolbarFamilyItems: Record< const demoToolbarFamilyItems: Record<

Loading…
Cancel
Save

Powered by TurnKey Linux.