feat: add editor-style UML scrolloff

This commit is contained in:
user
2026-07-17 16:17:23 +02:00
parent d87a4986f3
commit e89f3f11ce
2 changed files with 321 additions and 44 deletions
+5
View File
@@ -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`.
+313 -41
View File
@@ -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<NvimPanelSummary["state"], string> = {
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<HTMLElement>(".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<HTMLElement>(".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 }) => (
<div className={className} dangerouslySetInnerHTML={{ __html: html }} />
);
@@ -1994,6 +2150,7 @@ function SnapshotInteractiveUmlSequence({
const focusStorageKey = `${config.storage_key ?? asset.label}-navigation-level`;
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
const handledRevealRevisionRef = useRef(0);
const activateOnChangeRef = useRef(false);
const focusOnChangeRef = useRef<NavigationLevel | null>(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<HTMLElement>(".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<SVGRectElement>(
`.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<HTMLElement>(".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<HTMLElement>(".paper > .sheet"),
);
const sheet = sheets[Math.max(
0,
Math.min(sheets.length - 1, viewer.scroll.page_index),
)];
const sheetContent = sheet?.querySelector<HTMLElement>(".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<ApiNavigation>("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<HTMLElement>(".paper > .sheet"),
);
const sheetContents = sheets
.map(sheet => sheet.querySelector<HTMLElement>(".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<HTMLElement>(".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);
const sourceSheet = pendingSheetContent?.closest<HTMLElement>(".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<HTMLElement>(".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(() => {