History

fix(media): preserve native actions for remote images

t3-code[bot] committed 2 months ago

Commit 4e333bf1 parent 70cd2584 files · +138 −3Browse files

Unified · Split

4 files
  1. apps/desktop/src/window/DesktopWindow.test.ts +81 −2
  2. apps/desktop/src/window/DesktopWindow.ts +15 −0
  3. apps/web/src/components/media/MediaActions.test.ts +29 −0
  4. apps/web/src/components/media/MediaActions.tsx +13 −1
apps/desktop/src/window/DesktopWindow.test.ts+81−2
@@ -66,6 +66,7 @@ function makeFakeBrowserWindow() { · 65 unmodified lines
66 let zoomLevel = 0;66 let zoomLevel = 0;
67 const webContents = {67 const webContents = {
68 copyImageAt: vi.fn(),68 copyImageAt: vi.fn(),
69 downloadURL: vi.fn(),
69 getURL: vi.fn(() => "t3code-dev://app/"),70 getURL: vi.fn(() => "t3code-dev://app/"),
70 getZoomLevel: vi.fn(() => zoomLevel),71 getZoomLevel: vi.fn(() => zoomLevel),
71 setZoomLevel: vi.fn((level: number) => {72 setZoomLevel: vi.fn((level: number) => {
@@ -115,6 +116,8 @@ function makeFakeBrowserWindow() { · 43 unmodified lines
115 116
116 return {117 return {
117 window: window as unknown as Electron.BrowserWindow,118 window: window as unknown as Electron.BrowserWindow,
119 copyImageAt: webContents.copyImageAt,
120 downloadURL: webContents.downloadURL,
118 getBounds: window.getBounds,121 getBounds: window.getBounds,
119 getNormalBounds: window.getNormalBounds,122 getNormalBounds: window.getNormalBounds,
120 isDestroyed: window.isDestroyed,123 isDestroyed: window.isDestroyed,
@@ -208,6 +211,8 @@ function makeTestLayer(input: { · 87 unmodified lines
208 bounds: DesktopAppSettings.DesktopWindowBounds,211 bounds: DesktopAppSettings.DesktopWindowBounds,
209 ) => Effect.Effect<void>;212 ) => Effect.Effect<void>;
210 readonly openedExternalUrls?: unknown[];213 readonly openedExternalUrls?: unknown[];
214 readonly copiedTexts?: string[];
215 readonly popupTemplates?: Electron.MenuItemConstructorOptions[][];
211 readonly previewZoomReapplies?: number[];216 readonly previewZoomReapplies?: number[];
212}) {217}) {
213 let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS;218 let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS;
@@ -273,14 +278,24 @@ function makeTestLayer(input: { · 59 unmodified lines
273 desktopServerExposureLayer,278 desktopServerExposureLayer,
274 DesktopState.layer,279 DesktopState.layer,
275 electronAppLayer,280 electronAppLayer,
276 electronMenuLayer,281 input.popupTemplates
282 ? Layer.succeed(ElectronMenu.ElectronMenu, {
283 setApplicationMenu: () => Effect.void,
284 popupTemplate: ({ template }) =>
285 Effect.sync(() => input.popupTemplates?.push([...template])),
286 showContextMenu: () => Effect.succeed(Option.none()),
287 } satisfies ElectronMenu.ElectronMenu["Service"])
288 : electronMenuLayer,
277 Layer.succeed(ElectronShell.ElectronShell, {289 Layer.succeed(ElectronShell.ElectronShell, {
278 openExternal: (url) =>290 openExternal: (url) =>
279 Effect.sync(() => {291 Effect.sync(() => {
280 input.openedExternalUrls?.push(url);292 input.openedExternalUrls?.push(url);
281 return true;293 return true;
282 }),294 }),
283 copyText: () => Effect.void,295 copyText: (text) =>
296 Effect.sync(() => {
297 input.copiedTexts?.push(text);
298 }),
284 } satisfies ElectronShell.ElectronShell["Service"]),299 } satisfies ElectronShell.ElectronShell["Service"]),
285 electronThemeLayer,300 electronThemeLayer,
286 electronWindowLayer,301 electronWindowLayer,
@@ -398,6 +413,70 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n · 111 unmodified lines
398 });413 });
399 414
400describe("DesktopWindow", () => {415describe("DesktopWindow", () => {
416 it.effect("keeps copy, save, and URL actions native for external images", () =>
417 Effect.gen(function* () {
418 const fakeWindow = makeFakeBrowserWindow();
419 const createCount = yield* Ref.make(0);
420 const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
421 const copiedTexts: string[] = [];
422 const popupTemplates: Electron.MenuItemConstructorOptions[][] = [];
423 const layer = makeTestLayer({
424 window: fakeWindow.window,
425 createCount,
426 mainWindow,
427 copiedTexts,
428 popupTemplates,
429 });
430 const imageUrl = "https://images.example/screenshot.png";
431
432 yield* Effect.gen(function* () {
433 const desktopWindow = yield* DesktopWindow.DesktopWindow;
434 yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
435 const contextMenu = fakeWindow.webContentsListeners.get("context-menu");
436 if (!contextMenu) return yield* Effect.die("context-menu listener was not registered");
437
438 contextMenu({ preventDefault: vi.fn() }, {
439 dictionarySuggestions: [],
440 editFlags: { canCopy: false, canCut: false, canPaste: false, canSelectAll: false },
441 linkURL: "",
442 mediaType: "image",
443 misspelledWord: "",
444 srcURL: imageUrl,
445 x: 42,
446 y: 84,
447 } as unknown as Electron.ContextMenuParams);
448 yield* Effect.promise(() => Promise.resolve());
449
450 const template = popupTemplates.at(-1) ?? [];
451 const byLabel = (label: string) => template.find((item) => item.label === label);
452 assert.ok(byLabel("Copy URL"));
453 assert.ok(byLabel("Save Image"));
454 assert.ok(byLabel("Copy Image"));
455
456 byLabel("Copy URL")?.click?.(
457 {} as Electron.MenuItem,
458 fakeWindow.window,
459 {} as Electron.KeyboardEvent,
460 );
461 byLabel("Save Image")?.click?.(
462 {} as Electron.MenuItem,
463 fakeWindow.window,
464 {} as Electron.KeyboardEvent,
465 );
466 byLabel("Copy Image")?.click?.(
467 {} as Electron.MenuItem,
468 fakeWindow.window,
469 {} as Electron.KeyboardEvent,
470 );
471 yield* Effect.promise(() => Promise.resolve());
472
473 assert.deepEqual(copiedTexts, [imageUrl]);
474 assert.deepEqual(fakeWindow.downloadURL.mock.calls, [[imageUrl]]);
475 assert.deepEqual(fakeWindow.copyImageAt.mock.calls, [[42, 84]]);
476 }).pipe(Effect.provide(layer));
477 }),
478 );
479
401 it("leaves fullscreen before concealing a pending quit", () => {480 it("leaves fullscreen before concealing a pending quit", () => {
402 const fakeWindow = makeFakeBrowserWindow();481 const fakeWindow = makeFakeBrowserWindow();
403 482
apps/desktop/src/window/DesktopWindow.ts+15−0
@@ -523,6 +523,21 @@ export const make = Effect.gen(function* () { · 522 unmodified lines
523 }523 }
524 524
525 if (params.mediaType === "image") {525 if (params.mediaType === "image") {
526 const imageUrl = ElectronShell.parseSafeExternalUrl(params.srcURL);
527 if (Option.isSome(imageUrl)) {
528 menuTemplate.push(
529 {
530 label: "Copy URL",
531 click: () => {
532 void runPromise(electronShell.copyText(imageUrl.value));
533 },
534 },
535 {
536 label: "Save Image",
537 click: () => window.webContents.downloadURL(imageUrl.value),
538 },
539 );
540 }
526 menuTemplate.push({541 menuTemplate.push({
527 label: "Copy Image",542 label: "Copy Image",
528 click: () => window.webContents.copyImageAt(params.x, params.y),543 click: () => window.webContents.copyImageAt(params.x, params.y),
apps/web/src/components/media/MediaActions.test.ts+29−0
@@ -0,0 +1,29 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { shouldUseNativeImageContextMenu, type MediaActionSource } from "./MediaActions";
4
5const remoteImage: MediaActionSource = {
6 kind: "image",
7 name: "screenshot.png",
8 src: "https://images.example/screenshot.png",
9 reference: { kind: "url", url: "https://images.example/screenshot.png" },
10};
11
12describe("shouldUseNativeImageContextMenu", () => {
13 it("keeps direct external images on the browser-native menu", () => {
14 expect(shouldUseNativeImageContextMenu(remoteImage)).toBe(true);
15 });
16
17 it("keeps environment assets on the custom source-aware menu", () => {
18 expect(
19 shouldUseNativeImageContextMenu({
20 ...remoteImage,
21 asset: {} as NonNullable<MediaActionSource["asset"]>,
22 }),
23 ).toBe(false);
24 });
25
26 it("keeps videos on the custom source-aware menu", () => {
27 expect(shouldUseNativeImageContextMenu({ ...remoteImage, kind: "video" })).toBe(false);
28 });
29});
apps/web/src/components/media/MediaActions.tsx+13−1
@@ -25,6 +25,17 @@ export interface MediaActionSource { · 24 unmodified lines
25 readonly onOpenFile?: () => void;25 readonly onOpenFile?: () => void;
26}26}
27 27
28/** Browser/Electron native image menus can copy or save rendered cross-origin images without CORS. */
29export function shouldUseNativeImageContextMenu(source: MediaActionSource): boolean {
30 return (
31 source.kind === "image" &&
32 source.src !== null &&
33 source.reference?.kind === "url" &&
34 source.asset === undefined &&
35 source.onOpenFile === undefined
36 );
37}
38
28function mediaFileName(source: MediaActionSource): string {39function mediaFileName(source: MediaActionSource): string {
29 return (40 return (
30 (source.reference && mediaReferenceFileName(source.reference)) || source.name || source.kind41 (source.reference && mediaReferenceFileName(source.reference)) || source.name || source.kind
@@ -83,7 +94,8 @@ export function MediaActions({ · 52 unmodified lines
83 const menuOpen = useRef(false);94 const menuOpen = useRef(false);
84 const reference = source.reference;95 const reference = source.reference;
85 const hasActions =96 const hasActions =
86 source.kind === "image" || reference !== undefined || source.onOpenFile !== undefined;97 !shouldUseNativeImageContextMenu(source) &&
98 (source.kind === "image" || reference !== undefined || source.onOpenFile !== undefined);
87 const tooltip = reference?.kind === "file" ? reference.path : (reference?.url ?? source.name);99 const tooltip = reference?.kind === "file" ? reference.path : (reference?.url ?? source.name);
88 100
89 const showMenu = async (position: { x: number; y: number }) => {101 const showMenu = async (position: { x: number; y: number }) => {