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`. |
|
||||
| `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
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user