diff --git a/CHANGELOG.md b/CHANGELOG.md index b25cdad..ad2a3a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/ ### Fixed +- **Pop-out windows restored onto the wrong monitor** — dockview serializes each pop-out's position as absolute screen coordinates but re-adds the main window's `screenX/screenY` on restore, double-offsetting the window whenever the main window isn't at the screen origin (a multi-monitor setup). `loadLayout` now pre-subtracts the main window's current origin before `fromJSON`, so restored pop-outs land at their true saved coordinates. - **Preset editor crash** — opening the Edit Preset dialog (e.g. right after creating a preset) threw `DataCloneError: … structuredClone … could not be cloned` because it tried to `structuredClone` the preset's reactive proxy config, leaving the edit form unpopulated. It now clones the config via a JSON round-trip (configs are serializable by contract), so the editor opens and populates correctly. ### Added diff --git a/src/modules/dockview/popoutOffset.ts b/src/modules/dockview/popoutOffset.ts new file mode 100644 index 0000000..24e037f --- /dev/null +++ b/src/modules/dockview/popoutOffset.ts @@ -0,0 +1,68 @@ +/** + * Correct dockview's pop-out restore double-offset. + * + * dockview's `toJSON()` serializes each pop-out group's `position` as ABSOLUTE + * screen coordinates (the pop-out window's `screenX`/`screenY` at save time). + * On restore, `fromJSON` → `addPopoutGroup` re-opens the window at + * `window.screenX + position.left` / `window.screenY + position.top` — adding + * the MAIN window's screen offset to coordinates that are *already* absolute + * (dockview-core 6.6.1, `dockview-core.js:14580-14581`). On a multi-monitor + * setup — or any time the main window is not at the screen origin — the restored + * pop-out therefore drifts by the main window's offset and can land on the wrong + * monitor. + * + * This helper pre-subtracts the main window's current screen origin from each + * saved pop-out `position`, so dockview's re-addition cancels out and the window + * lands at its true saved absolute coordinates regardless of where the main + * window currently sits. + * + * Pure and side-effect-free: returns a corrected shallow copy and never mutates + * the input (which is the persisted layout blob). No-op when there are no + * pop-out groups or the origin is (0, 0). + * + * NOTE (CLAUDE.md library-gotcha rule): this compensates for dockview's internal + * offset math — re-verify against `dockview-core` on every major bump. + */ + +/** The `Box` dockview serializes per pop-out group (`types.d.ts` `Box`). */ +export interface PopoutBox { + left: number; + top: number; + width: number; + height: number; +} + +/** The slice of a serialized pop-out group this helper touches. */ +export interface SerializedPopoutGroupLike { + position?: PopoutBox | null; +} + +/** The slice of dockview's `SerializedDockview` this helper touches. */ +export interface DockviewStateLike { + popoutGroups?: SerializedPopoutGroupLike[]; +} + +export function correctPopoutRestoreOffset( + state: T, + originX: number, + originY: number, +): T { + const groups = state.popoutGroups; + if (!groups || groups.length === 0) return state; + if (originX === 0 && originY === 0) return state; + return { + ...state, + popoutGroups: groups.map((group) => + group && group.position + ? { + ...group, + position: { + ...group.position, + left: group.position.left - originX, + top: group.position.top - originY, + }, + } + : group, + ), + }; +} diff --git a/src/stores/session.ts b/src/stores/session.ts index 0e40718..fac9dd2 100644 --- a/src/stores/session.ts +++ b/src/stores/session.ts @@ -7,6 +7,10 @@ import { ref, shallowRef } from "vue"; import { useNotify } from "@/composables/useNotify"; import { trackPopoutWindow, untrackPopoutWindow } from "@/composables/usePopoutWindows"; +import { + correctPopoutRestoreOffset, + type DockviewStateLike, +} from "@/modules/dockview/popoutOffset"; import { type FloatBox, floatWasHeaderless, @@ -125,7 +129,16 @@ export const useSessionStore = defineStore("session", () => { api.clear(); if (layout.dockviewState) { try { - api.fromJSON(layout.dockviewState as Parameters[0]); + // Correct dockview's pop-out restore double-offset before fromJSON: it + // re-opens each pop-out at `window.screenX + position.left`, but the + // saved `position` is already absolute screen coords — so pre-subtract + // the main window's current origin (see correctPopoutRestoreOffset). + const corrected = correctPopoutRestoreOffset( + layout.dockviewState as DockviewStateLike, + window.screenX, + window.screenY, + ); + api.fromJSON(corrected as Parameters[0]); } catch { rebuildFromPanelStates(api, panelStates); } diff --git a/tests/unit/modules/dockview/popoutOffset.spec.ts b/tests/unit/modules/dockview/popoutOffset.spec.ts new file mode 100644 index 0000000..88fd8d1 --- /dev/null +++ b/tests/unit/modules/dockview/popoutOffset.spec.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; + +import { + correctPopoutRestoreOffset, + type DockviewStateLike, +} from "@/modules/dockview/popoutOffset"; + +describe("correctPopoutRestoreOffset", () => { + it("subtracts the main-window origin from each pop-out position so dockview's re-addition cancels", () => { + const ORIGIN_X = 100; + const ORIGIN_Y = 40; + const saved = { + grid: { root: "serialized-grid" }, + floatingGroups: [{ data: { id: "float" } }], + popoutGroups: [ + { data: { id: "g1" }, position: { left: 2000, top: 300, width: 800, height: 600 } }, + { data: { id: "g2" }, position: { left: -1440, top: 0, width: 1024, height: 768 } }, + ], + }; + + const corrected = correctPopoutRestoreOffset(saved, ORIGIN_X, ORIGIN_Y); + + // left/top reduced by the origin; width/height + sibling fields untouched. + expect(corrected.popoutGroups[0]!.position).toEqual({ + left: 1900, + top: 260, + width: 800, + height: 600, + }); + expect(corrected.popoutGroups[1]!.position).toEqual({ + left: -1540, + top: -40, + width: 1024, + height: 768, + }); + expect(corrected.popoutGroups[0]!.data).toEqual({ id: "g1" }); + // The rest of the dockview state is preserved verbatim. + expect(corrected.grid).toEqual(saved.grid); + expect(corrected.floatingGroups).toEqual(saved.floatingGroups); + + // Core invariant: dockview re-adds the origin on restore → the window lands + // at its true saved absolute coordinates. + expect(corrected.popoutGroups[0]!.position!.left + ORIGIN_X).toBe(2000); + expect(corrected.popoutGroups[0]!.position!.top + ORIGIN_Y).toBe(300); + }); + + it("does not mutate the input blob", () => { + const saved = { + popoutGroups: [{ position: { left: 2000, top: 300, width: 800, height: 600 } }], + }; + const snapshot = structuredClone(saved); + correctPopoutRestoreOffset(saved, 100, 40); + expect(saved).toEqual(snapshot); + }); + + it("is a no-op (same reference) when the main window is at the screen origin", () => { + const saved = { + popoutGroups: [{ position: { left: 2000, top: 300, width: 800, height: 600 } }], + }; + expect(correctPopoutRestoreOffset(saved, 0, 0)).toBe(saved); + }); + + it("is a no-op (same reference) when there are no pop-out groups", () => { + const noField: DockviewStateLike = {}; + expect(correctPopoutRestoreOffset(noField, 100, 40)).toBe(noField); + + const empty: DockviewStateLike = { popoutGroups: [] }; + expect(correctPopoutRestoreOffset(empty, 100, 40)).toBe(empty); + }); + + it("passes through a pop-out group whose position is null", () => { + const saved = { + popoutGroups: [ + { position: null }, + { position: { left: 500, top: 200, width: 400, height: 300 } }, + ], + }; + const corrected = correctPopoutRestoreOffset(saved, 100, 40); + expect(corrected.popoutGroups[0]!.position).toBeNull(); + expect(corrected.popoutGroups[1]!.position).toEqual({ + left: 400, + top: 160, + width: 400, + height: 300, + }); + }); +});