FN-7258: restore mobile board scroll on return
Preserve the board lane position when mobile users return from full-panel task detail. - Capture and restore board, column, project-content, and document scroll offsets for board detail navigation. - Retry board scroll restoration across bounded animation frames after remount and hydration. - Cover mobile and desktop Back to board behavior with focused tests and document the mobile scroll contract. - Add a patch changeset for the published CLI package. Files changed: .changeset/fn-7258-mobile-board-scroll.md | 7 ++ docs/dashboard-guide.md | 2 + .../__tests__/navigation-history.test.tsx | 111 ++++++++++++++++++--- .../hooks/__tests__/useBoardScrollRestore.test.ts | 43 +++++++- .../dashboard/app/hooks/useBoardScrollRestore.ts | 45 ++++++--- .../utils/__tests__/boardScrollSnapshot.test.ts | 91 +++++++++++++++-- .../dashboard/app/utils/boardScrollSnapshot.ts | 41 +++++++- 7 files changed, 305 insertions(+), 35 deletions(-) Fusion-Task-Id: FN-7258 Fusion-Task-Lineage: 97faf031-4244-4117-9948-45491c364134 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7258-mobile-board-scroll.md
Normal file
7
.changeset/fn-7258-mobile-board-scroll.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Preserve mobile board scroll after returning from task detail.
|
||||
category: fix
|
||||
dev: Restores the mobile board/card scroll snapshot after Back to board remounts the board.
|
||||
@@ -17,6 +17,8 @@ The installed mobile/PWA home-screen icons are generated from `packages/dashboar
|
||||
The dashboard now handles browser back navigation consistently on desktop and mobile.
|
||||
Using Back will first dismiss open modals and then step back through in-app view changes before leaving the app.
|
||||
When task detail is open from a board card, mobile list row, right-dock/activity/onboarding link, deep link, or another task detail link, one browser/Android Back action closes the current detail first and restores the prior dashboard context (for example, nested task detail → previous task detail, or task detail → board).
|
||||
On mobile board-card detail, **Back to board** also restores the prior board/card scroll position so the same lane context remains visible.
|
||||
<!-- FNXC:BoardNavigationDocs 2026-06-29-20:45: Mobile full-panel task detail temporarily replaces the board, so the user-facing navigation guide must document that Back to board restores the board/card scroll context instead of returning to the top of the board. -->
|
||||
This behavior used to be mobile-only, and now applies across all viewports.
|
||||
Task Detail modal opens from onboarding, activity log, and task-to-task navigation now all register navigation history entries, so Android back swipe/button dismisses them consistently.
|
||||
|
||||
|
||||
@@ -155,20 +155,24 @@ vi.mock("../../components/Board", () => ({
|
||||
onOpenDetail: (task: Task) => void;
|
||||
onOpenDetailWithTab?: (task: Task, initialTab: "changes" | "retries" | "workflow") => void;
|
||||
}) => (
|
||||
<div data-testid="board-view">
|
||||
{tasks.map((task) => (
|
||||
<div key={task.id}>
|
||||
<button type="button" data-testid={`open-task-${task.id}`} onClick={() => onOpenDetail(task)}>
|
||||
{task.title}
|
||||
</button>
|
||||
{task.modifiedFiles && task.modifiedFiles.length > 0 ? (
|
||||
<button type="button" data-testid={`open-task-changes-${task.id}`} onClick={() => onOpenDetailWithTab?.(task, "changes")}>
|
||||
Files changed
|
||||
</button>
|
||||
) : null}
|
||||
<main id="board" className="board" data-testid="board-view">
|
||||
<section className="column" data-column="todo">
|
||||
<div className="column-body" data-testid="todo-column-body">
|
||||
{tasks.map((task) => (
|
||||
<div key={task.id}>
|
||||
<button type="button" data-testid={`open-task-${task.id}`} onClick={() => onOpenDetail(task)}>
|
||||
{task.title}
|
||||
</button>
|
||||
{task.modifiedFiles && task.modifiedFiles.length > 0 ? (
|
||||
<button type="button" data-testid={`open-task-changes-${task.id}`} onClick={() => onOpenDetailWithTab?.(task, "changes")}>
|
||||
Files changed
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -674,6 +678,87 @@ describe("Navigation history integration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("returns desktop board-detail Back to board to the board without breaking history", async () => {
|
||||
mockUseViewportMode.mockReturnValue("desktop");
|
||||
const task = makeTask("FN-1", "Desktop Detail Card");
|
||||
mockUseTasks.mockImplementation(() => ({
|
||||
tasks: [task],
|
||||
createTask: mockCreateTask,
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
refreshTasks: vi.fn(),
|
||||
}));
|
||||
|
||||
await renderAppAndWait();
|
||||
const pushCallsBefore = (window.history.pushState as any).mock.calls.length;
|
||||
|
||||
fireEvent.click(screen.getByTestId("open-task-FN-1"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("task-detail-main-panel-content")).toBeTruthy();
|
||||
expect(screen.queryByTestId("board-view")).toBeNull();
|
||||
});
|
||||
expect((window.history.pushState as any).mock.calls.length).toBeGreaterThan(pushCallsBefore);
|
||||
|
||||
// FNXC:BoardNavigation 2026-06-29-20:45: Desktop board-card detail keeps the same full-panel Back-to-board history contract while mobile adds scroll restoration coverage.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Back to board" }));
|
||||
dispatchPopState({ navIndex: 0 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("task-detail-main-panel-content")).toBeNull();
|
||||
expect(screen.getByTestId("board-view")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("restores mobile board scroll after Back to board", async () => {
|
||||
mockUseViewportMode.mockReturnValue("mobile");
|
||||
const task = makeTask("FN-1", "Scrolled Mobile Card");
|
||||
mockUseTasks.mockImplementation(() => ({
|
||||
tasks: [task],
|
||||
createTask: mockCreateTask,
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
refreshTasks: vi.fn(),
|
||||
}));
|
||||
|
||||
await renderMobileAppAndWait();
|
||||
|
||||
const board = screen.getByTestId("board-view");
|
||||
const todoBody = screen.getByTestId("todo-column-body");
|
||||
board.scrollLeft = 240;
|
||||
todoBody.scrollTop = 380;
|
||||
|
||||
// FNXC:BoardNavigation 2026-06-29-20:45: Mobile Back-to-board must return to the clicked card's horizontal board offset and vertical lane offset after the full-panel detail replaces the board.
|
||||
fireEvent.click(screen.getByTestId("open-task-FN-1"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("task-detail-main-panel-content")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Back to board" }));
|
||||
dispatchPopState({ navIndex: 0 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("task-detail-main-panel-content")).toBeNull();
|
||||
expect(screen.getByTestId("board-view").scrollLeft).toBe(240);
|
||||
expect(screen.getByTestId("todo-column-body").scrollTop).toBe(380);
|
||||
});
|
||||
});
|
||||
|
||||
it("opens board files-changed actions inline on the changes tab instead of in a modal", async () => {
|
||||
const task = {
|
||||
...makeTask("FN-1", "Inline Changes Detail"),
|
||||
|
||||
@@ -34,7 +34,15 @@ describe("useBoardScrollRestore", () => {
|
||||
});
|
||||
|
||||
it("restores the captured snapshot after returning to the board view", () => {
|
||||
const sentinel = { boardLeft: 42, boardTop: 7, columnTops: { c1: 3 } };
|
||||
const sentinel = {
|
||||
boardLeft: 42,
|
||||
boardTop: 7,
|
||||
columnTops: { c1: 3 },
|
||||
projectContentLeft: 0,
|
||||
projectContentTop: 0,
|
||||
documentLeft: 0,
|
||||
documentTop: 0,
|
||||
};
|
||||
mockedCapture.mockReturnValue(sentinel);
|
||||
|
||||
// Make the double requestAnimationFrame fire synchronously so the restore
|
||||
@@ -67,4 +75,37 @@ describe("useBoardScrollRestore", () => {
|
||||
expect(mockedRestore).toHaveBeenCalledTimes(1);
|
||||
expect(mockedRestore).toHaveBeenCalledWith(sentinel);
|
||||
});
|
||||
|
||||
it("retries while the board is not yet ready after returning to the board view", () => {
|
||||
const sentinel = {
|
||||
boardLeft: 240,
|
||||
boardTop: 0,
|
||||
columnTops: { todo: 380 },
|
||||
projectContentLeft: 0,
|
||||
projectContentTop: 0,
|
||||
documentLeft: 0,
|
||||
documentTop: 0,
|
||||
};
|
||||
mockedCapture.mockReturnValue(sentinel);
|
||||
mockedRestore.mockReturnValueOnce(false).mockReturnValueOnce(true);
|
||||
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ taskView }: { taskView: TaskView }) => useBoardScrollRestore(taskView),
|
||||
{ initialProps: { taskView: "task-detail" } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.capture();
|
||||
result.current.requestRestore();
|
||||
rerender({ taskView: "board" });
|
||||
});
|
||||
|
||||
expect(mockedRestore).toHaveBeenCalledTimes(2);
|
||||
expect(mockedRestore).toHaveBeenLastCalledWith(sentinel);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/*
|
||||
FNXC:BoardNavigation 2026-06-24-00:00:
|
||||
Preserves horizontal board scroll and per-column vertical scroll across a board → task-detail → back-to-board round trip. capture() snapshots before opening detail; requestRestore() schedules a restore that fires (double requestAnimationFrame, after the board remounts) once the view returns to "board". Extracted from AppInner.
|
||||
|
||||
FNXC:BoardNavigation 2026-06-29-20:45:
|
||||
Mobile Back-to-board must restore the clicked-card board position after the full-panel detail unmounts. Retry the restore for a bounded sequence of animation frames because mobile board layout stabilization and workflow-board hydration can temporarily leave #board unavailable or reset its offsets after the first post-return frame.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
@@ -11,6 +14,8 @@ import {
|
||||
} from "../utils/boardScrollSnapshot";
|
||||
import type { TaskView } from "./useViewState";
|
||||
|
||||
const MAX_RESTORE_ATTEMPTS = 6;
|
||||
|
||||
export interface UseBoardScrollRestoreResult {
|
||||
capture: () => void;
|
||||
requestRestore: () => void;
|
||||
@@ -20,12 +25,6 @@ export function useBoardScrollRestore(taskView: TaskView): UseBoardScrollRestore
|
||||
const boardScrollSnapshotRef = useRef<BoardScrollSnapshot | null>(null);
|
||||
const pendingBoardScrollRestoreRef = useRef(false);
|
||||
|
||||
const restore = useCallback(() => {
|
||||
if (restoreBoardScrollSnapshot(boardScrollSnapshotRef.current)) {
|
||||
pendingBoardScrollRestoreRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const capture = useCallback(() => {
|
||||
boardScrollSnapshotRef.current = captureBoardScrollSnapshot();
|
||||
}, []);
|
||||
@@ -42,16 +41,36 @@ export function useBoardScrollRestore(taskView: TaskView): UseBoardScrollRestore
|
||||
const cancelFrame = typeof window.cancelAnimationFrame === "function"
|
||||
? window.cancelAnimationFrame.bind(window)
|
||||
: window.clearTimeout.bind(window);
|
||||
let firstFrame = 0;
|
||||
let secondFrame = 0;
|
||||
firstFrame = scheduleFrame(() => {
|
||||
secondFrame = scheduleFrame(restore);
|
||||
const frameIds: number[] = [];
|
||||
let attempts = 0;
|
||||
let cancelled = false;
|
||||
|
||||
const schedule = (callback: FrameRequestCallback) => {
|
||||
const id = scheduleFrame(callback);
|
||||
frameIds.push(id);
|
||||
};
|
||||
|
||||
const attemptRestore = () => {
|
||||
if (cancelled || !pendingBoardScrollRestoreRef.current) return;
|
||||
attempts += 1;
|
||||
const restored = restoreBoardScrollSnapshot(boardScrollSnapshotRef.current);
|
||||
if (restored) {
|
||||
pendingBoardScrollRestoreRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (attempts < MAX_RESTORE_ATTEMPTS) {
|
||||
schedule(attemptRestore);
|
||||
}
|
||||
};
|
||||
|
||||
schedule(() => {
|
||||
schedule(attemptRestore);
|
||||
});
|
||||
return () => {
|
||||
cancelFrame(firstFrame);
|
||||
cancelFrame(secondFrame);
|
||||
cancelled = true;
|
||||
frameIds.forEach(cancelFrame);
|
||||
};
|
||||
}, [restore, taskView]);
|
||||
}, [taskView]);
|
||||
|
||||
return { capture, requestRestore };
|
||||
}
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { captureBoardScrollSnapshot, restoreBoardScrollSnapshot } from "../boardScrollSnapshot";
|
||||
|
||||
describe("boardScrollSnapshot", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete (document as Document & { scrollingElement?: Element | null }).scrollingElement;
|
||||
Object.defineProperty(window, "scrollX", { configurable: true, writable: true, value: 0 });
|
||||
Object.defineProperty(window, "scrollY", { configurable: true, writable: true, value: 0 });
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("round-trips board horizontal scroll and per-column vertical scroll", () => {
|
||||
document.body.innerHTML = `
|
||||
<main id="board">
|
||||
<section class="column" data-column="todo"><div class="column-body"></div></section>
|
||||
<section class="column" data-column="in-progress"><div class="column-body"></div></section>
|
||||
</main>
|
||||
<div class="project-content">
|
||||
<main id="board">
|
||||
<section class="column" data-column="todo"><div class="column-body"></div></section>
|
||||
<section class="column" data-column="in-progress"><div class="column-body"></div></section>
|
||||
</main>
|
||||
</div>
|
||||
`;
|
||||
const projectContent = document.querySelector(".project-content") as HTMLElement;
|
||||
const board = document.getElementById("board") as HTMLElement;
|
||||
const todoBody = document.querySelector('[data-column="todo"] .column-body') as HTMLElement;
|
||||
const activeBody = document.querySelector('[data-column="in-progress"] .column-body') as HTMLElement;
|
||||
|
||||
projectContent.scrollLeft = 4;
|
||||
projectContent.scrollTop = 22;
|
||||
board.scrollLeft = 240;
|
||||
board.scrollTop = 12;
|
||||
todoBody.scrollTop = 380;
|
||||
@@ -20,22 +33,88 @@ describe("boardScrollSnapshot", () => {
|
||||
|
||||
const snapshot = captureBoardScrollSnapshot();
|
||||
|
||||
projectContent.scrollLeft = 0;
|
||||
projectContent.scrollTop = 0;
|
||||
board.scrollLeft = 0;
|
||||
board.scrollTop = 0;
|
||||
todoBody.scrollTop = 0;
|
||||
activeBody.scrollTop = 0;
|
||||
|
||||
expect(restoreBoardScrollSnapshot(snapshot)).toBe(true);
|
||||
expect(projectContent.scrollLeft).toBe(4);
|
||||
expect(projectContent.scrollTop).toBe(22);
|
||||
expect(board.scrollLeft).toBe(240);
|
||||
expect(board.scrollTop).toBe(12);
|
||||
expect(todoBody.scrollTop).toBe(380);
|
||||
expect(activeBody.scrollTop).toBe(95);
|
||||
});
|
||||
|
||||
it("round-trips document scroll without requiring the project-content shell", () => {
|
||||
document.body.innerHTML = `
|
||||
<main id="board">
|
||||
<section class="column" data-column="todo"><div class="column-body"></div></section>
|
||||
</main>
|
||||
`;
|
||||
Object.defineProperty(document, "scrollingElement", {
|
||||
configurable: true,
|
||||
value: document.documentElement,
|
||||
});
|
||||
Object.defineProperty(window, "scrollX", { configurable: true, writable: true, value: 0 });
|
||||
Object.defineProperty(window, "scrollY", { configurable: true, writable: true, value: 0 });
|
||||
const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation((leftOrOptions?: number | ScrollToOptions, top?: number) => {
|
||||
if (typeof leftOrOptions === "number") {
|
||||
Object.defineProperty(window, "scrollX", { configurable: true, writable: true, value: leftOrOptions });
|
||||
Object.defineProperty(window, "scrollY", { configurable: true, writable: true, value: top ?? window.scrollY });
|
||||
return;
|
||||
}
|
||||
Object.defineProperty(window, "scrollX", { configurable: true, writable: true, value: leftOrOptions?.left ?? window.scrollX });
|
||||
Object.defineProperty(window, "scrollY", { configurable: true, writable: true, value: leftOrOptions?.top ?? window.scrollY });
|
||||
});
|
||||
const board = document.getElementById("board") as HTMLElement;
|
||||
|
||||
board.scrollLeft = 40;
|
||||
board.scrollTop = 8;
|
||||
document.documentElement.scrollLeft = 13;
|
||||
document.documentElement.scrollTop = 144;
|
||||
|
||||
const snapshot = captureBoardScrollSnapshot();
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
boardLeft: 40,
|
||||
boardTop: 8,
|
||||
projectContentLeft: 0,
|
||||
projectContentTop: 0,
|
||||
documentLeft: 13,
|
||||
documentTop: 144,
|
||||
});
|
||||
|
||||
board.scrollLeft = 0;
|
||||
board.scrollTop = 0;
|
||||
document.documentElement.scrollLeft = 0;
|
||||
document.documentElement.scrollTop = 0;
|
||||
Object.defineProperty(window, "scrollX", { configurable: true, writable: true, value: 0 });
|
||||
Object.defineProperty(window, "scrollY", { configurable: true, writable: true, value: 0 });
|
||||
|
||||
expect(restoreBoardScrollSnapshot(snapshot)).toBe(true);
|
||||
expect(document.documentElement.scrollLeft).toBe(13);
|
||||
expect(document.documentElement.scrollTop).toBe(144);
|
||||
expect(board.scrollLeft).toBe(40);
|
||||
expect(board.scrollTop).toBe(8);
|
||||
expect(scrollTo).toHaveBeenCalledWith(13, 144);
|
||||
});
|
||||
|
||||
it("returns false when the board is not mounted", () => {
|
||||
document.body.innerHTML = "";
|
||||
|
||||
expect(captureBoardScrollSnapshot()).toBeNull();
|
||||
expect(restoreBoardScrollSnapshot({ boardLeft: 10, boardTop: 0, columnTops: {} })).toBe(false);
|
||||
expect(restoreBoardScrollSnapshot({
|
||||
boardLeft: 10,
|
||||
boardTop: 0,
|
||||
columnTops: {},
|
||||
projectContentLeft: 0,
|
||||
projectContentTop: 0,
|
||||
documentLeft: 0,
|
||||
documentTop: 0,
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,10 @@ export interface BoardScrollSnapshot {
|
||||
boardLeft: number;
|
||||
boardTop: number;
|
||||
columnTops: Record<string, number>;
|
||||
projectContentLeft: number;
|
||||
projectContentTop: number;
|
||||
documentLeft: number;
|
||||
documentTop: number;
|
||||
}
|
||||
|
||||
function getBoardDocument(doc?: Document): Document | null {
|
||||
@@ -12,12 +16,19 @@ function getBoardDocument(doc?: Document): Document | null {
|
||||
/*
|
||||
FNXC:BoardNavigation 2026-06-22-20:15:
|
||||
Board-card task detail replaces the board instead of overlaying it. Capture horizontal board scroll and per-column vertical scroll before opening detail, then restore after Back to board remounts the board so users return to the same lane/card context.
|
||||
|
||||
FNXC:BoardNavigation 2026-06-29-20:45:
|
||||
Mobile Back-to-board must restore the clicked-card board position even when the browser parks scroll on the project-content/document shell during the full-panel task-detail transition. Snapshot the shell offsets alongside #board and .column-body; CSS keeps #board as the horizontal scroller and .column-body as the vertical lane scroller, but restoring the shell defensively prevents mobile viewport drift from hiding the clicked card after return.
|
||||
*/
|
||||
export function captureBoardScrollSnapshot(doc?: Document): BoardScrollSnapshot | null {
|
||||
const ownerDocument = getBoardDocument(doc);
|
||||
const board = ownerDocument?.getElementById("board") as HTMLElement | null;
|
||||
if (!ownerDocument) return null;
|
||||
const board = ownerDocument.getElementById("board") as HTMLElement | null;
|
||||
if (!board) return null;
|
||||
|
||||
const projectContent = ownerDocument.querySelector<HTMLElement>(".project-content");
|
||||
const scrollingElement = ownerDocument.scrollingElement as HTMLElement | null;
|
||||
const defaultView = ownerDocument.defaultView;
|
||||
const columnTops: Record<string, number> = {};
|
||||
board.querySelectorAll<HTMLElement>(".column[data-column]").forEach((column) => {
|
||||
const columnId = column.dataset.column;
|
||||
@@ -31,15 +42,41 @@ export function captureBoardScrollSnapshot(doc?: Document): BoardScrollSnapshot
|
||||
boardLeft: board.scrollLeft,
|
||||
boardTop: board.scrollTop,
|
||||
columnTops,
|
||||
projectContentLeft: projectContent?.scrollLeft ?? 0,
|
||||
projectContentTop: projectContent?.scrollTop ?? 0,
|
||||
documentLeft: scrollingElement?.scrollLeft ?? defaultView?.scrollX ?? 0,
|
||||
documentTop: scrollingElement?.scrollTop ?? defaultView?.scrollY ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function restoreBoardScrollSnapshot(snapshot: BoardScrollSnapshot | null, doc?: Document): boolean {
|
||||
if (!snapshot) return false;
|
||||
const ownerDocument = getBoardDocument(doc);
|
||||
const board = ownerDocument?.getElementById("board") as HTMLElement | null;
|
||||
if (!ownerDocument) return false;
|
||||
const board = ownerDocument.getElementById("board") as HTMLElement | null;
|
||||
if (!board) return false;
|
||||
|
||||
const projectContent = ownerDocument.querySelector<HTMLElement>(".project-content");
|
||||
const scrollingElement = ownerDocument.scrollingElement as HTMLElement | null;
|
||||
const defaultView = ownerDocument.defaultView;
|
||||
if (projectContent) {
|
||||
projectContent.scrollLeft = snapshot.projectContentLeft ?? 0;
|
||||
projectContent.scrollTop = snapshot.projectContentTop ?? 0;
|
||||
}
|
||||
if (scrollingElement) {
|
||||
scrollingElement.scrollLeft = snapshot.documentLeft ?? 0;
|
||||
scrollingElement.scrollTop = snapshot.documentTop ?? 0;
|
||||
}
|
||||
const documentLeft = snapshot.documentLeft ?? 0;
|
||||
const documentTop = snapshot.documentTop ?? 0;
|
||||
if (defaultView && (defaultView.scrollX !== documentLeft || defaultView.scrollY !== documentTop)) {
|
||||
try {
|
||||
defaultView.scrollTo(documentLeft, documentTop);
|
||||
} catch {
|
||||
// Test DOMs may expose scrollTo without implementing it; element offsets above still cover the restore contract.
|
||||
}
|
||||
}
|
||||
|
||||
board.scrollLeft = snapshot.boardLeft;
|
||||
board.scrollTop = snapshot.boardTop;
|
||||
board.querySelectorAll<HTMLElement>(".column[data-column]").forEach((column) => {
|
||||
|
||||
Reference in New Issue
Block a user