From ce9547326181240195e5bbb0497ca9966d44f562 Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 28 May 2026 03:03:06 +0200 Subject: [PATCH] feat(words): internal scroll for the content area + capture-phase scroll sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor body was unbounded vertically — long documents pushed the whole page instead of confining the scroll to the editor surface. Per spec: "la parte de la edicion deberia tener un scroll". Recipe: - Three new tokens `content-max-block-size-sm/md/lg` (50vh / 60vh / 70vh) mirror the existing `content-min-block-size-*`. Generated CSS regenerated. CSS: - `[data-words-content]` now carries `max-block-size: var(--_words-content-max-block-size)` and `overflow-y: auto`. The min-block-size baseline stays as the starting height for empty editors. When content grows past the cap, the body scrolls internally — the host card no longer balloons. - `scroll-padding-block: 1rem` hint so caret-into-view nudges leave breathing room. Block-handle / block-inserter / image-float-bar: - Switch scroll listeners from `window.addEventListener('scroll')` to `document.addEventListener('scroll', { capture: true })`. Scroll events don't bubble; the previous wiring only caught window scrolls. Now the gutter overlays (handle position, "+" snap target, image float bar anchor) re-measure when the user scrolls INSIDE the content area too. Verification: dev probe at /uix/components/words confirms `max-block-size: 600px`, `overflow-y: auto`, content scrollHeight (615px) > clientHeight (600px), `isInternallyScrollable: true` on a 1000px viewport (60vh of 1000 = 600). 467/467 tests pass across eidos + morfo + sema + soma/components/words. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../eidos/components/words/words-block-handle.svelte | 9 +++++++-- .../components/words/words-block-inserter.svelte | 8 ++++++-- .../components/words/words-image-float-bar.svelte | 8 ++++++-- src/uix/eidos/components/words/words.css | 12 ++++++++++++ src/uix/eidos/generated/base.css | 3 +++ src/uix/eidos/lib/recipes/base.ts | 3 +++ 6 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/uix/eidos/components/words/words-block-handle.svelte b/src/uix/eidos/components/words/words-block-handle.svelte index 863c1968f..d4b2516ad 100644 --- a/src/uix/eidos/components/words/words-block-handle.svelte +++ b/src/uix/eidos/components/words/words-block-handle.svelte @@ -220,12 +220,17 @@ const win = content.ownerDocument.defaultView; const onscroll = () => measureFromEl(hoverBlockEl); - win?.addEventListener('scroll', onscroll, { passive: true }); + // Capture-phase + on document so we also catch scrolls inside + // any scrollable ancestor (the content area itself now has + // overflow-y:auto). Scroll events do not bubble, so a plain + // `document.addEventListener('scroll')` without capture would + // only catch root-document scrolls. + document.addEventListener('scroll', onscroll, { capture: true, passive: true }); win?.addEventListener('resize', onscroll, { passive: true }); return () => { document.removeEventListener('mousemove', onmove); - win?.removeEventListener('scroll', onscroll); + document.removeEventListener('scroll', onscroll, { capture: true }); win?.removeEventListener('resize', onscroll); }; }); diff --git a/src/uix/eidos/components/words/words-block-inserter.svelte b/src/uix/eidos/components/words/words-block-inserter.svelte index 281d2731b..1e0d7136c 100644 --- a/src/uix/eidos/components/words/words-block-inserter.svelte +++ b/src/uix/eidos/components/words/words-block-inserter.svelte @@ -223,14 +223,18 @@ // on scroll and let the next mousemove re-snap. seam = null; }; - win?.addEventListener('scroll', onscroll, { passive: true }); + // Capture phase so we catch scrolls inside the content area + // (which now scrolls internally) — scroll events do not bubble, + // so plain document.addEventListener would only catch root + // scrolls. + document.addEventListener('scroll', onscroll, { capture: true, passive: true }); win?.addEventListener('resize', onscroll, { passive: true }); return () => { document.removeEventListener('mousemove', onmove); document.removeEventListener('dragover', ondragover); document.removeEventListener('drop', ondrop); - win?.removeEventListener('scroll', onscroll); + document.removeEventListener('scroll', onscroll, { capture: true }); win?.removeEventListener('resize', onscroll); }; }); diff --git a/src/uix/eidos/components/words/words-image-float-bar.svelte b/src/uix/eidos/components/words/words-image-float-bar.svelte index 2fe203339..bd8981896 100644 --- a/src/uix/eidos/components/words/words-image-float-bar.svelte +++ b/src/uix/eidos/components/words/words-image-float-bar.svelte @@ -63,12 +63,16 @@ const win = targetFigure.ownerDocument.defaultView; if (!win) return; const opts: AddEventListenerOptions = { passive: true }; - win.addEventListener('scroll', measure, opts); + // Capture-phase on document catches scrolls inside the content + // area (now overflow-y:auto) too — scroll events don't bubble + // so plain `win.addEventListener('scroll')` only catches the + // root document scroll. + document.addEventListener('scroll', measure, { capture: true, passive: true }); win.addEventListener('resize', measure, opts); const ro = new ResizeObserver(measure); ro.observe(targetFigure); return () => { - win.removeEventListener('scroll', measure, opts); + document.removeEventListener('scroll', measure, { capture: true }); win.removeEventListener('resize', measure, opts); ro.disconnect(); }; diff --git a/src/uix/eidos/components/words/words.css b/src/uix/eidos/components/words/words.css index 226feb64c..52d4a6f15 100644 --- a/src/uix/eidos/components/words/words.css +++ b/src/uix/eidos/components/words/words.css @@ -4,6 +4,7 @@ --_words-content-px: var(--words-content-px-md); --_words-content-py: var(--words-content-py-md); --_words-content-min-block-size: var(--words-content-min-block-size-md); + --_words-content-max-block-size: var(--words-content-max-block-size-md); /* Gutter reserved for the block-handle grip + inserter "+" overlay. Rendered as a SEPARATE column outside the editor box (its own background + dot pattern), with a small gap before the editor. @@ -87,6 +88,7 @@ --_words-content-px: var(--words-content-px-sm); --_words-content-py: var(--words-content-py-sm); --_words-content-min-block-size: var(--words-content-min-block-size-sm); + --_words-content-max-block-size: var(--words-content-max-block-size-sm); --_words-toolbar-px: var(--words-toolbar-px-sm); --_words-toolbar-py: var(--words-toolbar-py-sm); --_words-button-size: var(--words-button-size-sm); @@ -99,6 +101,7 @@ --_words-content-px: var(--words-content-px-lg); --_words-content-py: var(--words-content-py-lg); --_words-content-min-block-size: var(--words-content-min-block-size-lg); + --_words-content-max-block-size: var(--words-content-max-block-size-lg); --_words-toolbar-px: var(--words-toolbar-px-lg); --_words-toolbar-py: var(--words-toolbar-py-lg); --_words-button-size: var(--words-button-size-lg); @@ -1828,7 +1831,12 @@ } [data-words-content] { + /* Starting baseline keeps the editor at a sensible height when + * empty; max-block-size caps growth so long documents scroll + * INSIDE the content area instead of pushing the page. */ min-block-size: var(--_words-content-min-block-size); + max-block-size: var(--_words-content-max-block-size); + overflow-y: auto; padding-block: var(--_words-content-py); padding-inline: var(--_words-content-px); color: var(--words-content-color); @@ -1838,6 +1846,10 @@ caret-color: var(--_words-accent-solid); user-select: text; -webkit-user-select: text; + /* Hint to the browser that this is a self-contained scroll region + * so the rail / drawer overlays positioned by viewport coords don't + * get clipped weirdly on focus. */ + scroll-padding-block: 1rem; } [data-words-content] * { diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index bf6cc127f..88ac7d1bc 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -2279,6 +2279,9 @@ --words-content-min-block-size-sm: 10rem; --words-content-min-block-size-md: 13rem; --words-content-min-block-size-lg: 16rem; + --words-content-max-block-size-sm: 50vh; + --words-content-max-block-size-md: 60vh; + --words-content-max-block-size-lg: 70vh; --words-selection-color: var(--color-content-primary); --words-block-gap: var(--space-2); --words-heading-color: var(--color-content-primary); diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts index 748559e4d..ca63ea0f9 100644 --- a/src/uix/eidos/lib/recipes/base.ts +++ b/src/uix/eidos/lib/recipes/base.ts @@ -2715,6 +2715,9 @@ export const THEME_BASE_RECIPE_TOKENS = { 'content-min-block-size-sm': '10rem', 'content-min-block-size-md': '13rem', 'content-min-block-size-lg': '16rem', + 'content-max-block-size-sm': '50vh', + 'content-max-block-size-md': '60vh', + 'content-max-block-size-lg': '70vh', 'selection-color': 'var(--color-content-primary)', 'block-gap': 'var(--space-2)', 'heading-color': 'var(--color-content-primary)',