| author | |
| committer | |
| log | d8a6dfd31539a86d08bd4fbd030f8252b3c405ac |
| tree | db0f2626ebe7fd5e8559d1e132db7e2569421d20 |
| parent | e9ae134c59bedd39428e1d279df0ada3e86cd500 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>4 files changed, 219 insertions(+), 38 deletions(-)
apps/desktop/src/preview/Manager.test.ts+119-15| ... | @@ -979,7 +979,10 @@ describe("PreviewManager", () => { | ... | @@ -979,7 +979,10 @@ describe("PreviewManager", () => { |
| 979 | ), | 979 | ), |
| 980 | ); | 980 | ); |
| 981 | 981 | ||
| 982 | effectIt.effect("mirrors Electron's effective zoom across registration and navigation", () => | 982 | // The guest reports whatever zoom level Chromium handed it from the app |
| 983 | // window, so the tab's own zoom is the source of truth in both directions: | ||
| 984 | // asserted onto every guest, never read back off one. | ||
| 985 | effectIt.effect("keeps the tab's own zoom instead of the guest's reported zoom", () => | ||
| 983 | withManager((manager) => | 986 | withManager((manager) => |
| 984 | Effect.gen(function* () { | 987 | Effect.gen(function* () { |
| 985 | let effectiveZoom = 0.9; | 988 | let effectiveZoom = 0.9; |
| ... | @@ -1025,18 +1028,13 @@ describe("PreviewManager", () => { | ... | @@ -1025,18 +1028,13 @@ describe("PreviewManager", () => { |
| 1025 | yield* manager.createTab("tab_zoom"); | 1028 | yield* manager.createTab("tab_zoom"); |
| 1026 | yield* manager.registerWebview("tab_zoom", 42); | 1029 | yield* manager.registerWebview("tab_zoom", 42); |
| 1027 | 1030 | ||
| 1028 | expect(states.at(-1)?.zoomFactor).toBe(0.9); | 1031 | expect(states.at(-1)?.zoomFactor).toBe(1); |
| 1029 | expect(setZoomFactor).not.toHaveBeenCalled(); | 1032 | expect(setZoomFactor).toHaveBeenCalledWith(1); |
| 1030 | 1033 | ||
| 1031 | effectiveZoom = 1.25; | 1034 | // An app zoom leaves the guest reporting the inherited level. Navigating |
| 1032 | listeners.get("did-navigate")?.(); | 1035 | // must not adopt it as the preview's zoom. |
| 1033 | yield* Effect.yieldNow; | 1036 | effectiveZoom = 0.8; |
| 1034 | 1037 | url = "https://example.com/after-app-zoom"; | |
| 1035 | expect(states.at(-1)?.zoomFactor).toBe(1.25); | ||
| 1036 | expect(setZoomFactor).not.toHaveBeenCalled(); | ||
| 1037 | |||
| 1038 | zoomReadable = false; | ||
| 1039 | url = "https://example.com/after-zoom-read-failed"; | ||
| 1040 | listeners.get("did-navigate")?.(); | 1038 | listeners.get("did-navigate")?.(); |
| 1041 | yield* Effect.yieldNow; | 1039 | yield* Effect.yieldNow; |
| 1042 | 1040 | ||
| ... | @@ -1045,7 +1043,18 @@ describe("PreviewManager", () => { | ... | @@ -1045,7 +1043,18 @@ describe("PreviewManager", () => { |
| 1045 | url, | 1043 | url, |
| 1046 | title: "Example", | 1044 | title: "Example", |
| 1047 | }); | 1045 | }); |
| 1048 | expect(states.at(-1)?.zoomFactor).toBe(1.25); | 1046 | expect(states.at(-1)?.zoomFactor).toBe(1); |
| 1047 | |||
| 1048 | // Only the preview's own zoom controls move it. | ||
| 1049 | yield* manager.zoomIn("tab_zoom"); | ||
| 1050 | expect(setZoomFactor).toHaveBeenCalledWith(1.1); | ||
| 1051 | expect(states.at(-1)?.zoomFactor).toBe(1.1); | ||
| 1052 | |||
| 1053 | zoomReadable = false; | ||
| 1054 | listeners.get("did-navigate")?.(); | ||
| 1055 | yield* Effect.yieldNow; | ||
| 1056 | |||
| 1057 | expect(states.at(-1)?.zoomFactor).toBe(1.1); | ||
| 1049 | 1058 | ||
| 1050 | const replacementSetZoomFactor = vi.fn(); | 1059 | const replacementSetZoomFactor = vi.fn(); |
| 1051 | fromId.mockReturnValue({ | 1060 | fromId.mockReturnValue({ |
| ... | @@ -1074,8 +1083,103 @@ describe("PreviewManager", () => { | ... | @@ -1074,8 +1083,103 @@ describe("PreviewManager", () => { |
| 1074 | 1083 | ||
| 1075 | yield* manager.registerWebview("tab_zoom", 43); | 1084 | yield* manager.registerWebview("tab_zoom", 43); |
| 1076 | 1085 | ||
| 1077 | expect(replacementSetZoomFactor).toHaveBeenCalledWith(1.25); | 1086 | expect(replacementSetZoomFactor).toHaveBeenCalledWith(1.1); |
| 1078 | expect(states.at(-1)?.zoomFactor).toBe(1.25); | 1087 | expect(states.at(-1)?.zoomFactor).toBe(1.1); |
| 1088 | }), | ||
| 1089 | ), | ||
| 1090 | ); | ||
| 1091 | |||
| 1092 | // Zooming the app UI pushes the window's zoom level onto every guest, so the | ||
| 1093 | // preview has to be put back at the zoom the user gave it. | ||
| 1094 | effectIt.effect("re-applies each tab's own zoom when the app window zooms", () => | ||
| 1095 | withManager((manager) => | ||
| 1096 | Effect.gen(function* () { | ||
| 1097 | const setZoomFactor = vi.fn(); | ||
| 1098 | fromId.mockReturnValue({ | ||
| 1099 | id: 42, | ||
| 1100 | isDestroyed: () => false, | ||
| 1101 | getType: () => "webview", | ||
| 1102 | getURL: () => "https://example.com", | ||
| 1103 | getTitle: () => "Example", | ||
| 1104 | isLoading: () => false, | ||
| 1105 | getZoomFactor: () => 1, | ||
| 1106 | setZoomFactor, | ||
| 1107 | on: vi.fn(), | ||
| 1108 | off: vi.fn(), | ||
| 1109 | ipc: { on: vi.fn(), off: vi.fn() }, | ||
| 1110 | send: webviewSend, | ||
| 1111 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | ||
| 1112 | setWindowOpenHandler: vi.fn(), | ||
| 1113 | debugger: { | ||
| 1114 | isAttached: () => false, | ||
| 1115 | attach: vi.fn(), | ||
| 1116 | sendCommand: vi.fn(async () => undefined), | ||
| 1117 | on: vi.fn(), | ||
| 1118 | off: vi.fn(), | ||
| 1119 | }, | ||
| 1120 | } as never); | ||
| 1121 | |||
| 1122 | yield* manager.createTab("tab_reapply"); | ||
| 1123 | yield* manager.registerWebview("tab_reapply", 42); | ||
| 1124 | yield* manager.zoomIn("tab_reapply"); | ||
| 1125 | setZoomFactor.mockClear(); | ||
| 1126 | |||
| 1127 | yield* manager.reapplyZoom(); | ||
| 1128 | |||
| 1129 | expect(setZoomFactor).toHaveBeenCalledTimes(1); | ||
| 1130 | expect(setZoomFactor).toHaveBeenCalledWith(1.1); | ||
| 1131 | }), | ||
| 1132 | ), | ||
| 1133 | ); | ||
| 1134 | |||
| 1135 | // did-attach and dom-ready both re-register the guest that is already | ||
| 1136 | // attached, and a guest that just inherited the app window's zoom needs its | ||
| 1137 | // own back — without that round trip republishing tab state. | ||
| 1138 | effectIt.effect("re-asserts the tab's zoom when the active guest registers again", () => | ||
| 1139 | withManager((manager) => | ||
| 1140 | Effect.gen(function* () { | ||
| 1141 | const setZoomFactor = vi.fn(); | ||
| 1142 | fromId.mockReturnValue({ | ||
| 1143 | id: 42, | ||
| 1144 | isDestroyed: () => false, | ||
| 1145 | getType: () => "webview", | ||
| 1146 | getURL: () => "https://example.com", | ||
| 1147 | getTitle: () => "Example", | ||
| 1148 | isLoading: () => false, | ||
| 1149 | getZoomFactor: () => 1, | ||
| 1150 | setZoomFactor, | ||
| 1151 | on: vi.fn(), | ||
| 1152 | off: vi.fn(), | ||
| 1153 | ipc: { on: vi.fn(), off: vi.fn() }, | ||
| 1154 | send: webviewSend, | ||
| 1155 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | ||
| 1156 | setWindowOpenHandler: vi.fn(), | ||
| 1157 | debugger: { | ||
| 1158 | isAttached: () => false, | ||
| 1159 | attach: vi.fn(), | ||
| 1160 | sendCommand: vi.fn(async () => undefined), | ||
| 1161 | on: vi.fn(), | ||
| 1162 | off: vi.fn(), | ||
| 1163 | }, | ||
| 1164 | } as never); | ||
| 1165 | const states: PreviewManager.PreviewTabState[] = []; | ||
| 1166 | yield* manager.subscribeStateChanges((_tabId, state) => | ||
| 1167 | Effect.sync(() => { | ||
| 1168 | states.push(state); | ||
| 1169 | }), | ||
| 1170 | ); | ||
| 1171 | |||
| 1172 | yield* manager.createTab("tab_reregister_zoom"); | ||
| 1173 | yield* manager.registerWebview("tab_reregister_zoom", 42); | ||
| 1174 | yield* manager.zoomIn("tab_reregister_zoom"); | ||
| 1175 | setZoomFactor.mockClear(); | ||
| 1176 | const publishedBefore = states.length; | ||
| 1177 | |||
| 1178 | yield* manager.registerWebview("tab_reregister_zoom", 42); | ||
| 1179 | |||
| 1180 | expect(setZoomFactor).toHaveBeenCalledWith(1.1); | ||
| 1181 | expect(states.length).toBe(publishedBefore); | ||
| 1182 | expect(states.at(-1)?.zoomFactor).toBe(1.1); | ||
| 1079 | }), | 1183 | }), |
| 1080 | ), | 1184 | ), |
| 1081 | ); | 1185 | ); |
apps/desktop/src/preview/Manager.ts+49-23| ... | @@ -647,6 +647,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -647,6 +647,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 647 | if (Option.isSome(next)) yield* emit(tabId, next.value); | 647 | if (Option.isSome(next)) yield* emit(tabId, next.value); |
| 648 | }); | 648 | }); |
| 649 | 649 | ||
| 650 | /** | ||
| 651 | * Pushes a tab's zoom factor onto whichever guest it currently owns, reading | ||
| 652 | * both at call time. Anything that applies zoom after an await goes through | ||
| 653 | * here: a snapshot taken before the await can be older than a zoom action that | ||
| 654 | * landed in between, and re-applying it would roll that action back. | ||
| 655 | */ | ||
| 656 | const assertTabZoom = Effect.fn("PreviewManager.assertTabZoom")(function* (tabId: string) { | ||
| 657 | const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); | ||
| 658 | if (!tab || tab.webContentsId == null) return; | ||
| 659 | const wc = webContents.fromId(tab.webContentsId); | ||
| 660 | if (!wc || wc.isDestroyed()) return; | ||
| 661 | yield* attempt({ operation: "assertTabZoom", tabId, webContentsId: wc.id }, () => | ||
| 662 | wc.setZoomFactor(tab.zoomFactor), | ||
| 663 | ).pipe(Effect.ignore); | ||
| 664 | }); | ||
| 665 | |||
| 650 | const requireWebContents = Effect.fn("PreviewManager.requireWebContents")(function* ( | 666 | const requireWebContents = Effect.fn("PreviewManager.requireWebContents")(function* ( |
| 651 | tabId: string, | 667 | tabId: string, |
| 652 | ) { | 668 | ) { |
| ... | @@ -1305,10 +1321,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1305,10 +1321,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1305 | confirmedNavigation = false, | 1321 | confirmedNavigation = false, |
| 1306 | ) { | 1322 | ) { |
| 1307 | if (wc.isDestroyed()) return; | 1323 | if (wc.isDestroyed()) return; |
| 1308 | const zoomFactor = yield* attempt( | ||
| 1309 | { operation: "syncWebContentsState.getZoomFactor", tabId, webContentsId: wc.id }, | ||
| 1310 | () => wc.getZoomFactor(), | ||
| 1311 | ).pipe(Effect.option); | ||
| 1312 | const computedNavStatus = computeNavStatus(wc); | 1324 | const computedNavStatus = computeNavStatus(wc); |
| 1313 | const canGoBack = wc.navigationHistory.canGoBack(); | 1325 | const canGoBack = wc.navigationHistory.canGoBack(); |
| 1314 | const canGoForward = wc.navigationHistory.canGoForward(); | 1326 | const canGoForward = wc.navigationHistory.canGoForward(); |
| ... | @@ -1338,7 +1350,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1338,7 +1350,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1338 | navStatus, | 1350 | navStatus, |
| 1339 | canGoBack, | 1351 | canGoBack, |
| 1340 | canGoForward, | 1352 | canGoForward, |
| 1341 | ...(Option.isSome(zoomFactor) ? { zoomFactor: zoomFactor.value } : {}), | 1353 | // zoomFactor is deliberately not read back from the guest: Chromium |
| 1354 | // reports the level it inherited from the app window, so mirroring it | ||
| 1355 | // would turn an app zoom into the preview's own zoom. | ||
| 1342 | updatedAt, | 1356 | updatedAt, |
| 1343 | }; | 1357 | }; |
| 1344 | return [ | 1358 | return [ |
| ... | @@ -1716,11 +1730,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1716,11 +1730,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1716 | const annotationTheme = yield* Ref.get(annotationThemeRef); | 1730 | const annotationTheme = yield* Ref.get(annotationThemeRef); |
| 1717 | const currentAttachment = attached.get(webContentsId); | 1731 | const currentAttachment = attached.get(webContentsId); |
| 1718 | if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) { | 1732 | if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) { |
| 1719 | const zoomFactor = yield* attempt( | 1733 | // The guest we already own re-announced itself, so nothing about the tab |
| 1720 | { operation: "registerWebview.getZoomFactor", tabId, webContentsId }, | 1734 | // changed. Only push its zoom back down — Chromium may have just handed |
| 1721 | () => wc.getZoomFactor(), | 1735 | // this guest the app window's zoom level. |
| 1722 | ); | 1736 | yield* assertTabZoom(tabId); |
| 1723 | yield* update(tabId, { zoomFactor }); | ||
| 1724 | yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => | 1737 | yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => |
| 1725 | wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), | 1738 | wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), |
| 1726 | ); | 1739 | ); |
| ... | @@ -1749,18 +1762,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1749,18 +1762,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1749 | ) { | 1762 | ) { |
| 1750 | return yield* new PreviewTabNotFoundError({ tabId }); | 1763 | return yield* new PreviewTabNotFoundError({ tabId }); |
| 1751 | } | 1764 | } |
| 1752 | const zoomFactor = | 1765 | // Always assert the tab's own zoom rather than reading the guest's: a guest |
| 1753 | replacedWebContentsId !== null | 1766 | // attaching while the app UI is zoomed starts at the embedder's inherited |
| 1754 | ? yield* attempt( | 1767 | // zoom level, which is not the preview's zoom. Done before the guest is |
| 1755 | { operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, | 1768 | // published so it never paints a frame at the inherited zoom. |
| 1756 | () => { | 1769 | yield* attempt({ operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, () => |
| 1757 | wc.setZoomFactor(currentTab.zoomFactor); | 1770 | wc.setZoomFactor(currentTab.zoomFactor), |
| 1758 | return currentTab.zoomFactor; | 1771 | ); |
| 1759 | }, | ||
| 1760 | ) | ||
| 1761 | : yield* attempt({ operation: "registerWebview.getZoomFactor", tabId, webContentsId }, () => | ||
| 1762 | wc.getZoomFactor(), | ||
| 1763 | ); | ||
| 1764 | yield* attachListeners(tabId, wc); | 1772 | yield* attachListeners(tabId, wc); |
| 1765 | const registeredAt = yield* currentIso; | 1773 | const registeredAt = yield* currentIso; |
| 1766 | const registration = yield* SynchronizedRef.modifyEffect(tabsRef, (tabs) => | 1774 | const registration = yield* SynchronizedRef.modifyEffect(tabsRef, (tabs) => |
| ... | @@ -1784,7 +1792,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1784,7 +1792,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1784 | navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, | 1792 | navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, |
| 1785 | canGoBack: wc.navigationHistory.canGoBack(), | 1793 | canGoBack: wc.navigationHistory.canGoBack(), |
| 1786 | canGoForward: wc.navigationHistory.canGoForward(), | 1794 | canGoForward: wc.navigationHistory.canGoForward(), |
| 1787 | zoomFactor, | ||
| 1788 | updatedAt: registeredAt, | 1795 | updatedAt: registeredAt, |
| 1789 | }; | 1796 | }; |
| 1790 | return [ | 1797 | return [ |
| ... | @@ -1806,6 +1813,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1806,6 +1813,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1806 | return yield* new PreviewTabNotFoundError({ tabId }); | 1813 | return yield* new PreviewTabNotFoundError({ tabId }); |
| 1807 | } | 1814 | } |
| 1808 | const { state: registered, pendingUrl } = registration.value; | 1815 | const { state: registered, pendingUrl } = registration.value; |
| 1816 | // A zoom action that landed while this attach was in flight addressed the | ||
| 1817 | // guest this one replaced, so settle the new guest on the committed factor. | ||
| 1818 | yield* assertTabZoom(tabId); | ||
| 1809 | runFork(restoreControlSession(tabId, wc)); | 1819 | runFork(restoreControlSession(tabId, wc)); |
| 1810 | yield* emit(tabId, registered); | 1820 | yield* emit(tabId, registered); |
| 1811 | yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => | 1821 | yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => |
| ... | @@ -2099,6 +2109,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -2099,6 +2109,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2099 | ); | 2109 | ); |
| 2100 | }); | 2110 | }); |
| 2101 | 2111 | ||
| 2112 | /** | ||
| 2113 | * Chromium hands every guest `<webview>` the embedder's zoom level, so zooming | ||
| 2114 | * the app UI drags the previewed page along with it. The preview browser owns | ||
| 2115 | * its own zoom factor, so re-assert it on each attached guest whenever the main | ||
| 2116 | * window's zoom changes (see DesktopWindow.zoomMain). | ||
| 2117 | */ | ||
| 2118 | const reapplyZoom = Effect.fn("PreviewManager.reapplyZoom")(function* () { | ||
| 2119 | const tabIds = Array.from((yield* SynchronizedRef.get(tabsRef)).keys()); | ||
| 2120 | yield* Effect.forEach(tabIds, assertTabZoom, { discard: true }); | ||
| 2121 | }); | ||
| 2122 | |||
| 2102 | const applyZoom = Effect.fn("PreviewManager.applyZoom")(function* ( | 2123 | const applyZoom = Effect.fn("PreviewManager.applyZoom")(function* ( |
| 2103 | tabId: string, | 2124 | tabId: string, |
| 2104 | transform: (current: number) => number, | 2125 | transform: (current: number) => number, |
| ... | @@ -3476,6 +3497,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -3476,6 +3497,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 3476 | openPictureInPicture, | 3497 | openPictureInPicture, |
| 3477 | openDevTools, | 3498 | openDevTools, |
| 3478 | pickElement, | 3499 | pickElement, |
| 3500 | reapplyZoom, | ||
| 3479 | refresh, | 3501 | refresh, |
| 3480 | registerWebview, | 3502 | registerWebview, |
| 3481 | resetZoom: (tabId: string) => applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR), | 3503 | resetZoom: (tabId: string) => applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR), |
| ... | @@ -3774,6 +3796,9 @@ export class PreviewManager extends Context.Service< | ... | @@ -3774,6 +3796,9 @@ export class PreviewManager extends Context.Service< |
| 3774 | readonly zoomIn: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | 3796 | readonly zoomIn: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| 3775 | readonly zoomOut: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | 3797 | readonly zoomOut: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| 3776 | readonly resetZoom: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | 3798 | readonly resetZoom: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| 3799 | // Re-applies every attached guest's own zoom factor, undoing the zoom level | ||
| 3800 | // Chromium inherits from the embedder when the app UI zooms. | ||
| 3801 | readonly reapplyZoom: () => Effect.Effect<void>; | ||
| 3777 | readonly hardReload: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | 3802 | readonly hardReload: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| 3778 | readonly setColorScheme: ( | 3803 | readonly setColorScheme: ( |
| 3779 | tabId: string, | 3804 | tabId: string, |
| ... | @@ -3874,6 +3899,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { | ... | @@ -3874,6 +3899,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 3874 | zoomIn: operations.zoomIn, | 3899 | zoomIn: operations.zoomIn, |
| 3875 | zoomOut: operations.zoomOut, | 3900 | zoomOut: operations.zoomOut, |
| 3876 | resetZoom: operations.resetZoom, | 3901 | resetZoom: operations.resetZoom, |
| 3902 | reapplyZoom: operations.reapplyZoom, | ||
| 3877 | hardReload: operations.hardReload, | 3903 | hardReload: operations.hardReload, |
| 3878 | setColorScheme: operations.setColorScheme, | 3904 | setColorScheme: operations.setColorScheme, |
| 3879 | openDevTools: operations.openDevTools, | 3905 | openDevTools: operations.openDevTools, |
apps/desktop/src/window/DesktopWindow.test.ts+47| ... | @@ -61,9 +61,14 @@ const environmentInput = { | ... | @@ -61,9 +61,14 @@ const environmentInput = { |
| 61 | function makeFakeBrowserWindow() { | 61 | function makeFakeBrowserWindow() { |
| 62 | const windowListeners = new Map<string, (...args: readonly unknown[]) => void>(); | 62 | const windowListeners = new Map<string, (...args: readonly unknown[]) => void>(); |
| 63 | const webContentsListeners = new Map<string, (...args: readonly unknown[]) => void>(); | 63 | const webContentsListeners = new Map<string, (...args: readonly unknown[]) => void>(); |
| 64 | let zoomLevel = 0; | ||
| 64 | const webContents = { | 65 | const webContents = { |
| 65 | copyImageAt: vi.fn(), | 66 | copyImageAt: vi.fn(), |
| 66 | getURL: vi.fn(() => "t3code-dev://app/"), | 67 | getURL: vi.fn(() => "t3code-dev://app/"), |
| 68 | getZoomLevel: vi.fn(() => zoomLevel), | ||
| 69 | setZoomLevel: vi.fn((level: number) => { | ||
| 70 | zoomLevel = level; | ||
| 71 | }), | ||
| 67 | isLoadingMainFrame: vi.fn(() => false), | 72 | isLoadingMainFrame: vi.fn(() => false), |
| 68 | on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { | 73 | on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { |
| 69 | webContentsListeners.set(eventName, listener); | 74 | webContentsListeners.set(eventName, listener); |
| ... | @@ -116,6 +121,7 @@ function makeFakeBrowserWindow() { | ... | @@ -116,6 +121,7 @@ function makeFakeBrowserWindow() { |
| 116 | openDevTools: webContents.openDevTools, | 121 | openDevTools: webContents.openDevTools, |
| 117 | reload: webContents.reload, | 122 | reload: webContents.reload, |
| 118 | send: webContents.send, | 123 | send: webContents.send, |
| 124 | setZoomLevel: webContents.setZoomLevel, | ||
| 119 | setAutoHideCursor: window.setAutoHideCursor, | 125 | setAutoHideCursor: window.setAutoHideCursor, |
| 120 | webContentsListeners, | 126 | webContentsListeners, |
| 121 | windowListeners, | 127 | windowListeners, |
| ... | @@ -186,6 +192,7 @@ function makeTestLayer(input: { | ... | @@ -186,6 +192,7 @@ function makeTestLayer(input: { |
| 186 | bounds: DesktopAppSettings.DesktopWindowBounds, | 192 | bounds: DesktopAppSettings.DesktopWindowBounds, |
| 187 | ) => Effect.Effect<void>; | 193 | ) => Effect.Effect<void>; |
| 188 | readonly openedExternalUrls?: unknown[]; | 194 | readonly openedExternalUrls?: unknown[]; |
| 195 | readonly previewZoomReapplies?: number[]; | ||
| 189 | }) { | 196 | }) { |
| 190 | let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; | 197 | let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; |
| 191 | const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { | 198 | const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { |
| ... | @@ -264,6 +271,10 @@ function makeTestLayer(input: { | ... | @@ -264,6 +271,10 @@ function makeTestLayer(input: { |
| 264 | setMainWindow: () => Effect.void, | 271 | setMainWindow: () => Effect.void, |
| 265 | isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), | 272 | isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), |
| 266 | getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"), | 273 | getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"), |
| 274 | reapplyZoom: () => | ||
| 275 | Effect.sync(() => { | ||
| 276 | input.previewZoomReapplies?.push(input.window.webContents.getZoomLevel()); | ||
| 277 | }), | ||
| 267 | }), | 278 | }), |
| 268 | ), | 279 | ), |
| 269 | ), | 280 | ), |
| ... | @@ -483,6 +494,42 @@ describe("DesktopWindow", () => { | ... | @@ -483,6 +494,42 @@ describe("DesktopWindow", () => { |
| 483 | }), | 494 | }), |
| 484 | ); | 495 | ); |
| 485 | 496 | ||
| 497 | // Chromium hands the main window's zoom level down to embedded preview | ||
| 498 | // guests, so every app zoom has to put the preview browser back at its own | ||
| 499 | // zoom or zooming the UI drags the previewed page with it. | ||
| 500 | it.effect("restores the preview browser's own zoom after zooming the app", () => | ||
| 501 | Effect.gen(function* () { | ||
| 502 | const fakeWindow = makeFakeBrowserWindow(); | ||
| 503 | const createCount = yield* Ref.make(0); | ||
| 504 | const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none()); | ||
| 505 | const previewZoomReapplies: number[] = []; | ||
| 506 | const layer = makeTestLayer({ | ||
| 507 | window: fakeWindow.window, | ||
| 508 | createCount, | ||
| 509 | mainWindow, | ||
| 510 | previewZoomReapplies, | ||
| 511 | }); | ||
| 512 | |||
| 513 | yield* Effect.gen(function* () { | ||
| 514 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | ||
| 515 | yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); | ||
| 516 | |||
| 517 | yield* desktopWindow.zoomMain("out"); | ||
| 518 | yield* desktopWindow.zoomMain("out"); | ||
| 519 | yield* desktopWindow.zoomMain("in"); | ||
| 520 | yield* desktopWindow.zoomMain("reset"); | ||
| 521 | |||
| 522 | assert.deepEqual( | ||
| 523 | fakeWindow.setZoomLevel.mock.calls.map(([level]) => level), | ||
| 524 | [-0.5, -1, -0.5, 0], | ||
| 525 | ); | ||
| 526 | // Recorded after the window level moved, so the preview is put back at | ||
| 527 | // its own zoom on every step rather than left on the inherited one. | ||
| 528 | assert.deepEqual(previewZoomReapplies, [-0.5, -1, -0.5, 0]); | ||
| 529 | }).pipe(Effect.provide(layer)); | ||
| 530 | }), | ||
| 531 | ); | ||
| 532 | |||
| 486 | it.effect("uses the persisted main window bounds when opening the window", () => | 533 | it.effect("uses the persisted main window bounds when opening the window", () => |
| 487 | Effect.gen(function* () { | 534 | Effect.gen(function* () { |
| 488 | const fakeWindow = makeFakeBrowserWindow(); | 535 | const fakeWindow = makeFakeBrowserWindow(); |
apps/desktop/src/window/DesktopWindow.ts+4| ... | @@ -855,6 +855,10 @@ export const make = Effect.gen(function* () { | ... | @@ -855,6 +855,10 @@ export const make = Effect.gen(function* () { |
| 855 | webContents.setZoomLevel( | 855 | webContents.setZoomLevel( |
| 856 | direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), | 856 | direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), |
| 857 | ); | 857 | ); |
| 858 | // Chromium pushes the new level down to embedded guests, which would zoom | ||
| 859 | // the previewed page along with the app UI. The preview browser keeps its | ||
| 860 | // own zoom, so put each guest back where the preview left it. | ||
| 861 | yield* previewManager.reapplyZoom(); | ||
| 858 | }), | 862 | }), |
| 859 | syncAppearance: Effect.gen(function* () { | 863 | syncAppearance: Effect.gen(function* () { |
| 860 | const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; | 864 | const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; |