Make the suggestion rail's overhang reachable, and keep the text in view

The margin rail hangs off an absolutely-positioned column, so its cards add
no layout height. On her live document that meant four 173px cards anchored
inside 126px of text: a 714px stack over a page whose scrollHeight equalled
its clientHeight. The lower cards weren't far from their sentence, they were
off-screen with nothing to scroll.

The rail now reports how far its resolved stack reaches and the wrapper takes
that as a minimum height, so the space those cards occupy is real, scrollable
page. minHeight never shrinks the column, so a rail that fits beside its text
is unaffected.

Scrolling into that space would have carried every sentence off the top, so
the prose is pinned while the stack overhangs it. The offset is
min(0, port - content): prose shorter than the viewport pins at the top,
taller prose pins by its bottom edge, keeping the last lines visible — those
are the ones the overhanging cards flag.

The prose box has to stay at its natural height. Keeping the old h-full made
it measure the wrapper this change had just grown, reporting the cards'
height as the text's own, so the pin could never trip.

Verified in a browser at the review's 1517x810, driven offline by the rule
pack: 8 cards over 95px of prose gained 675px of scroll where there was none,
the last card lands fully in view with the text still on screen, tall prose
pins bottom-anchored without disturbing ordinary scrolling, and hover-linking
still glows the right span.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
prosolis
2026-07-27 23:07:09 -07:00
parent 10e8aef86c
commit de251ceae2
4 changed files with 157 additions and 3 deletions
+69 -2
View File
@@ -15,7 +15,7 @@ import TableHeader from '@tiptap/extension-table-header'
import TableCell from '@tiptap/extension-table-cell'
import { FontSize } from './FontSize'
import type { EditorView } from '@tiptap/pm/view'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { Toolbar } from '../Toolbar/Toolbar'
import { SuggestionCard } from './SuggestionCard'
import { SuggestionRail, type RailItem } from './SuggestionRail'
@@ -34,6 +34,10 @@ import { speak, speechSupported } from '../../audio/speech'
import type { SpellChecker } from '../../hooks/useSpellChecker'
import { usePack } from '../../i18n'
// Breathing room left below the last suggestion card when the rail's stack is what
// defines the column's height, so the bottom card doesn't sit flush on the edge.
const RAIL_TAIL = 24
export interface EditorChange {
content: string // Tiptap JSON, stringified
content_text: string // flattened plain text for the LLM
@@ -229,6 +233,9 @@ export function EditorCore({
// own name, so it says "português" rather than "pt-PT".
const pack = usePack()
const wrapperRef = useRef<HTMLDivElement>(null)
// The text column itself, measured separately from its wrapper: the wrapper is
// grown to cover the card stack, so only this reports the height of the prose.
const contentRef = useRef<HTMLDivElement>(null)
const [hover, setHover] = useState<HoverState | null>(null)
// The open spelling popover (click a red-underlined word), or null.
const [misspell, setMisspell] = useState<MisspellState | null>(null)
@@ -266,6 +273,15 @@ export function EditorCore({
// `activeId` is the suggestion currently emphasized (hovered text or card).
const [railItems, setRailItems] = useState<RailItem[]>([])
const [railEnabled, setRailEnabled] = useState(false)
// How far the resolved card stack reaches below the wrapper's top, reported by
// the rail. Cards are absolutely positioned and so contribute no layout height:
// without this the column below the last line of text isn't scrollable and any
// card that lands there is unreachable, not merely far from its sentence.
const [railExtent, setRailExtent] = useState(0)
// Sticky offset for the text column, or null when it should sit in normal flow.
// Set only while the stack overhangs the text: scrolling down to reach the lower
// cards would otherwise carry every sentence off the top of the screen.
const [stickTop, setStickTop] = useState<number | null>(null)
const [railExpandedId, setRailExpandedId] = useState<string | null>(null)
const [activeId, setActiveId] = useState<string | null>(null)
// A stable handle to the latest recompute so the editor's onUpdate (captured
@@ -444,6 +460,42 @@ export function EditorCore({
}
}, [recomputeRail])
// The rail only reports its extent while it's mounted, so clear it when the last
// card goes (accepted the lot, or the window narrowed past the rail's threshold)
// — otherwise the column keeps the height of a stack that no longer exists.
useEffect(() => {
if (!railEnabled || railItems.length === 0) setRailExtent(0)
}, [railEnabled, railItems.length])
// Decide whether the text column has to be pinned. The rail's cards hang off an
// absolutely-positioned column, so when several suggestions share one short
// paragraph the stack runs far past the last line of text. Growing the wrapper to
// `railExtent` makes that space scrollable (item 4: the lower cards were simply
// unreachable); pinning the prose inside it means scrolling down to read those
// cards keeps the sentences on screen instead of scrolling them away.
//
// The offset is `min(0, port - content)`: prose shorter than the viewport sticks
// at the top, taller prose sticks by its *bottom* edge, so its last lines — the
// ones the overhanging cards flag — stay visible rather than the first.
useLayoutEffect(() => {
const wrapper = wrapperRef.current
const content = contentRef.current
if (!wrapper || !content || !railEnabled || railExtent <= 0) {
setStickTop(null)
return
}
const contentH = content.offsetHeight
// Only pin when the stack actually overhangs the prose; a rail that fits
// beside its text needs nothing, and pinning it would be a change for free.
if (railExtent <= contentH) {
setStickTop(null)
return
}
const port = wrapper.closest('.petal-scrollport')
const portH = port ? port.clientHeight : window.innerHeight
setStickTop(Math.min(0, portH - contentH - RAIL_TAIL))
}, [railEnabled, railExtent, railItems])
// Emphasize the flagged text for the active suggestion, mirroring the rail
// card ↔ text link both ways. Driven through the decoration plugin (not an
// imperative DOM class) so it survives the repaints that fire on every edit.
@@ -1082,6 +1134,10 @@ export function EditorCore({
<div
ref={wrapperRef}
className="relative flex-1"
// Grown to cover the card stack when it overhangs the prose, so the space
// those cards occupy is actually scrollable. `minHeight` never shrinks the
// column, so a rail that fits beside its text changes nothing.
style={railEnabled && railExtent > 0 ? { minHeight: railExtent + RAIL_TAIL } : undefined}
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
onMouseMove={handleMouseMove}
@@ -1093,7 +1149,17 @@ export function EditorCore({
onTouchMove={cancelLongPress}
onTouchEnd={cancelLongPress}
>
<EditorContent editor={editor} className="h-full" />
{/* The prose sits in its own box so it can be measured (and pinned)
independently of the wrapper, which the rail may have grown. The box is
deliberately left at its natural height: sized to the wrapper it would
report the stack's height back as the text's own, and the pin below
could never trip. */}
<div
ref={contentRef}
style={stickTop === null ? undefined : { position: 'sticky', top: stickTop }}
>
<EditorContent editor={editor} className="h-full" />
</div>
{findOpen && editor && <FindReplace editor={editor} onClose={() => setFindOpen(false)} />}
{confetti && <Confetti top={confetti.top} left={confetti.left} />}
{gloss && (
@@ -1165,6 +1231,7 @@ export function EditorCore({
onHover={setActiveId}
onActivate={activateRailCard}
onToggleExpand={toggleRailExpand}
onExtent={setRailExtent}
/>
)}
</div>
@@ -28,6 +28,12 @@ interface Props {
// A card's body was clicked — scroll its highlight into view and toggle expand.
onActivate: (id: string) => void
onToggleExpand: (id: string) => void
// How far down the resolved stack reaches (px below the wrapper's top). Cards
// are absolutely positioned, so they add nothing to layout height — a cluster of
// errors in one short paragraph can pile cards hundreds of px past the end of the
// text, with no scrollable space to reach them. The editor uses this to grow the
// column so every card can at least be scrolled to.
onExtent: (bottom: number) => void
}
// SuggestionRail is the right-margin "comment column": every outstanding
@@ -44,6 +50,7 @@ export function SuggestionRail({
onHover,
onActivate,
onToggleExpand,
onExtent,
}: Props) {
// Measured resolved tops keyed by suggestion id (after collision avoidance).
const [tops, setTops] = useState<Record<string, number>>({})
@@ -65,13 +72,16 @@ export function SuggestionRail({
const layoutKey = ordered.map((i) => `${i.suggestion.id}:${Math.round(i.anchorTop)}`).join('|')
useLayoutEffect(() => {
let cursor = -Infinity
let bottom = 0
const next: Record<string, number> = {}
for (const { suggestion, anchorTop } of ordered) {
const h = cardRefs.current.get(suggestion.id)?.offsetHeight ?? 96
const top = Math.max(anchorTop, cursor)
next[suggestion.id] = top
cursor = top + h + CARD_GAP
bottom = top + h
}
onExtent(bottom)
setTops((prev) => {
const ids = Object.keys(next)
if (ids.length === Object.keys(prev).length && ids.every((id) => prev[id] === next[id])) return prev