@ -64,6 +64,7 @@
let slots = $state< readonly ColumnSlot [ ] > ([]);
let slots = $state< readonly ColumnSlot [ ] > ([]);
let openColumnKey = $state< string | null > (null);
let openColumnKey = $state< string | null > (null);
let hoveredColumnKey = $state< string | null > (null);
let raf = 0;
let raf = 0;
// Re-entrancy guard. The dropdown can fire `onSelect` twice in a
// Re-entrancy guard. The dropdown can fire `onSelect` twice in a
// row (pointerup + keyboard activation, or a single-click that gets
// row (pointerup + keyboard activation, or a single-click that gets
@ -152,6 +153,39 @@
schedule();
schedule();
});
});
// Track which column the mouse is over — `pointer-events: none` on
// the inserter wrap is required so caret/text events pass through to
// the contenteditable below, but it ALSO disables CSS `:hover`
// detection on the wrap (the browser can't dispatch `mouseover` to a
// pointer-events:none element). So we listen on the column elements
// directly and stash the key in `hoveredColumnKey` — the wrap then
// reveals its trigger via `data-hovered={ key === hoveredColumnKey } `.
$effect(() => {
// Re-bind when slots change (so we attach to fresh column elements).
void slots;
if (!content) return;
const cols = Array.from(content.querySelectorAll('[data-words-column]')) as HTMLElement[];
const disposers: Array< () => void> = [];
for (const col of cols) {
const ids = readColumnPath(col);
if (!ids) continue;
const key = `${ ids . columnsIdx } -${ ids . colIdx } `;
disposers.push(
dom.listen(col, 'mouseenter', () => {
hoveredColumnKey = key;
})
);
disposers.push(
dom.listen(col, 'mouseleave', () => {
if (hoveredColumnKey === key) hoveredColumnKey = null;
})
);
}
return () => {
for (const d of disposers) d?.();
};
});
function emptyParagraph(): WordsBlock {
function emptyParagraph(): WordsBlock {
return { type : 'paragraph' , children : [{ type : 'text' , text : '' }] } as WordsBlock;
return { type : 'paragraph' , children : [{ type : 'text' , text : '' }] } as WordsBlock;
}
}
@ -326,6 +360,7 @@
< div
< div
data-words-column-inserter
data-words-column-inserter
data-empty={ slot . isEmpty ? '' : undefined }
data-empty={ slot . isEmpty ? '' : undefined }
data-hovered={ hoveredColumnKey === k ? '' : undefined }
style="position:absolute; top:{ slot . rect . top } px; left:{ slot . rect . left } px; width:{ slot . rect . width } px; height:{ slot . rect . height } px;"
style="position:absolute; top:{ slot . rect . top } px; left:{ slot . rect . left } px; width:{ slot . rect . width } px; height:{ slot . rect . height } px;"
>
>
< DropdownMenu
< DropdownMenu
@ -400,7 +435,8 @@
opacity: 0.65;
opacity: 0.65;
transition: opacity 120ms ease;
transition: opacity 120ms ease;
}
}
[data-words-column-inserter][data-empty]:hover :global([data-words-column-inserter-trigger]),
[data-words-column-inserter][data-empty][data-hovered]
:global([data-words-column-inserter-trigger]),
[data-words-column-inserter][data-empty]
[data-words-column-inserter][data-empty]
:global([data-words-column-inserter-trigger][data-state='open']) {
:global([data-words-column-inserter-trigger][data-state='open']) {
opacity: 1;
opacity: 1;
@ -417,14 +453,18 @@
opacity: 0;
opacity: 0;
transition: opacity 120ms ease;
transition: opacity 120ms ease;
}
}
[data-words-column-inserter]:not([data-empty]):hover
/* `:hover` on the wrap can't fire (wrap has pointer-events: none) so
we drive visibility via `data-hovered` (a JS-tracked attribute set
by listening to `mouseenter`/`mouseleave` on the column elements
themselves — see the matching $effect in the script block). */
[data-words-column-inserter]:not([data-empty])[data-hovered]
:global([data-words-column-inserter-trigger]),
:global([data-words-column-inserter-trigger]),
[data-words-column-inserter]:not([data-empty])
[data-words-column-inserter]:not([data-empty])
:global([data-words-column-inserter-trigger][data-state='open']) {
:global([data-words-column-inserter-trigger][data-state='open']) {
pointer-events: auto;
pointer-events: auto;
opacity: 0.65;
opacity: 0.65;
}
}
[data-words-column-inserter]:not([data-empty]):hover
[data-words-column-inserter]:not([data-empty])[data-hovered]
:global([data-words-column-inserter-trigger]:hover),
:global([data-words-column-inserter-trigger]:hover),
[data-words-column-inserter]:not([data-empty])
[data-words-column-inserter]:not([data-empty])
:global([data-words-column-inserter-trigger][data-state='open']) {
:global([data-words-column-inserter-trigger][data-state='open']) {