feat: add editor-style UML scrolloff
This commit is contained in:
@@ -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`. |
|
| `POST /api/nvim/input` | Przekazuje do 256 znaków przez `nvim_input`. |
|
||||||
| `WS /api/nvim-ui` | Strumień zewnętrznego UI Neovima do panelu WWW. |
|
| `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ż
|
Wyłączenie `viewer.nvim.sync` automatycznie wyłącza również
|
||||||
`viewer.nvim.control`. Samo ponowne włączenie synchronizacji nie uzbraja
|
`viewer.nvim.control`. Samo ponowne włączenie synchronizacji nie uzbraja
|
||||||
sterowania klawiaturą bez jawnego `control: true`.
|
sterowania klawiaturą bez jawnego `control: true`.
|
||||||
|
|||||||
+313
-41
@@ -29,7 +29,12 @@ type ApiViewerState = {
|
|||||||
revision: number;
|
revision: number;
|
||||||
actor?: string | null;
|
actor?: string | null;
|
||||||
scale: number;
|
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 };
|
viewport: { width: number; height: number };
|
||||||
nvim: {
|
nvim: {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -259,6 +264,157 @@ const nvimStatusLabel: Record<NvimPanelSummary["state"], string> = {
|
|||||||
unselected: "OFFLINE · BRAK CELU",
|
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 }) => (
|
const Html = ({ html, className }: { html: string; className?: string }) => (
|
||||||
<div className={className} dangerouslySetInnerHTML={{ __html: html }} />
|
<div className={className} dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
);
|
);
|
||||||
@@ -1994,6 +2150,7 @@ function SnapshotInteractiveUmlSequence({
|
|||||||
const focusStorageKey = `${config.storage_key ?? asset.label}-navigation-level`;
|
const focusStorageKey = `${config.storage_key ?? asset.label}-navigation-level`;
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
|
const handledRevealRevisionRef = useRef(0);
|
||||||
const activateOnChangeRef = useRef(false);
|
const activateOnChangeRef = useRef(false);
|
||||||
const focusOnChangeRef = useRef<NavigationLevel | null>(null);
|
const focusOnChangeRef = useRef<NavigationLevel | null>(null);
|
||||||
const persistOnChangeRef = useRef(true);
|
const persistOnChangeRef = useRef(true);
|
||||||
@@ -2154,35 +2311,69 @@ function SnapshotInteractiveUmlSequence({
|
|||||||
}, [asset.alt, entries, progress, stepIndex, svgMarkup]);
|
}, [asset.alt, entries, progress, stepIndex, svgMarkup]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const frame = window.requestAnimationFrame(() => {
|
if (
|
||||||
const chrome = document.querySelector<HTMLElement>(".viewer-chrome");
|
revealRevision === 0
|
||||||
if (chrome?.classList.contains("is-nvim-fit")) return;
|
|| handledRevealRevisionRef.current === revealRevision
|
||||||
|
) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let correctionFrame = 0;
|
||||||
|
const reveal = (pass: number) => {
|
||||||
|
if (cancelled) return;
|
||||||
const target = canvasRef.current?.querySelector<SVGRectElement>(
|
const target = canvasRef.current?.querySelector<SVGRectElement>(
|
||||||
`.uml-step-hit[data-uml-step="${stepIndex}"]`,
|
`.uml-step-hit[data-uml-step="${stepIndex}"]`,
|
||||||
) ?? rootRef.current;
|
);
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
const bounds = target.getBoundingClientRect();
|
const chrome = document.querySelector<HTMLElement>(".viewer-chrome");
|
||||||
const chromeBounds = chrome?.getBoundingClientRect();
|
if (chrome?.classList.contains("is-nvim-fit")) {
|
||||||
const visibleTop = Math.max(
|
handledRevealRevisionRef.current = revealRevision;
|
||||||
0,
|
return;
|
||||||
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 windowViewport = chromeAwareWindowViewport(target);
|
||||||
return () => window.cancelAnimationFrame(frame);
|
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]);
|
}, [revealRevision, stepIndex, svgMarkup]);
|
||||||
|
|
||||||
const publishEntry = (
|
const publishEntry = (
|
||||||
@@ -2230,11 +2421,11 @@ function SnapshotInteractiveUmlSequence({
|
|||||||
const entry = entries[bounded];
|
const entry = entries[bounded];
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
setFocusLevel(level);
|
setFocusLevel(level);
|
||||||
setRevealRevision(current => current + 1);
|
|
||||||
if (bounded === stepIndex) {
|
if (bounded === stepIndex) {
|
||||||
publishEntry(entry, activate, level, persist);
|
publishEntry(entry, activate, level, persist);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setRevealRevision(current => current + 1);
|
||||||
activateOnChangeRef.current = activate;
|
activateOnChangeRef.current = activate;
|
||||||
focusOnChangeRef.current = level;
|
focusOnChangeRef.current = level;
|
||||||
persistOnChangeRef.current = persist;
|
persistOnChangeRef.current = persist;
|
||||||
@@ -2735,6 +2926,10 @@ function App({ model }: { model: CardModel }) {
|
|||||||
);
|
);
|
||||||
const viewerRevisionRef = useRef(0);
|
const viewerRevisionRef = useRef(0);
|
||||||
const navigationRevisionRef = 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 [scale, setScaleState] = useState(2.18);
|
||||||
const [nvimVisible, setNvimVisibleState] = useState(
|
const [nvimVisible, setNvimVisibleState] = useState(
|
||||||
() => localStorage.getItem("stem-card-nvim-visible") !== "false",
|
() => localStorage.getItem("stem-card-nvim-visible") !== "false",
|
||||||
@@ -2794,10 +2989,16 @@ function App({ model }: { model: CardModel }) {
|
|||||||
}, []);
|
}, []);
|
||||||
const setNvimVisible = (value: boolean) => {
|
const setNvimVisible = (value: boolean) => {
|
||||||
setNvimVisibleState(value);
|
setNvimVisibleState(value);
|
||||||
if (!value) setNvimExpandedState(false);
|
if (!value) {
|
||||||
|
setNvimExpandedState(false);
|
||||||
|
setNvimFitState(false);
|
||||||
|
}
|
||||||
localStorage.setItem("stem-card-nvim-visible", String(value));
|
localStorage.setItem("stem-card-nvim-visible", String(value));
|
||||||
void publishViewer({
|
void publishViewer({
|
||||||
nvim: { visible: value, ...(value ? {} : { expanded: false }) },
|
nvim: {
|
||||||
|
visible: value,
|
||||||
|
...(value ? {} : { expanded: false, fit: false }),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const setNvimControlMode = (value: boolean) => {
|
const setNvimControlMode = (value: boolean) => {
|
||||||
@@ -2870,22 +3071,57 @@ function App({ model }: { model: CardModel }) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const applyViewer = (viewer: ApiViewerState) => {
|
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)));
|
setScaleState(Math.max(0.25, Math.min(3, viewer.scale)));
|
||||||
setNvimVisibleState(viewer.nvim.visible);
|
setNvimVisibleState(viewer.nvim.visible);
|
||||||
setNvimSyncModeState(viewer.nvim.sync);
|
setNvimSyncModeState(viewer.nvim.sync);
|
||||||
setNvimControlModeState(viewer.nvim.sync && viewer.nvim.control);
|
setNvimControlModeState(viewer.nvim.sync && viewer.nvim.control);
|
||||||
setNvimExpandedState(viewer.nvim.expanded);
|
setNvimExpandedState(expanded);
|
||||||
setNvimFitState(viewer.nvim.fit);
|
setNvimFitState(fit);
|
||||||
localStorage.setItem("stem-card-nvim-visible", String(viewer.nvim.visible));
|
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-sync-mode", String(viewer.nvim.sync));
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"stem-card-nvim-control-mode",
|
"stem-card-nvim-control-mode",
|
||||||
String(viewer.nvim.sync && viewer.nvim.control),
|
String(viewer.nvim.sync && viewer.nvim.control),
|
||||||
);
|
);
|
||||||
if (Math.abs(window.scrollX - viewer.scroll.x) > 1
|
const token = ++remoteScrollTokenRef.current;
|
||||||
|| Math.abs(window.scrollY - viewer.scroll.y) > 1) {
|
applyingRemoteScrollRef.current = true;
|
||||||
window.scrollTo({ left: viewer.scroll.x, top: viewer.scroll.y, behavior: "smooth" });
|
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 () => {
|
const poll = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -2905,6 +3141,9 @@ function App({ model }: { model: CardModel }) {
|
|||||||
if (navigation && navigation.revision > navigationRevisionRef.current) {
|
if (navigation && navigation.revision > navigationRevisionRef.current) {
|
||||||
navigationRevisionRef.current = navigation.revision;
|
navigationRevisionRef.current = navigation.revision;
|
||||||
if (navigation.actor !== clientIdRef.current) {
|
if (navigation.actor !== clientIdRef.current) {
|
||||||
|
if (applyingRemoteScrollRef.current) {
|
||||||
|
pendingRemoteNavigationReportRef.current = true;
|
||||||
|
}
|
||||||
window.dispatchEvent(new CustomEvent<ApiNavigation>("stem:navigation-command", {
|
window.dispatchEvent(new CustomEvent<ApiNavigation>("stem:navigation-command", {
|
||||||
detail: navigation,
|
detail: navigation,
|
||||||
}));
|
}));
|
||||||
@@ -2919,6 +3158,10 @@ function App({ model }: { model: CardModel }) {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
|
remoteScrollTokenRef.current += 1;
|
||||||
|
window.cancelAnimationFrame(remoteScrollFrameRef.current);
|
||||||
|
applyingRemoteScrollRef.current = false;
|
||||||
|
pendingRemoteNavigationReportRef.current = false;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
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]);
|
}, [nvimSummary.grid?.columns, nvimSummary.grid?.rows, nvimSummary.screenCursor?.column, nvimSummary.screenCursor?.row, nvimSummary.state, publishViewer]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timer = 0;
|
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);
|
window.clearTimeout(timer);
|
||||||
timer = window.setTimeout(() => {
|
timer = window.setTimeout(() => {
|
||||||
const sheets = Array.from(document.querySelectorAll<HTMLElement>(".paper > .sheet"));
|
const sourceSheet = pendingSheetContent?.closest<HTMLElement>(".sheet");
|
||||||
const center = window.scrollY + window.innerHeight / 2;
|
const sourceIndex = sourceSheet ? sheets.indexOf(sourceSheet) : -1;
|
||||||
const pageIndex = sheets.reduce((best, sheet, index) => {
|
const pageIndex = sourceIndex >= 0
|
||||||
const top = sheet.offsetTop;
|
? sourceIndex
|
||||||
const distance = Math.abs(top + sheet.offsetHeight / 2 - center);
|
: 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;
|
return distance < best.distance ? { index, distance } : best;
|
||||||
}, { index: 0, distance: Number.POSITIVE_INFINITY }).index;
|
}, { index: 0, distance: Number.POSITIVE_INFINITY }).index;
|
||||||
|
const sheetContent = sourceIndex >= 0
|
||||||
|
? pendingSheetContent
|
||||||
|
: sheets[pageIndex]?.querySelector<HTMLElement>(".sheet-content") ?? null;
|
||||||
void publishViewer({
|
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 },
|
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||||||
});
|
});
|
||||||
|
pendingSheetContent = null;
|
||||||
}, 120);
|
}, 120);
|
||||||
};
|
};
|
||||||
window.addEventListener("scroll", report, { passive: true });
|
window.addEventListener("scroll", report, { passive: true });
|
||||||
window.addEventListener("resize", report);
|
window.addEventListener("resize", report);
|
||||||
|
window.addEventListener("stem:viewer-scroll-settled", report);
|
||||||
|
sheetContents.forEach(sheet => sheet.addEventListener("scroll", report, { passive: true }));
|
||||||
report();
|
report();
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
window.removeEventListener("scroll", report);
|
window.removeEventListener("scroll", report);
|
||||||
window.removeEventListener("resize", report);
|
window.removeEventListener("resize", report);
|
||||||
|
window.removeEventListener("stem:viewer-scroll-settled", report);
|
||||||
|
sheetContents.forEach(sheet => sheet.removeEventListener("scroll", report));
|
||||||
};
|
};
|
||||||
}, [publishViewer]);
|
}, [publishViewer]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user