diff --git a/docs/api.md b/docs/api.md index c94ff0a..197b71d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -95,6 +95,11 @@ wyniku; pozycja strony i `SYNC ON` nie są wtedy publikowane. | `POST /api/nvim/input` | Przekazuje do 256 znaków przez `nvim_input`. | | `WS /api/nvim-ui` | Strumień zewnętrznego UI Neovima do panelu WWW. | +Pole `viewer.scroll` zapisuje położenie dokumentu (`x`, `y`), numer strony +`page_index` oraz `sheet_scroll_top` dla jej wewnętrznego obszaru. Dzięki temu +sterowanie API odtwarza ten sam fragment diagramu także wtedy, gdy przy dużym +powiększeniu przewija się najpierw zawartość strony, a dopiero potem dokument. + Wyłączenie `viewer.nvim.sync` automatycznie wyłącza również `viewer.nvim.control`. Samo ponowne włączenie synchronizacji nie uzbraja sterowania klawiaturą bez jawnego `control: true`. diff --git a/react/src/main.tsx b/react/src/main.tsx index 70cefb9..dcb2d9a 100644 --- a/react/src/main.tsx +++ b/react/src/main.tsx @@ -29,7 +29,12 @@ type ApiViewerState = { revision: number; actor?: string | null; scale: number; - scroll: { x: number; y: number; page_index: number }; + scroll: { + x: number; + y: number; + page_index: number; + sheet_scroll_top?: number; + }; viewport: { width: number; height: number }; nvim: { visible: boolean; @@ -259,6 +264,157 @@ const nvimStatusLabel: Record = { unselected: "OFFLINE · BRAK CELU", }; +const UML_SCROLLOFF_RATIO = 0.25; +const UML_SCROLLOFF_EPSILON_PX = 1; +const UML_SCROLLOFF_MAX_PASSES = 4; + +type VerticalViewport = { top: number; bottom: number }; + +function clipsVertically(element: HTMLElement) { + const overflow = window.getComputedStyle(element).overflowY; + return overflow === "auto" || overflow === "scroll" || overflow === "hidden" + || overflow === "clip"; +} + +function elementClientViewport(element: HTMLElement): VerticalViewport { + const bounds = element.getBoundingClientRect(); + const layoutHeight = element.offsetHeight || element.clientHeight; + const scale = layoutHeight > 0 ? bounds.height / layoutHeight : 1; + const top = bounds.top + element.clientTop * scale; + return { + top, + bottom: top + element.clientHeight * scale, + }; +} + +function chromeAwareWindowViewport(target: Element): VerticalViewport | null { + const chrome = document.querySelector(".viewer-chrome"); + if (chrome?.classList.contains("is-nvim-fit")) return null; + + let top = 0; + let bottom = window.innerHeight; + if (chrome) { + const chromeBounds = chrome.getBoundingClientRect(); + const targetBounds = target.getBoundingClientRect(); + const overlapsHorizontally = chromeBounds.left < targetBounds.right + && chromeBounds.right > targetBounds.left; + if ( + overlapsHorizontally + && chromeBounds.bottom > 0 + && chromeBounds.top < window.innerHeight + ) { + top = Math.max(top, chromeBounds.bottom); + } + } + return bottom - top > UML_SCROLLOFF_EPSILON_PX ? { top, bottom } : null; +} + +function visibleVerticalViewport(target: Element): VerticalViewport | null { + const windowViewport = chromeAwareWindowViewport(target); + if (!windowViewport) return null; + + let { top, bottom } = windowViewport; + for ( + let ancestor = target.parentElement; + ancestor && ancestor !== document.body && ancestor !== document.documentElement; + ancestor = ancestor.parentElement + ) { + if (!clipsVertically(ancestor)) continue; + const client = elementClientViewport(ancestor); + top = Math.max(top, client.top); + bottom = Math.min(bottom, client.bottom); + } + return bottom - top > UML_SCROLLOFF_EPSILON_PX ? { top, bottom } : null; +} + +function documentScroller() { + return (document.scrollingElement as HTMLElement | null) + ?? document.documentElement; +} + +function verticalScrollChain(target: Element) { + const chain: HTMLElement[] = []; + for ( + let ancestor = target.parentElement; + ancestor && ancestor !== document.body && ancestor !== document.documentElement; + ancestor = ancestor.parentElement + ) { + const overflow = window.getComputedStyle(ancestor).overflowY; + const acceptsScroll = overflow === "auto" || overflow === "scroll"; + if (acceptsScroll && ancestor.scrollHeight - ancestor.clientHeight > 1) { + chain.push(ancestor); + } + } + const root = documentScroller(); + if (!chain.includes(root)) chain.push(root); + return chain; +} + +function visualScrollScale(scroller: HTMLElement) { + if (scroller === documentScroller()) return 1; + const bounds = scroller.getBoundingClientRect(); + const layoutHeight = scroller.offsetHeight || scroller.clientHeight; + const scale = layoutHeight > 0 ? bounds.height / layoutHeight : 1; + return Number.isFinite(scale) && scale > 0 ? scale : 1; +} + +function setScrollPositionInstant( + scroller: HTMLElement, + position: { top?: number; left?: number }, +) { + const previousBehavior = scroller.style.scrollBehavior; + scroller.style.scrollBehavior = "auto"; + if (position.left != null) scroller.scrollLeft = position.left; + if (position.top != null) scroller.scrollTop = position.top; + scroller.style.scrollBehavior = previousBehavior; +} + +function bringTargetSheetIntoWindow(target: Element, viewport: VerticalViewport) { + const anchor = target.closest(".sheet-content"); + if (!anchor) return 0; + const bounds = elementClientViewport(anchor); + let delta = 0; + if (bounds.bottom <= viewport.top) { + delta = bounds.bottom - viewport.bottom; + } else if (bounds.top >= viewport.bottom) { + delta = bounds.top - viewport.top; + } + if (Math.abs(delta) <= UML_SCROLLOFF_EPSILON_PX) return 0; + + const root = documentScroller(); + const previous = root.scrollTop; + const maximum = Math.max(0, root.scrollHeight - root.clientHeight); + setScrollPositionInstant(root, { + top: Math.max(0, Math.min(maximum, previous + delta)), + }); + return root.scrollTop - previous; +} + +function applyVerticalScrollDelta(target: Element, delta: number) { + let remaining = delta; + for (const scroller of verticalScrollChain(target)) { + if (Math.abs(remaining) <= UML_SCROLLOFF_EPSILON_PX) break; + const scale = visualScrollScale(scroller); + const maximum = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + const previous = scroller.scrollTop; + const next = Math.max(0, Math.min(maximum, previous + remaining / scale)); + setScrollPositionInstant(scroller, { top: next }); + remaining -= (scroller.scrollTop - previous) * scale; + } + return delta - remaining; +} + +function umlScrolloffDelta(target: Element, viewport: VerticalViewport) { + const bounds = target.getBoundingClientRect(); + const cursor = (bounds.top + bounds.bottom) / 2; + const height = viewport.bottom - viewport.top; + const upper = viewport.top + height * UML_SCROLLOFF_RATIO; + const lower = viewport.bottom - height * UML_SCROLLOFF_RATIO; + if (cursor < upper) return cursor - upper; + if (cursor > lower) return cursor - lower; + return 0; +} + const Html = ({ html, className }: { html: string; className?: string }) => (
); @@ -1994,6 +2150,7 @@ function SnapshotInteractiveUmlSequence({ const focusStorageKey = `${config.storage_key ?? asset.label}-navigation-level`; const rootRef = useRef(null); const canvasRef = useRef(null); + const handledRevealRevisionRef = useRef(0); const activateOnChangeRef = useRef(false); const focusOnChangeRef = useRef(null); const persistOnChangeRef = useRef(true); @@ -2154,35 +2311,69 @@ function SnapshotInteractiveUmlSequence({ }, [asset.alt, entries, progress, stepIndex, svgMarkup]); useEffect(() => { - const frame = window.requestAnimationFrame(() => { - const chrome = document.querySelector(".viewer-chrome"); - if (chrome?.classList.contains("is-nvim-fit")) return; + if ( + revealRevision === 0 + || handledRevealRevisionRef.current === revealRevision + ) return; + + let cancelled = false; + let correctionFrame = 0; + const reveal = (pass: number) => { + if (cancelled) return; const target = canvasRef.current?.querySelector( `.uml-step-hit[data-uml-step="${stepIndex}"]`, - ) ?? rootRef.current; + ); if (!target) return; - const bounds = target.getBoundingClientRect(); - const chromeBounds = chrome?.getBoundingClientRect(); - const visibleTop = Math.max( - 0, - chromeBounds && chromeBounds.bottom > 0 ? chromeBounds.bottom : 0, - ) + 12; - const visibleBottom = window.innerHeight - 12; - let delta = 0; - if (bounds.height > visibleBottom - visibleTop) { - delta = bounds.top - visibleTop; - } else if (bounds.top < visibleTop) delta = bounds.top - visibleTop; - else if (bounds.bottom > visibleBottom) delta = bounds.bottom - visibleBottom; - if (Math.abs(delta) > 1) { - const previousBehavior = document.documentElement.style.scrollBehavior; - document.documentElement.style.scrollBehavior = "auto"; - window.scrollBy({ top: delta, behavior: "auto" }); - window.requestAnimationFrame(() => { - document.documentElement.style.scrollBehavior = previousBehavior; - }); + const chrome = document.querySelector(".viewer-chrome"); + if (chrome?.classList.contains("is-nvim-fit")) { + handledRevealRevisionRef.current = revealRevision; + return; } - }); - return () => window.cancelAnimationFrame(frame); + const windowViewport = chromeAwareWindowViewport(target); + if (!windowViewport) { + handledRevealRevisionRef.current = revealRevision; + return; + } + const viewport = visibleVerticalViewport(target); + if (!viewport) { + const pageMovement = bringTargetSheetIntoWindow(target, windowViewport); + const fallbackDelta = pageMovement === 0 + ? umlScrolloffDelta(target, windowViewport) + : 0; + const fallbackMovement = pageMovement === 0 + ? applyVerticalScrollDelta(target, fallbackDelta) + : pageMovement; + if ( + pass < UML_SCROLLOFF_MAX_PASSES - 1 + && Math.abs(fallbackMovement) > UML_SCROLLOFF_EPSILON_PX + ) { + correctionFrame = window.requestAnimationFrame(() => reveal(pass + 1)); + return; + } + handledRevealRevisionRef.current = revealRevision; + return; + } + const delta = umlScrolloffDelta(target, viewport); + if (Math.abs(delta) <= UML_SCROLLOFF_EPSILON_PX) { + handledRevealRevisionRef.current = revealRevision; + return; + } + const applied = applyVerticalScrollDelta(target, delta); + if ( + pass < UML_SCROLLOFF_MAX_PASSES - 1 + && Math.abs(applied) > UML_SCROLLOFF_EPSILON_PX + ) { + correctionFrame = window.requestAnimationFrame(() => reveal(pass + 1)); + return; + } + handledRevealRevisionRef.current = revealRevision; + }; + const frame = window.requestAnimationFrame(() => reveal(0)); + return () => { + cancelled = true; + window.cancelAnimationFrame(frame); + window.cancelAnimationFrame(correctionFrame); + }; }, [revealRevision, stepIndex, svgMarkup]); const publishEntry = ( @@ -2230,11 +2421,11 @@ function SnapshotInteractiveUmlSequence({ const entry = entries[bounded]; if (!entry) return; setFocusLevel(level); - setRevealRevision(current => current + 1); if (bounded === stepIndex) { publishEntry(entry, activate, level, persist); return; } + setRevealRevision(current => current + 1); activateOnChangeRef.current = activate; focusOnChangeRef.current = level; persistOnChangeRef.current = persist; @@ -2735,6 +2926,10 @@ function App({ model }: { model: CardModel }) { ); const viewerRevisionRef = useRef(0); const navigationRevisionRef = useRef(0); + const applyingRemoteScrollRef = useRef(false); + const pendingRemoteNavigationReportRef = useRef(false); + const remoteScrollFrameRef = useRef(0); + const remoteScrollTokenRef = useRef(0); const [scale, setScaleState] = useState(2.18); const [nvimVisible, setNvimVisibleState] = useState( () => localStorage.getItem("stem-card-nvim-visible") !== "false", @@ -2794,10 +2989,16 @@ function App({ model }: { model: CardModel }) { }, []); const setNvimVisible = (value: boolean) => { setNvimVisibleState(value); - if (!value) setNvimExpandedState(false); + if (!value) { + setNvimExpandedState(false); + setNvimFitState(false); + } localStorage.setItem("stem-card-nvim-visible", String(value)); void publishViewer({ - nvim: { visible: value, ...(value ? {} : { expanded: false }) }, + nvim: { + visible: value, + ...(value ? {} : { expanded: false, fit: false }), + }, }); }; const setNvimControlMode = (value: boolean) => { @@ -2870,22 +3071,57 @@ function App({ model }: { model: CardModel }) { }, }; const applyViewer = (viewer: ApiViewerState) => { + const fit = viewer.nvim.visible && viewer.nvim.fit; + const expanded = viewer.nvim.visible && !fit && viewer.nvim.expanded; setScaleState(Math.max(0.25, Math.min(3, viewer.scale))); setNvimVisibleState(viewer.nvim.visible); setNvimSyncModeState(viewer.nvim.sync); setNvimControlModeState(viewer.nvim.sync && viewer.nvim.control); - setNvimExpandedState(viewer.nvim.expanded); - setNvimFitState(viewer.nvim.fit); + setNvimExpandedState(expanded); + setNvimFitState(fit); localStorage.setItem("stem-card-nvim-visible", String(viewer.nvim.visible)); localStorage.setItem("stem-card-nvim-sync-mode", String(viewer.nvim.sync)); localStorage.setItem( "stem-card-nvim-control-mode", String(viewer.nvim.sync && viewer.nvim.control), ); - if (Math.abs(window.scrollX - viewer.scroll.x) > 1 - || Math.abs(window.scrollY - viewer.scroll.y) > 1) { - window.scrollTo({ left: viewer.scroll.x, top: viewer.scroll.y, behavior: "smooth" }); - } + const token = ++remoteScrollTokenRef.current; + applyingRemoteScrollRef.current = true; + window.cancelAnimationFrame(remoteScrollFrameRef.current); + remoteScrollFrameRef.current = window.requestAnimationFrame(() => { + if (token !== remoteScrollTokenRef.current) return; + const root = documentScroller(); + setScrollPositionInstant(root, { + left: viewer.scroll.x, + top: viewer.scroll.y, + }); + + const sheets = Array.from( + document.querySelectorAll(".paper > .sheet"), + ); + const sheet = sheets[Math.max( + 0, + Math.min(sheets.length - 1, viewer.scroll.page_index), + )]; + const sheetContent = sheet?.querySelector(".sheet-content"); + if ( + sheetContent + && Number.isFinite(viewer.scroll.sheet_scroll_top) + ) { + setScrollPositionInstant(sheetContent, { + top: viewer.scroll.sheet_scroll_top ?? 0, + }); + } + remoteScrollFrameRef.current = window.requestAnimationFrame(() => { + if (token === remoteScrollTokenRef.current) { + applyingRemoteScrollRef.current = false; + if (pendingRemoteNavigationReportRef.current) { + pendingRemoteNavigationReportRef.current = false; + window.dispatchEvent(new Event("stem:viewer-scroll-settled")); + } + } + }); + }); }; const poll = async () => { try { @@ -2905,6 +3141,9 @@ function App({ model }: { model: CardModel }) { if (navigation && navigation.revision > navigationRevisionRef.current) { navigationRevisionRef.current = navigation.revision; if (navigation.actor !== clientIdRef.current) { + if (applyingRemoteScrollRef.current) { + pendingRemoteNavigationReportRef.current = true; + } window.dispatchEvent(new CustomEvent("stem:navigation-command", { detail: navigation, })); @@ -2919,6 +3158,10 @@ function App({ model }: { model: CardModel }) { return () => { cancelled = true; window.clearInterval(timer); + remoteScrollTokenRef.current += 1; + window.cancelAnimationFrame(remoteScrollFrameRef.current); + applyingRemoteScrollRef.current = false; + pendingRemoteNavigationReportRef.current = false; }; }, []); useEffect(() => { @@ -2932,29 +3175,58 @@ function App({ model }: { model: CardModel }) { }, [nvimSummary.grid?.columns, nvimSummary.grid?.rows, nvimSummary.screenCursor?.column, nvimSummary.screenCursor?.row, nvimSummary.state, publishViewer]); useEffect(() => { let timer = 0; - const report = () => { + let pendingSheetContent: HTMLElement | null = null; + const sheets = Array.from( + document.querySelectorAll(".paper > .sheet"), + ); + const sheetContents = sheets + .map(sheet => sheet.querySelector(".sheet-content")) + .filter((sheet): sheet is HTMLElement => Boolean(sheet)); + const report = (event?: Event) => { + if (applyingRemoteScrollRef.current) return; + const source = event?.currentTarget; + if (source instanceof HTMLElement && source.classList.contains("sheet-content")) { + pendingSheetContent = source; + } window.clearTimeout(timer); timer = window.setTimeout(() => { - const sheets = Array.from(document.querySelectorAll(".paper > .sheet")); - const center = window.scrollY + window.innerHeight / 2; - const pageIndex = sheets.reduce((best, sheet, index) => { - const top = sheet.offsetTop; - const distance = Math.abs(top + sheet.offsetHeight / 2 - center); - return distance < best.distance ? { index, distance } : best; - }, { index: 0, distance: Number.POSITIVE_INFINITY }).index; + const sourceSheet = pendingSheetContent?.closest(".sheet"); + const sourceIndex = sourceSheet ? sheets.indexOf(sourceSheet) : -1; + const pageIndex = sourceIndex >= 0 + ? sourceIndex + : sheets.reduce((best, sheet, index) => { + const bounds = sheet.getBoundingClientRect(); + const distance = Math.abs( + (bounds.top + bounds.bottom) / 2 - window.innerHeight / 2, + ); + return distance < best.distance ? { index, distance } : best; + }, { index: 0, distance: Number.POSITIVE_INFINITY }).index; + const sheetContent = sourceIndex >= 0 + ? pendingSheetContent + : sheets[pageIndex]?.querySelector(".sheet-content") ?? null; void publishViewer({ - scroll: { x: window.scrollX, y: window.scrollY, page_index: pageIndex }, + scroll: { + x: window.scrollX, + y: window.scrollY, + page_index: pageIndex, + sheet_scroll_top: sheetContent?.scrollTop ?? 0, + }, viewport: { width: window.innerWidth, height: window.innerHeight }, }); + pendingSheetContent = null; }, 120); }; window.addEventListener("scroll", report, { passive: true }); window.addEventListener("resize", report); + window.addEventListener("stem:viewer-scroll-settled", report); + sheetContents.forEach(sheet => sheet.addEventListener("scroll", report, { passive: true })); report(); return () => { window.clearTimeout(timer); window.removeEventListener("scroll", report); window.removeEventListener("resize", report); + window.removeEventListener("stem:viewer-scroll-settled", report); + sheetContents.forEach(sheet => sheet.removeEventListener("scroll", report)); }; }, [publishViewer]); useEffect(() => {