diff --git a/src/uix/eidos/components/words/words-block-handle.svelte b/src/uix/eidos/components/words/words-block-handle.svelte index 6cb1e7f6e..ecd25def2 100644 --- a/src/uix/eidos/components/words/words-block-handle.svelte +++ b/src/uix/eidos/components/words/words-block-handle.svelte @@ -42,72 +42,27 @@ }); /** - * Detect drawer side so the grip auto-flips to the OPPOSITE margin. - * Drawer on right (default) → grip on block's left. Drawer on left - * → grip on block's right (so it doesn't fight the drawer's gutter). - * Read from the DOM each measure — cheap, and reactive to layout - * changes (user toggling side prop). + * Position the grip at the BLOCK's lower-left corner. Per the + * gutter redesign: no separate rail column; the handle overlays + * the block directly, hovering at the lower-left corner with a + * small inset so it doesn't clip the block's border. */ - function detectDrawerSide(): 'left' | 'right' { - const drawer = document.querySelector('[data-words-drawer]'); - return drawer?.getAttribute('data-side') === 'left' ? 'left' : 'right'; - } - - /** - * Read the editor host's CSS variables so the grip lands centered - * inside the dedicated rail column. Layout (right-drawer default): - * [rail-px] [rail-gap] [content...] [drawer] - * Block's `rect.left` is the editor card's left + content-px. The - * rail column sits to the LEFT of the editor card by - * (rail-px + rail-gap), so the rail's center in viewport coords - * is `block.rect.left - content-px - rail-gap - rail-px/2`. - */ - function cssToPx(raw: string, rootFontSize: number): number { - const trimmed = raw.trim(); - const num = parseFloat(trimmed); - if (!isFinite(num)) return 0; - if (trimmed.endsWith('rem')) return num * rootFontSize; - if (trimmed.endsWith('em')) return num * rootFontSize; - // 'px', unitless, calc()-resolved → already in px (or treat as px) - return num; - } - - function rememberRailMetrics(el: HTMLElement): { - railPx: number; - railGap: number; - contentPx: number; - } { - const host = el.closest('[data-words]') ?? document.documentElement; - const cs = getComputedStyle(host); - const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; - const railPx = cssToPx(cs.getPropertyValue('--_words-rail-px'), rootFontSize) || 32; - const railGap = cssToPx(cs.getPropertyValue('--_words-rail-gap'), rootFontSize) || 8; - const contentPx = cssToPx(cs.getPropertyValue('--_words-content-px'), rootFontSize) || 16; - return { railPx, railGap, contentPx }; - } - function measureFromEl(el: HTMLElement | null) { if (!el) { position = null; return; } const rect = el.getBoundingClientRect(); - const side = detectDrawerSide(); - const { railPx, railGap, contentPx } = rememberRailMetrics(el); - // Grip is 28×28 (`1.75rem`) → centering offset is 14. - const gripHalf = 14; - // Center of the rail column (relative to block's text-left): - // right-drawer: rail is BEFORE the editor box. - // rail center = block.rect.left - contentPx - railGap - railPx/2 - // left-drawer: rail is AFTER the editor box. - // rail center = block.rect.right + contentPx + railGap + railPx/2 - const railCenter = - side === 'left' - ? rect.right + contentPx + railGap + railPx / 2 - : rect.left - contentPx - railGap - railPx / 2; + // Grip is 28×28 — small inset (4px) from the block's bottom- + // left so it stays inside the block's hit area without + // touching the border. For very tall blocks the grip lives at + // the bottom-left; for very short blocks the grip extends a + // few pixels above the bottom — still readable. + const inset = 4; + const gripSize = 28; position = { - top: rect.top + rect.height / 2 - gripHalf, - left: railCenter - gripHalf + top: rect.bottom - gripSize - inset, + left: rect.left + inset }; } diff --git a/src/uix/eidos/components/words/words-block-inserter.svelte b/src/uix/eidos/components/words/words-block-inserter.svelte index 9e11a519d..c4c7a9e1d 100644 --- a/src/uix/eidos/components/words/words-block-inserter.svelte +++ b/src/uix/eidos/components/words/words-block-inserter.svelte @@ -33,92 +33,41 @@ readonly right: number; }; - // Seam under the cursor (snapped to the closest one within - // `SEAM_RANGE_PX`). null while the cursor is too far from any seam. + // Seam under the cursor. Set when the cursor is in the bottom + // portion of a top-level block; cleared otherwise. let seam = $state(null); - const SEAM_RANGE_PX = 18; - - function listBlockBoundaries(content: HTMLElement): Seam[] { - // Top-level blocks only — same predicate as the block-handle. - const blocks = Array.from( - content.querySelectorAll( - '[data-words-node="block"][data-words-path], [data-words-node="list"][data-words-path], [data-words-node="table"][data-words-path]' - ) - ).filter((el) => !(el.getAttribute('data-words-path') ?? '').includes('.')); - if (!blocks.length) return []; - const seams: 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: 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, - left: Math.min(a.left, b.left), - right: Math.max(a.right, b.right) - }); - } - // "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, - y: last.bottom, - left: last.left, - right: last.right - }); - return seams; - } - /** - * The "+" only shows up when the cursor is OUTSIDE the content box - * (i.e. in the rail column) AND vertically positioned in a gap - * between two blocks. Hovering over a block — either on its text - * or on the rail aligned with the block — surfaces the grip - * instead (block-handle is the actor there). Per user spec: - * "el + solo aparece en la banda entre bloques". + * Per the gutter redesign: the "+" overlays the block ITSELF when + * the cursor is inside the block and in its BOTTOM portion (last + * ~25% of block height, capped at 24px). The seam anchors at the + * block's bottom edge with the wrapper spanning the block's width + * so the "+" button lands at horizontal center. Hovering the + * upper / middle of a block surfaces nothing — that's the + * editing area. Hovering its bottom edge surfaces the "+". */ function findSeamForCursor(e: MouseEvent, content: HTMLElement): Seam | null { - const contentRect = content.getBoundingClientRect(); - // Cursor inside the content's horizontal extent → user is reading - // text or is in the editor body, not the rail. No "+". - if (e.clientX >= contentRect.left && e.clientX <= contentRect.right) { - 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], [data-words-node="list"][data-words-path], [data-words-node="table"][data-words-path]' ) ).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; - } - // 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. - const seams = listBlockBoundaries(content); - let best: Seam | null = null; - let bestDelta = SEAM_RANGE_PX; - for (const s of seams) { - const delta = Math.abs(s.y - e.clientY); - if (delta < bestDelta) { - best = s; - bestDelta = delta; - } + for (let i = 0; i < blocks.length; i++) { + const r = blocks[i].getBoundingClientRect(); + if (e.clientX < r.left || e.clientX > r.right) continue; + if (e.clientY < r.top || e.clientY > r.bottom) continue; + // Bottom zone: last 25% of block height, capped at 24px so + // short blocks don't get a disproportionate trigger area. + const triggerZone = Math.min(r.height * 0.25, 24); + if (e.clientY < r.bottom - triggerZone) continue; + return { + insertIndex: i + 1, + y: r.bottom, + left: r.left, + right: r.right + }; } - return best; + return null; } $effect(() => { @@ -252,26 +201,15 @@ seam = null; } - /** - * Mirror the drawer's side so the "+" button auto-flips to the - * OPPOSITE gutter (CSS reads `data-side` to swap left:-28 vs - * right:-28). Drawer right (default) → "+" on left margin; - * drawer left → "+" on right margin. - */ - const drawerSide = $derived.by(() => { - // Read reactively each time seam changes — re-evaluating the - // DOM is cheap and the side rarely flips during a session. - if (!seam) return 'right' as const; - const drawer = document.querySelector('[data-words-drawer]'); - return drawer?.getAttribute('data-side') === 'left' ? 'left' : 'right'; - }); + // drawerSide flip removed — the "+" lives INSIDE the block now, + // centered on its width, so it doesn't depend on which side the + // drawer sits on. {#if seam}
diff --git a/src/uix/eidos/components/words/words.css b/src/uix/eidos/components/words/words.css index 4370e9c9d..6a4894eed 100644 --- a/src/uix/eidos/components/words/words.css +++ b/src/uix/eidos/components/words/words.css @@ -10,13 +10,10 @@ background + dot pattern), with a small gap before the editor. The grip + "+" are positioned by JS at viewport coordinates that land inside this column. */ - --_words-rail-px: 2rem; - --_words-rail-gap: 0.5rem; - /* Flat light silver band with a slightly darker edge toward the - * editor card. Tokens declared in `lib/recipes/base.ts > - * words.rail-bg / rail-border`. Fixed tone — see token comment. */ - --_words-rail-bg: var(--words-rail-bg); - --_words-rail-border: var(--words-rail-border); + /* Gutter rail removed — handle and inserter now overlay the block + * itself (handle at lower-left corner, "+" at center-bottom when + * the cursor is near the block's bottom edge). No separate rail + * column, no extra grid space, no dot pattern. */ --_words-toolbar-px: var(--words-toolbar-px-md); --_words-toolbar-py: var(--words-toolbar-py-md); --_words-button-size: var(--words-button-size-md); @@ -141,65 +138,31 @@ } /* ── Layout ────────────────────────────────────────────────────────────── - The editor host is a grid: rail | gap | (toolbar/content/status) | - drawer. The rail is a dedicated column OUTSIDE the editor card that - visually holds the block-handle grip + inserter "+" overlays — with - its own grey background and dotted pattern, separated from the - editor by a small gap mirroring the drawer's gap on the other side. - When `` the columns flip. Children without - a grid-area placement (BubbleMenu / SlashMenu / HiddenInput / - find-replace) are absolute-positioned and don't participate. */ + Two-column grid: content | drawer (or drawer | content when + side='left'). No more rail column — the gutter overlays (block- + handle, block-inserter) now position themselves DIRECTLY on top of + the block at viewport coordinates (lower-left corner for the grip, + center-bottom for the "+"). Children without a grid-area placement + (BubbleMenu / SlashMenu / HiddenInput / find-replace) are absolute- + positioned and don't participate. */ [data-words]:has([data-words-drawer]) { display: grid; - grid-template-columns: - var(--_words-rail-px) - var(--_words-rail-gap) - minmax(0, 1fr) - max-content; + grid-template-columns: minmax(0, 1fr) max-content; grid-template-rows: auto minmax(0, 1fr) auto; grid-template-areas: - 'rail . toolbar drawer' - 'rail . content drawer' - 'rail . status drawer'; + 'toolbar drawer' + 'content drawer' + 'status drawer'; column-gap: 0; } -/* Left-side drawer — drawer first, then rail at the END mirroring - the right-side layout (rail is on the SAME side as the grip, - opposite to the drawer). */ +/* Left-side drawer — drawer first, then content. */ [data-words]:has([data-words-drawer][data-side='left']) { - grid-template-columns: - max-content - minmax(0, 1fr) - var(--_words-rail-gap) - var(--_words-rail-px); + grid-template-columns: max-content minmax(0, 1fr); grid-template-areas: - 'drawer toolbar . rail' - 'drawer content . rail' - 'drawer status . rail'; -} - -/* Rail visual — pseudo-element on the editor host that fills the - `rail` grid area with a flat silver band. The edge closest to the - editor card carries a slightly darker hairline so the rail reads as - a separate gutter. Grip + "+" overlays are JS-positioned via - viewport coordinates that land here. */ -[data-words]:has([data-words-drawer])::before { - content: ''; - grid-area: rail; - background-color: var(--_words-rail-bg); - border-inline-end: var(--words-border-width) solid var(--_words-rail-border); - border-radius: var(--_words-radius) 0 0 var(--_words-radius); - pointer-events: none; -} - -/* Left-side drawer flips the layout (drawer | content | gap | rail), - so the rail's "darker edge" is on the inline-START (the side facing - the editor card on its right). */ -[data-words]:has([data-words-drawer][data-side='left'])::before { - border-inline-end: 0; - border-inline-start: var(--words-border-width) solid var(--_words-rail-border); - border-radius: 0 var(--_words-radius) var(--_words-radius) 0; + 'drawer toolbar' + 'drawer content' + 'drawer status'; } [data-words]:has([data-words-drawer]) > [data-words-toolbar] { @@ -811,8 +774,11 @@ /* Distinct color against the dotted rail. Accent on hover. */ color: var(--words-command-color); cursor: grab; - opacity: 0.75; - transition: opacity 120ms ease, background-color 120ms ease, color 120ms ease; + /* The handle is shown via JS on block hover (position set when + * cursor is over a top-level block). Full opacity by default — + * the visibility is binary, not faded. */ + opacity: 1; + transition: background-color 120ms ease, color 120ms ease; z-index: var(--z-index-popover); } @@ -907,14 +873,13 @@ [data-words-block-inserter-button] { pointer-events: auto; position: absolute; - /* Land the "+" centered in the dedicated rail column to the left - of the editor box. Same metrics as the block-handle's positioning: - block.rect.left − content-px − rail-gap − rail-px/2 − 12 (button half). - The inserter wrapper sits at block.rect.left, so we offset by - −(content-px + rail-gap + rail-px/2 + 12). */ - left: calc( - -1 * (var(--_words-content-px) + var(--_words-rail-gap) + var(--_words-rail-px) / 2) - 12px - ); + /* Centered horizontally on the block (the wrapper spans the block + width; the button uses left:50% + translateX(-50%) to land in + the middle). Vertically sits at the wrapper's center + (translateY(-50%) on the wrapper places it at the block's + bottom edge). */ + left: 50%; + transform: translateX(-50%); display: inline-flex; align-items: center; justify-content: center; @@ -933,44 +898,27 @@ border-color 120ms ease; } -/* No :hover state on the inserter button. The block-handle is also - * passive; the gutter overlays should read as ambient affordances, - * not interactive buttons that "light up". (Removed earlier accent - * solid hover rule per spec.) */ - -/* When the drawer is on the LEFT side, flip the "+" button so it - lands in the rail column on the editor's RIGHT side. Same metric - math as the right-drawer case, just on the inline-end side. */ -[data-words-block-inserter][data-side='left'] [data-words-block-inserter-button] { - left: auto; - right: calc( - -1 * (var(--_words-content-px) + var(--_words-rail-gap) + var(--_words-rail-px) / 2) - 12px - ); -} +/* No :hover state on the inserter button — gutter overlays read as + * ambient affordances, not buttons that "light up". */ [data-words-block-inserter-line] { pointer-events: auto; flex: 1 1 auto; block-size: 2px; border-radius: 1px; - background: color-mix(in srgb, var(--_words-accent-solid) 35%, transparent); - opacity: 0; - transition: opacity 120ms ease; -} - -[data-words-block-inserter]:hover [data-words-block-inserter-line] { - opacity: 1; + /* Solid accent — no transparency, no blur. Per spec: "la line + * debe de ser mas solida sin blur". */ + background: var(--_words-accent-solid); + opacity: 0.9; } /* Drop-target mode: while a block is being dragged the inserter - doubles as the drop indicator. The line stays prominent (no hover - needed) and grows a touch so the user can see exactly where the - block will land. */ + doubles as the drop indicator. The line grows a touch so the user + can see exactly where the block will land. No box-shadow halo — + keep it solid + sharp (per spec). */ [data-words-block-inserter][data-mode='drop'] [data-words-block-inserter-line] { opacity: 1; block-size: 3px; - background: var(--_words-accent-solid); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--_words-accent-solid) 25%, transparent); } /* Grabbed state on the block-handle grip — visible when the user has