Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions src/modules/dockview/popoutOffset.ts
Original file line number Diff line number Diff line change
@@ -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<T extends DockviewStateLike>(
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,
),
};
}
15 changes: 14 additions & 1 deletion src/stores/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -125,7 +129,16 @@ export const useSessionStore = defineStore("session", () => {
api.clear();
if (layout.dockviewState) {
try {
api.fromJSON(layout.dockviewState as Parameters<DockviewApi["fromJSON"]>[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<DockviewApi["fromJSON"]>[0]);
} catch {
rebuildFromPanelStates(api, panelStates);
}
Expand Down
87 changes: 87 additions & 0 deletions tests/unit/modules/dockview/popoutOffset.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
Loading