|
|
|
|
@ -1,488 +0,0 @@
|
|
|
|
|
<script lang="ts">
|
|
|
|
|
/**
|
|
|
|
|
* Column bottom-`+` inserter.
|
|
|
|
|
*
|
|
|
|
|
* Renders a `+` button at the BOTTOM of every `[data-words-column]`
|
|
|
|
|
* inside the editor — both for empty and populated columns. Click
|
|
|
|
|
* opens a dropdown of block types from `defaultWordsSchema.insertable()`;
|
|
|
|
|
* picking an entry appends a new block at the end of the column's
|
|
|
|
|
* `children` array, with a trailing paragraph added when the new
|
|
|
|
|
* block is atomic (so the caret has a place to land).
|
|
|
|
|
*
|
|
|
|
|
* Architecture choice: the inserter dispatches a single
|
|
|
|
|
* `insertBlockInColumn` engine command. That op returns a
|
|
|
|
|
* `WordsOperationResult` carrying BOTH the doc mutation AND the
|
|
|
|
|
* post-insert selection in one transaction — canonical Tiptap-style.
|
|
|
|
|
* Earlier iterations used `updateBlock` (patch the columns array)
|
|
|
|
|
* followed by a deferred `setSelection`; those two ticks raced
|
|
|
|
|
* against the engine's own `restoreDomSelection`, producing a stale
|
|
|
|
|
* caret. Combined with DropdownMenu's default focus-return-to-trigger,
|
|
|
|
|
* the next Space keystroke re-activated the `+` button and fired a
|
|
|
|
|
* phantom repeat-insert. Both pathologies disappear with the
|
|
|
|
|
* single-transaction op + `onCloseAutoFocus={(e) => e.preventDefault()}`
|
|
|
|
|
* on the dropdown Content (which suppresses the focus return).
|
|
|
|
|
*
|
|
|
|
|
* Positioning: same frame-relative measurement as
|
|
|
|
|
* `words-block-gutter`. The button sits at column bottom anchored to
|
|
|
|
|
* the column's bounding rect; re-measures on scroll (capture) +
|
|
|
|
|
* after each `api.document` change.
|
|
|
|
|
*/
|
|
|
|
|
import { tick } from 'svelte';
|
|
|
|
|
import type { ActiveDom } from '$adom';
|
|
|
|
|
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
|
|
|
|
|
import { Plus } from '$uix/eidos/components/icon';
|
|
|
|
|
import {
|
|
|
|
|
defaultWordsSchema,
|
|
|
|
|
type ProviderSnippetProps,
|
|
|
|
|
type WordsBlock,
|
|
|
|
|
type WordsBlockMenuEntry,
|
|
|
|
|
type ColumnsBlock
|
|
|
|
|
} from '$soma/components/words';
|
|
|
|
|
|
|
|
|
|
let {
|
|
|
|
|
api,
|
|
|
|
|
content,
|
|
|
|
|
dom
|
|
|
|
|
}: {
|
|
|
|
|
api: ProviderSnippetProps;
|
|
|
|
|
content: HTMLElement;
|
|
|
|
|
dom: ActiveDom;
|
|
|
|
|
} = $props();
|
|
|
|
|
|
|
|
|
|
const frame = $derived(content.closest('[data-words]') as HTMLElement | null);
|
|
|
|
|
|
|
|
|
|
interface ColumnSlot {
|
|
|
|
|
readonly columnsIdx: number;
|
|
|
|
|
readonly colIdx: number;
|
|
|
|
|
/** True when the column has only a single empty paragraph — drives
|
|
|
|
|
* the visual size (large centered button for invitation vs. small
|
|
|
|
|
* bottom button for "add more"). Also drives the replace-in-place
|
|
|
|
|
* vs append semantics in `handleInsert`. */
|
|
|
|
|
readonly isEmpty: boolean;
|
|
|
|
|
readonly rect: { top: number; left: number; width: number; height: number };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let slots = $state<readonly ColumnSlot[]>([]);
|
|
|
|
|
let openColumnKey = $state<string | null>(null);
|
|
|
|
|
let hoveredColumnKey = $state<string | null>(null);
|
|
|
|
|
let raf = 0;
|
|
|
|
|
// Re-entrancy guard. The dropdown can fire `onSelect` twice in a
|
|
|
|
|
// row (pointerup + keyboard activation, or a single-click that gets
|
|
|
|
|
// re-emitted when the menu's portal repositions after the document
|
|
|
|
|
// mutation). Without the guard the user reported the same heading
|
|
|
|
|
// appearing 3× from a single click. The guard is cleared after a
|
|
|
|
|
// full tick so the next legitimate click is unaffected.
|
|
|
|
|
let busy = false;
|
|
|
|
|
|
|
|
|
|
function isColumnEmpty(col: Element): boolean {
|
|
|
|
|
const blocks = Array.from(col.children).filter(
|
|
|
|
|
(el) => el instanceof HTMLElement && el.matches("[data-words-node='block']")
|
|
|
|
|
) as HTMLElement[];
|
|
|
|
|
if (blocks.length !== 1) return false;
|
|
|
|
|
const only = blocks[0];
|
|
|
|
|
if (only.getAttribute('data-words-block') !== 'paragraph') return false;
|
|
|
|
|
const text = only.textContent ?? '';
|
|
|
|
|
return text.replace(//g, '').length === 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readColumnPath(col: Element): { columnsIdx: number; colIdx: number } | null {
|
|
|
|
|
// Any block inside the column carries `data-words-path="C.K.…"` —
|
|
|
|
|
// the first two segments are the columns block's top-level index
|
|
|
|
|
// and the column's position. We read it off the first child block
|
|
|
|
|
// (always present after the engine's normalize seeds an empty
|
|
|
|
|
// paragraph) to avoid threading the path explicitly through DOM.
|
|
|
|
|
const firstBlock = Array.from(col.children).find(
|
|
|
|
|
(el) => el instanceof HTMLElement && el.matches("[data-words-node='block']")
|
|
|
|
|
) as HTMLElement | undefined;
|
|
|
|
|
const path = firstBlock?.getAttribute('data-words-path');
|
|
|
|
|
if (!path) return null;
|
|
|
|
|
const parts = path.split('.').map((s) => Number.parseInt(s, 10));
|
|
|
|
|
if (parts.length < 2 || parts.some((n) => !Number.isFinite(n))) return null;
|
|
|
|
|
return { columnsIdx: parts[0], colIdx: parts[1] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function relocate() {
|
|
|
|
|
if (!frame) {
|
|
|
|
|
slots = [];
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const fr = frame.getBoundingClientRect();
|
|
|
|
|
const cr = content.getBoundingClientRect();
|
|
|
|
|
const cols = Array.from(content.querySelectorAll('[data-words-column]')) as HTMLElement[];
|
|
|
|
|
const next: ColumnSlot[] = [];
|
|
|
|
|
for (const col of cols) {
|
|
|
|
|
const ids = readColumnPath(col);
|
|
|
|
|
if (!ids) continue;
|
|
|
|
|
const br = col.getBoundingClientRect();
|
|
|
|
|
if (br.bottom < cr.top + 4 || br.top > cr.bottom - 4) continue;
|
|
|
|
|
const empty = isColumnEmpty(col);
|
|
|
|
|
// Empty column → cover the whole column rect (large centered
|
|
|
|
|
// `+` is the invitation to start). Non-empty → only a thin
|
|
|
|
|
// strip at the bottom of the column. The wrap has
|
|
|
|
|
// pointer-events: none AND its trigger overlays the wrap area,
|
|
|
|
|
// so a tall non-empty wrap WOULD cover content blocks AND
|
|
|
|
|
// (more importantly) the auto-added trailing paragraph that
|
|
|
|
|
// follows atomic inserts (image / divider). Clicks intended
|
|
|
|
|
// for that paragraph would hit the `+` trigger instead,
|
|
|
|
|
// opening the dropdown when the user just wanted to type.
|
|
|
|
|
// The strip height matches the trigger button (~26px) + a
|
|
|
|
|
// small padding so it sits cleanly at the column bottom.
|
|
|
|
|
const stripH = 28;
|
|
|
|
|
const top = empty ? br.top - fr.top : br.bottom - fr.top - stripH;
|
|
|
|
|
const height = empty ? br.height : stripH;
|
|
|
|
|
next.push({
|
|
|
|
|
columnsIdx: ids.columnsIdx,
|
|
|
|
|
colIdx: ids.colIdx,
|
|
|
|
|
isEmpty: empty,
|
|
|
|
|
rect: {
|
|
|
|
|
top,
|
|
|
|
|
left: br.left - fr.left,
|
|
|
|
|
width: br.width,
|
|
|
|
|
height
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
slots = next;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function schedule() {
|
|
|
|
|
if (raf) return;
|
|
|
|
|
raf = requestAnimationFrame(() => {
|
|
|
|
|
raf = 0;
|
|
|
|
|
relocate();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
const disposers = [dom.listen(content, 'scroll', schedule, { capture: true })];
|
|
|
|
|
schedule();
|
|
|
|
|
return () => {
|
|
|
|
|
for (const d of disposers) d?.();
|
|
|
|
|
if (raf) cancelAnimationFrame(raf);
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Re-measure after edits change which columns exist / shrink / grow.
|
|
|
|
|
$effect(() => {
|
|
|
|
|
void api.document;
|
|
|
|
|
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 {
|
|
|
|
|
return { type: 'paragraph', children: [{ type: 'text', text: '' }] } as WordsBlock;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function blockForEntry(entry: WordsBlockMenuEntry): WordsBlock | null {
|
|
|
|
|
// Mirror the gutter's collapsed UX: a single "Heading" entry creates
|
|
|
|
|
// H1 with a literal "Title" stub so the user sees what they got;
|
|
|
|
|
// a single "List" creates an unordered list seeded with a list-item.
|
|
|
|
|
// Picture also takes the placeholder shape so the inspector's
|
|
|
|
|
// URL field can drive the user to fill it.
|
|
|
|
|
if (entry.id === 'image') {
|
|
|
|
|
return { type: 'image', src: '' } as WordsBlock;
|
|
|
|
|
}
|
|
|
|
|
if (entry.id === 'heading') {
|
|
|
|
|
return {
|
|
|
|
|
type: 'heading',
|
|
|
|
|
level: 1,
|
|
|
|
|
children: [{ type: 'text', text: 'Title' }]
|
|
|
|
|
} as WordsBlock;
|
|
|
|
|
}
|
|
|
|
|
if (entry.id === 'list') {
|
|
|
|
|
return {
|
|
|
|
|
type: 'list',
|
|
|
|
|
kind: 'unordered',
|
|
|
|
|
items: [{ children: [{ type: 'text', text: 'List item' }] }]
|
|
|
|
|
} as WordsBlock;
|
|
|
|
|
}
|
|
|
|
|
if (entry.id === 'divider') {
|
|
|
|
|
return { type: 'divider' } as WordsBlock;
|
|
|
|
|
}
|
|
|
|
|
if (entry.id === 'table') {
|
|
|
|
|
// Registry creates a 3×3 empty table by default.
|
|
|
|
|
const created = entry.create();
|
|
|
|
|
return created as unknown as WordsBlock;
|
|
|
|
|
}
|
|
|
|
|
if (entry.id.startsWith('callout-')) {
|
|
|
|
|
const intent = entry.id.slice('callout-'.length);
|
|
|
|
|
return {
|
|
|
|
|
type: 'callout',
|
|
|
|
|
intent,
|
|
|
|
|
children: [emptyParagraph()]
|
|
|
|
|
} as WordsBlock;
|
|
|
|
|
}
|
|
|
|
|
// Fallback for registry-driven entries (paragraph, code-block, etc.).
|
|
|
|
|
const created = entry.create();
|
|
|
|
|
return created ? (created as unknown as WordsBlock) : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleInsert(slot: ColumnSlot, entry: WordsBlockMenuEntry) {
|
|
|
|
|
if (busy) return;
|
|
|
|
|
const newBlock = blockForEntry(entry);
|
|
|
|
|
if (!newBlock) return;
|
|
|
|
|
|
|
|
|
|
const colsBlock = api.document.children[slot.columnsIdx] as ColumnsBlock | undefined;
|
|
|
|
|
if (!colsBlock || colsBlock.type !== 'columns') return;
|
|
|
|
|
|
|
|
|
|
busy = true;
|
|
|
|
|
// Hard timeout to release the busy lock no matter what — without
|
|
|
|
|
// this, any path that doesn't reach the end of the function leaves
|
|
|
|
|
// the `+` button dead forever. 500ms is well beyond any legitimate
|
|
|
|
|
// re-fire window (covers two awaited ticks + the engine's
|
|
|
|
|
// restoreDomSelection tick).
|
|
|
|
|
const win = dom.getWindow();
|
|
|
|
|
const timer = win?.setTimeout(() => {
|
|
|
|
|
busy = false;
|
|
|
|
|
}, 500);
|
|
|
|
|
|
|
|
|
|
const isAtomic = newBlock.type === 'image' || newBlock.type === 'divider';
|
|
|
|
|
const newInnerIdx = slot.isEmpty ? 0 : colsBlock.columns[slot.colIdx]?.children.length ?? 0;
|
|
|
|
|
|
|
|
|
|
// Canonical Tiptap order — `editor.chain().focus().insertContent(...).run()`
|
|
|
|
|
// adapted to a dropdown trigger:
|
|
|
|
|
//
|
|
|
|
|
// 1. Close the dropdown FIRST — Svelte flushes it and FocusScope
|
|
|
|
|
// tears down (its `focusin` capture-listener that bounces
|
|
|
|
|
// focus back inside the menu disappears). `trapFocus={false}`
|
|
|
|
|
// on Content makes step-2 ticks short.
|
|
|
|
|
// 2. `await tick()` — lets Content unmount + cleanup propagate.
|
|
|
|
|
// 3. `api.focus()` — editor receives focus before any DOM
|
|
|
|
|
// mutation that could steal it.
|
|
|
|
|
// 4. `api.applyCommand(insertBlockInColumn)` — engine sets the
|
|
|
|
|
// model selection inside the new block AND schedules
|
|
|
|
|
// `restoreDomSelection` (via the provider fix in
|
|
|
|
|
// `applyCommandWithOptions`), which writes the caret into
|
|
|
|
|
// the already-focused editor.
|
|
|
|
|
//
|
|
|
|
|
// Without this order: the dropdown's trap intercepts our focus
|
|
|
|
|
// call; `restoreDomSelection` writes a selection on a non-focused
|
|
|
|
|
// element; the next selectionchange echo overwrites the model to
|
|
|
|
|
// the stale caret position; typing goes nowhere.
|
|
|
|
|
openColumnKey = null;
|
|
|
|
|
await tick();
|
|
|
|
|
api.focus();
|
|
|
|
|
api.applyCommand({
|
|
|
|
|
type: 'insertBlockInColumn',
|
|
|
|
|
columnsIdx: slot.columnsIdx,
|
|
|
|
|
colIdx: slot.colIdx,
|
|
|
|
|
block: newBlock as unknown as Readonly<Record<string, unknown>>
|
|
|
|
|
});
|
|
|
|
|
// Capture the engine's INTENDED selection IMMEDIATELY after the
|
|
|
|
|
// command runs (before any selectionchange echo can overwrite it).
|
|
|
|
|
// `api.focus()` above lands focus on the contenteditable; that
|
|
|
|
|
// triggers a `selectionchange` event that fires
|
|
|
|
|
// `syncSelectionFromDom`, which reads the stale DOM caret (doc
|
|
|
|
|
// start, where focus defaulted) and overwrites our model's
|
|
|
|
|
// post-insert selection. By the time the engine's automatic
|
|
|
|
|
// `restoreDomSelection` runs (via tick), the model is already
|
|
|
|
|
// corrupted.
|
|
|
|
|
const intended = api.selection;
|
|
|
|
|
// Defeat the echo via setTimeout(0): runs AFTER the
|
|
|
|
|
// selectionchange handler, AFTER the engine's own restore (which
|
|
|
|
|
// would have written the corrupted selection). Re-set the model
|
|
|
|
|
// to the intended selection — `setSelection` schedules its own
|
|
|
|
|
// `restoreDomSelection` via tick, writing the correct caret AFTER
|
|
|
|
|
// all interference has settled.
|
|
|
|
|
if (win && intended) {
|
|
|
|
|
win.setTimeout(() => {
|
|
|
|
|
api.setSelection(intended);
|
|
|
|
|
if (isAtomic) {
|
|
|
|
|
api.selectAtomicBlock(slot.columnsIdx, [
|
|
|
|
|
slot.columnsIdx,
|
|
|
|
|
slot.colIdx,
|
|
|
|
|
newInnerIdx
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}, 0);
|
|
|
|
|
} else if (isAtomic) {
|
|
|
|
|
api.selectAtomicBlock(slot.columnsIdx, [
|
|
|
|
|
slot.columnsIdx,
|
|
|
|
|
slot.colIdx,
|
|
|
|
|
newInnerIdx
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (win && timer !== undefined) win.clearTimeout(timer);
|
|
|
|
|
busy = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Same collapsed entry set as the gutter for visual consistency.
|
|
|
|
|
const COLLAPSE_GROUPS: Record<string, { id: string; label: string }> = {
|
|
|
|
|
'heading-1': { id: 'heading', label: 'Heading' },
|
|
|
|
|
'unordered-list': { id: 'list', label: 'List' }
|
|
|
|
|
};
|
|
|
|
|
const COLLAPSED_DROPS = new Set(['heading-2', 'heading-3', 'ordered-list', 'check-list']);
|
|
|
|
|
const inserts = $derived.by(() => {
|
|
|
|
|
const all = defaultWordsSchema.insertable();
|
|
|
|
|
return all
|
|
|
|
|
.filter((entry) => !COLLAPSED_DROPS.has(entry.id))
|
|
|
|
|
.map((entry) => {
|
|
|
|
|
const replacement = COLLAPSE_GROUPS[entry.id];
|
|
|
|
|
if (!replacement) return entry;
|
|
|
|
|
return { ...entry, id: replacement.id, label: replacement.label };
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function keyFor(slot: ColumnSlot): string {
|
|
|
|
|
return `${slot.columnsIdx}-${slot.colIdx}`;
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
{#if frame}
|
|
|
|
|
{#each slots as slot (keyFor(slot))}
|
|
|
|
|
{@const k = keyFor(slot)}
|
|
|
|
|
<!--
|
|
|
|
|
Positioning:
|
|
|
|
|
- Empty column → button centered (vertically AND horizontally)
|
|
|
|
|
inside the column. Big invitation to insert something.
|
|
|
|
|
- Non-empty column → button at the bottom seam of the column,
|
|
|
|
|
horizontally centered. Reads as "add another block here".
|
|
|
|
|
Both share the same dropdown content + the same `handleInsert`.
|
|
|
|
|
-->
|
|
|
|
|
<div
|
|
|
|
|
data-words-column-inserter
|
|
|
|
|
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;"
|
|
|
|
|
>
|
|
|
|
|
<DropdownMenu
|
|
|
|
|
open={openColumnKey === k}
|
|
|
|
|
onOpenChange={(v: boolean) => {
|
|
|
|
|
openColumnKey = v ? k : openColumnKey === k ? null : openColumnKey;
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<DropdownMenu.Trigger
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="xs"
|
|
|
|
|
iconOnly
|
|
|
|
|
aria-label="Insertar bloque en columna"
|
|
|
|
|
data-words-column-inserter-trigger
|
|
|
|
|
>
|
|
|
|
|
{#snippet icon()}<Plus />{/snippet}
|
|
|
|
|
</DropdownMenu.Trigger>
|
|
|
|
|
<DropdownMenu.Portal>
|
|
|
|
|
<DropdownMenu.Content
|
|
|
|
|
side="bottom"
|
|
|
|
|
align="center"
|
|
|
|
|
data-words-column-inserter-menu
|
|
|
|
|
trapFocus={false}
|
|
|
|
|
onCloseAutoFocus={(e) => {
|
|
|
|
|
// `trapFocus={false}` above means FocusScope skips
|
|
|
|
|
// installing its capture-phase `focusin` bouncer —
|
|
|
|
|
// `api.focus()` in handleInsert actually sticks on
|
|
|
|
|
// the contenteditable.
|
|
|
|
|
// `preventDefault` here still blocks the dropdown
|
|
|
|
|
// from auto-returning focus to the `+` trigger (Space
|
|
|
|
|
// on a focused button = phantom repeat-insert).
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{#each inserts as entry (entry.id)}
|
|
|
|
|
<DropdownMenu.Item onSelect={() => handleInsert(slot, entry)}>
|
|
|
|
|
{entry.label}
|
|
|
|
|
</DropdownMenu.Item>
|
|
|
|
|
{/each}
|
|
|
|
|
</DropdownMenu.Content>
|
|
|
|
|
</DropdownMenu.Portal>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
</div>
|
|
|
|
|
{/each}
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
/* Overlay layer — non-interactive so caret/text events pass through
|
|
|
|
|
to the contenteditable below. The trigger button alone re-enables
|
|
|
|
|
pointer events via the explicit override below. */
|
|
|
|
|
[data-words-column-inserter] {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
}
|
|
|
|
|
/* Empty column: center the button vertically too — feels like an
|
|
|
|
|
invitation in the middle of the empty rectangle. Always visible
|
|
|
|
|
so the user knows where to start. */
|
|
|
|
|
[data-words-column-inserter][data-empty] {
|
|
|
|
|
align-items: center;
|
|
|
|
|
}
|
|
|
|
|
/* Non-empty column: pin to the bottom seam — "add another block
|
|
|
|
|
below the current content". A small inset so the trigger doesn't
|
|
|
|
|
collide with the column's border line. */
|
|
|
|
|
[data-words-column-inserter]:not([data-empty]) {
|
|
|
|
|
align-items: flex-end;
|
|
|
|
|
padding-block-end: var(--space-1);
|
|
|
|
|
}
|
|
|
|
|
/* Empty column: always visible (full opacity) as invitation. */
|
|
|
|
|
[data-words-column-inserter][data-empty] :global([data-words-column-inserter-trigger]) {
|
|
|
|
|
pointer-events: auto;
|
|
|
|
|
opacity: 0.65;
|
|
|
|
|
transition: opacity 120ms ease;
|
|
|
|
|
}
|
|
|
|
|
[data-words-column-inserter][data-empty][data-hovered]
|
|
|
|
|
:global([data-words-column-inserter-trigger]),
|
|
|
|
|
[data-words-column-inserter][data-empty]
|
|
|
|
|
:global([data-words-column-inserter-trigger][data-state='open']) {
|
|
|
|
|
opacity: 1;
|
|
|
|
|
}
|
|
|
|
|
/* Non-empty column: HIDDEN AND non-interactive by default so the
|
|
|
|
|
button doesn't cover the last block's clickable area (a user
|
|
|
|
|
trying to click at the end of the last paragraph would otherwise
|
|
|
|
|
hit the button instead of placing the caret). Both `opacity: 0`
|
|
|
|
|
AND `pointer-events: none` are needed — opacity alone leaves the
|
|
|
|
|
click target alive at zero opacity. Appears on hover OR when the
|
|
|
|
|
user already opened the menu. */
|
|
|
|
|
[data-words-column-inserter]:not([data-empty]) :global([data-words-column-inserter-trigger]) {
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
opacity: 0;
|
|
|
|
|
transition: opacity 120ms ease;
|
|
|
|
|
}
|
|
|
|
|
/* `: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]),
|
|
|
|
|
[data-words-column-inserter]:not([data-empty])
|
|
|
|
|
:global([data-words-column-inserter-trigger][data-state='open']) {
|
|
|
|
|
pointer-events: auto;
|
|
|
|
|
opacity: 0.65;
|
|
|
|
|
}
|
|
|
|
|
[data-words-column-inserter]:not([data-empty])[data-hovered]
|
|
|
|
|
:global([data-words-column-inserter-trigger]:hover),
|
|
|
|
|
[data-words-column-inserter]:not([data-empty])
|
|
|
|
|
:global([data-words-column-inserter-trigger][data-state='open']) {
|
|
|
|
|
opacity: 1;
|
|
|
|
|
}
|
|
|
|
|
</style>
|