The rail is a mode, not a screen size
Item 7 said to confirm before building, and confirming is what mattered. The rail's 348px threshold is measured against a fixed 720px column centred in the pane. The doc-list sidebar is 280px, so at her 1517px viewport the right margin is 258 with it open and 406 without — either side of the threshold. What moves between them is distraction-free mode, which engages on its own when the editor takes focus. The rail therefore appears when she starts writing and disappears when she stops; items 4 and 5 disagreed about whether it exists at 1517px only because they caught it in different states. Re-centring a fixed-width column changes its position and not its size, so the wrapper's ResizeObserver reported nothing and no window resize fired. railEnabled kept whatever value it last had. Leaving distraction-free with the rail up left a 300px column in a 266px margin: overhanging the viewport by 66px, cards clipped mid-sentence, the page scrolling sideways. Entering it with the rail down opened 406px of margin and put nothing in it. Both persisted until something else happened to resize the window. Observe the scrollport too — it spans the pane, so it resizes whenever the chrome around the editor does. That covers any future chrome that moves the editor, which threading focusMode down as a prop would not. Clicking a highlight now opens the anchored card even when the rail is up. That is the item's own acceptance criterion and was previously false by design; the measured distance from the first flagged span to its rail card is 651px, not the ~400 the review guessed. Hover still defers to the rail, since the reasoning against an unbidden second card was about hover and still holds — but a click is her asking to deal with that word. The rail card glows instead of expanding, so nothing is ever open twice. Verified in Chrome at the review's own 1517x810, driving the rule pack from item 3b so no model was involved: the rail follows the mode in both directions with no resize event anywhere; the popover lands 6px under the word with the full explanation, Ask Petal, Accept and Dismiss; accepting from it applied the edit and took the rail 6 cards to 5, leaving the rest with their ids, positions and wording intact. railFit.test.ts pins the threshold to the margins actually measured. The observer wiring has no unit test and can't have a useful one: jsdom has no layout, so every rect is zero and the rail branch is unreachable there. That half is browser-verified only, and the doc says so.
This commit is contained in:
@@ -19,6 +19,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react
|
||||
import { Toolbar } from '../Toolbar/Toolbar'
|
||||
import { SuggestionCard } from './SuggestionCard'
|
||||
import { SuggestionRail, type RailItem } from './SuggestionRail'
|
||||
import { railFitsBeside } from './railFit'
|
||||
import { SuggestionHighlight, setSuggestions, setActiveSuggestion, findRange } from './SuggestionHighlight'
|
||||
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
|
||||
import { MisspellCard } from './MisspellCard'
|
||||
@@ -425,9 +426,7 @@ export function EditorCore({
|
||||
const wrapper = wrapperRef.current
|
||||
if (!wrapper) return
|
||||
const wrapRect = wrapper.getBoundingClientRect()
|
||||
// Need room for the 300px column + its 32px gutter (see .petal-rail), plus
|
||||
// a little breathing space to the viewport edge.
|
||||
setRailEnabled(window.innerWidth - wrapRect.right >= 348)
|
||||
setRailEnabled(railFitsBeside(window.innerWidth, wrapRect.right))
|
||||
const seen = new Set<string>()
|
||||
const items: RailItem[] = []
|
||||
wrapper.querySelectorAll<HTMLElement>('.petal-suggestion[data-suggestion-id]').forEach((el) => {
|
||||
@@ -448,11 +447,24 @@ export function EditorCore({
|
||||
|
||||
// Re-anchor when the suggestion set changes (after the decorations repaint),
|
||||
// and keep the rail in sync with viewport/editor width changes (room + reflow).
|
||||
//
|
||||
// The scrollport is observed as well as the wrapper, and it is not redundant:
|
||||
// the wrapper is a fixed 720px column, so entering or leaving distraction-free
|
||||
// mode *moves* it (the pane re-centres) without ever changing its size. A
|
||||
// ResizeObserver on the wrapper alone reports nothing, no window resize fires,
|
||||
// and `railEnabled` keeps whatever value it had — leaving the 300px rail
|
||||
// rendered into the 266px margin a restored sidebar leaves behind, cards
|
||||
// clipped mid-sentence and the page scrolling sideways. The scrollport spans
|
||||
// the pane, so it resizes whenever the chrome around the editor does.
|
||||
useEffect(() => {
|
||||
recomputeRail()
|
||||
const wrapper = wrapperRef.current
|
||||
const port = wrapper?.closest('.petal-scrollport')
|
||||
const ro = wrapper ? new ResizeObserver(() => recomputeRail()) : null
|
||||
if (wrapper && ro) ro.observe(wrapper)
|
||||
if (wrapper && ro) {
|
||||
ro.observe(wrapper)
|
||||
if (port) ro.observe(port)
|
||||
}
|
||||
window.addEventListener('resize', recomputeRail)
|
||||
return () => {
|
||||
ro?.disconnect()
|
||||
@@ -590,12 +602,15 @@ export function EditorCore({
|
||||
(e: React.MouseEvent) => {
|
||||
if (!(e.target as HTMLElement).closest('.petal-suggestion')) return
|
||||
if (railEnabled) {
|
||||
setActiveId(null)
|
||||
// A click-opened card outlives the pointer (it closes on a click away),
|
||||
// so its rail card keeps the glow — otherwise the open card and the
|
||||
// margin stop agreeing about which suggestion is being read.
|
||||
if (!hover) setActiveId(null)
|
||||
return
|
||||
}
|
||||
scheduleClose()
|
||||
},
|
||||
[scheduleClose, railEnabled],
|
||||
[scheduleClose, railEnabled, hover],
|
||||
)
|
||||
|
||||
const keepOpen = useCallback(() => clearTimeout(closeTimer.current), [])
|
||||
@@ -685,14 +700,14 @@ export function EditorCore({
|
||||
if (suggestionEl) {
|
||||
const id = suggestionEl.getAttribute('data-suggestion-id')
|
||||
if (id) {
|
||||
// With the rail open, a tap emphasizes and expands its margin card
|
||||
// instead of opening a floating one.
|
||||
if (railEnabled) {
|
||||
setActiveId(id)
|
||||
setRailExpandedId(id)
|
||||
} else {
|
||||
openCardFor(id, suggestionEl)
|
||||
}
|
||||
// Clicking a highlight always opens the card at the word. Hover still
|
||||
// defers to the rail (see handleMouseOver) — an unbidden floating card
|
||||
// beside a margin card that already says the same thing is noise. But a
|
||||
// click is her asking to deal with *this* word, and answering it 650px
|
||||
// away in the periphery is the gesture item 7 is about. The rail card
|
||||
// glows rather than expanding, so the suggestion is never open twice.
|
||||
setActiveId(id)
|
||||
openCardFor(id, suggestionEl)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1210,7 +1225,7 @@ export function EditorCore({
|
||||
onAdd={addMisspellingToDict}
|
||||
/>
|
||||
)}
|
||||
{hover && !railEnabled && (
|
||||
{hover && (
|
||||
<SuggestionCard
|
||||
suggestion={hover.suggestion}
|
||||
style={{ top: hover.top, left: hover.left }}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RAIL_MIN_MARGIN, railFitsBeside } from './railFit'
|
||||
|
||||
// The numbers below are not invented: they were measured in Chrome at the UX
|
||||
// review's own 1517x810 viewport, with the 280px sidebar open and closed. They
|
||||
// are here so the two layouts stay distinguishable if the constant is ever tuned.
|
||||
describe('railFitsBeside', () => {
|
||||
it('fits in distraction-free mode, where the pane spans the window', () => {
|
||||
// 1517px window, sidebar collapsed: the 720px column centres at left 391,
|
||||
// so its right edge is 1111 and 406px of margin remain.
|
||||
expect(railFitsBeside(1517, 1111)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not fit with the document list open at the same window size', () => {
|
||||
// Same window, 280px sidebar in flow: the column re-centres to right 1259 and
|
||||
// the margin falls to 258 — the measurement item 5 reported. This is the case
|
||||
// that must return false; rendering the rail here overhangs the viewport.
|
||||
expect(railFitsBeside(1517, 1259)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects the mid-animation width too, not just the settled one', () => {
|
||||
// The sidebar animates over 280ms, so the recompute can land on an
|
||||
// intermediate margin (266 was observed one frame in). Anything under the
|
||||
// threshold has to read as "no rail", or the column flickers back in.
|
||||
expect(railFitsBeside(1517, 1251)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats the threshold as inclusive', () => {
|
||||
expect(railFitsBeside(1000, 1000 - RAIL_MIN_MARGIN)).toBe(true)
|
||||
expect(railFitsBeside(1000, 1000 - RAIL_MIN_MARGIN + 1)).toBe(false)
|
||||
})
|
||||
|
||||
it('has room to spare on a wide desktop', () => {
|
||||
// 1920px maximised, sidebar open: margin 468.
|
||||
expect(railFitsBeside(1920, 1452)).toBe(true)
|
||||
})
|
||||
|
||||
it('never fits on a narrow window, whatever the column does', () => {
|
||||
expect(railFitsBeside(900, 810)).toBe(false)
|
||||
expect(railFitsBeside(768, 744)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
// Whether the margin rail has room to sit beside the editor.
|
||||
//
|
||||
// The rail is a 300px column with a 32px gutter (see `.petal-rail` in index.css);
|
||||
// RAIL_MIN_MARGIN adds a little breathing space to the viewport edge. Below it the
|
||||
// editor falls back to the inline card anchored under the word.
|
||||
//
|
||||
// This is a bare comparison, but it earns a name: the number decides which of two
|
||||
// entirely different suggestion surfaces she gets, and the margin it measures moves
|
||||
// for reasons that have nothing to do with the window size. The editor is a fixed
|
||||
// 720px column centred in the pane, so collapsing the 280px sidebar (distraction-free
|
||||
// mode) re-centres it and changes this margin by 140px without resizing anything.
|
||||
// See the ResizeObserver in EditorCore for the other half of that story.
|
||||
export const RAIL_MIN_MARGIN = 348
|
||||
|
||||
// `wrapperRight` and `innerWidth` are both viewport coordinates — i.e. exactly
|
||||
// `wrapper.getBoundingClientRect().right` and `window.innerWidth`.
|
||||
export function railFitsBeside(innerWidth: number, wrapperRight: number): boolean {
|
||||
return innerWidth - wrapperRight >= RAIL_MIN_MARGIN
|
||||
}
|
||||
Reference in New Issue
Block a user