From ec1f8ff76957d6d4061b5212f68b7004a924639e Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Thu, 22 Oct 2026 10:11:35 +0200 Subject: [PATCH 1/2] feat(screenshotImprovements): Can change background color, inclue UI and Print hotkey --- app/components/Screenshot.vue | 173 +++++++++++++++--- app/components/ViewToolbar.vue | 9 + internal/stores/hybrid_viewer/scene.ts | 25 ++- .../microservice/app/set_is_app_ready.post.ts | 4 - tests/unit/components/Screenshot.nuxt.test.ts | 83 +++++++++ 5 files changed, 263 insertions(+), 31 deletions(-) create mode 100644 tests/unit/components/Screenshot.nuxt.test.ts diff --git a/app/components/Screenshot.vue b/app/components/Screenshot.vue index 06264627..a31323ef 100644 --- a/app/components/Screenshot.vue +++ b/app/components/Screenshot.vue @@ -2,10 +2,14 @@ import viewer_schemas, { type ViewerTakeScreenshotParams, } from "@geode/opengeodeweb-viewer/opengeodeweb_viewer_typed_schemas.js"; +import { BACKGROUND_GREY_VALUE } from "@ogw_internal/stores/hybrid_viewer/constants"; +import type { RGBAColor } from "@ogw_front/utils/default_styles/constants"; import ToolPanel from "@ogw_front/components/ToolPanel.vue"; +import ViewerOptionsColorPicker from "@ogw_front/components/Viewer/Options/ColorPicker.vue"; import fileDownload from "js-file-download"; import { useClipboardItems } from "@vueuse/core"; import { useFeedbackStore } from "@ogw_front/stores/feedback"; +import { useHybridViewerStore } from "@ogw_front/stores/hybrid_viewer"; import { useViewerStore } from "@ogw_front/stores/viewer"; const DEFAULT_PANEL_WIDTH = 260; @@ -24,14 +28,54 @@ const output_extensions = const filename = ref(""); const output_extension = ref("png"); const include_background = ref(true); +const include_ui = ref(false); const screenshot_type = ref("file"); +const DEFAULT_BACKGROUND_COLOR: RGBAColor = { + red: BACKGROUND_GREY_VALUE, + green: BACKGROUND_GREY_VALUE, + blue: BACKGROUND_GREY_VALUE, + alpha: 1, +}; +const background_color = ref({ ...DEFAULT_BACKGROUND_COLOR }); +let is_capturing = false; const { copy } = useClipboardItems(); -async function takeScreenshot(): Promise { - const viewerStore = useViewerStore(); +function applyBackgroundColor(color: RGBAColor): Promise { + const hybridViewerStore = useHybridViewerStore(); + // oxlint-disable-next-line id-length -- the viewer schema requires { r, g, b } + return hybridViewerStore.setBackgroundColor({ r: color.red, g: color.green, b: color.blue }); +} + +async function restoreBackgroundColor(): Promise { + background_color.value = { ...DEFAULT_BACKGROUND_COLOR }; + await applyBackgroundColor(DEFAULT_BACKGROUND_COLOR); +} + +async function deliverScreenshot(blob: Blob | Uint8Array, current_filename: string): Promise { const feedbackStore = useFeedbackStore(); - const current_filename = screenshot_type.value === "file" ? filename.value : "screenshot"; + if (screenshot_type.value === "file") { + fileDownload(blob, `${current_filename}.${output_extension.value}`); + feedbackStore.add_success("Screenshot downloaded"); + return; + } + try { + const pngBlob = new Blob([blob], { type: "image/png" }); + await copy([new ClipboardItem({ "image/png": pngBlob })]); + feedbackStore.add_success("Screenshot copied to clipboard"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + feedbackStore.add_error( + 0, + "", + "Clipboard Error", + `Failed to copy screenshot to clipboard: ${message}`, + ); + } +} + +async function takeSceneScreenshot(current_filename: string): Promise { + const viewerStore = useViewerStore(); const schema = viewer_schemas.opengeodeweb_viewer.viewer.take_screenshot; const params = { filename: current_filename, @@ -46,32 +90,92 @@ async function takeScreenshot(): Promise { { // `blob` is the image file content, received as binary (Uint8Array) through wslink response_function: async ({ blob }) => { - if (screenshot_type.value === "file") { - fileDownload(blob, `${current_filename}.${output_extension.value}`); - feedbackStore.add_success("Screenshot downloaded"); - } else { - try { - const pngBlob = new Blob([blob], { type: "image/png" }); - await copy([new ClipboardItem({ "image/png": pngBlob })]); - feedbackStore.add_success("Screenshot copied to clipboard"); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - feedbackStore.add_error( - 0, - "", - "Clipboard Error", - `Failed to copy screenshot to clipboard: ${message}`, - ); - } - } + await deliverScreenshot(blob, current_filename); }, }, ); - show.value = false; } -watch(output_extension, (value) => { - if (value !== "png") { +// `preferCurrentTab` is not yet part of the TypeScript DOM lib +interface TabCaptureOptions extends DisplayMediaStreamOptions { + preferCurrentTab: boolean; +} + +// Captures the whole browser tab (scene and web UI) through the native screen capture API +async function captureTab(mime_type: string): Promise { + const options: TabCaptureOptions = { video: true, audio: false, preferCurrentTab: true }; + const stream = await navigator.mediaDevices.getDisplayMedia(options); + try { + const video = document.createElement("video"); + video.srcObject = stream; + video.muted = true; + await video.play(); + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + canvas.getContext("2d")?.drawImage(video, 0, 0); + // oxlint-disable-next-line promise/avoid-new + return await new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error("Failed to encode the captured frame")); + } + }, mime_type); + }); + } finally { + for (const track of stream.getTracks()) { + track.stop(); + } + } +} + +async function takeUiScreenshot(current_filename: string): Promise { + const feedbackStore = useFeedbackStore(); + const mime_type = output_extension.value === "jpg" ? "image/jpeg" : "image/png"; + try { + const blob = await captureTab(mime_type); + await deliverScreenshot(blob, current_filename); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + feedbackStore.add_error(0, "", "Screenshot Error", `Failed to capture the page: ${message}`); + } +} + +async function takeScreenshot(): Promise { + const current_filename = screenshot_type.value === "file" ? filename.value : "screenshot"; + is_capturing = true; + try { + if (include_ui.value) { + // Close the panel first so it does not appear on the captured page + show.value = false; + await nextTick(); + await takeUiScreenshot(current_filename); + } else { + await takeSceneScreenshot(current_filename); + show.value = false; + } + } finally { + await restoreBackgroundColor(); + is_capturing = false; + } +} + +watch(background_color, async (color) => { + if (show.value) { + await applyBackgroundColor(color); + } +}); + +watch(show, async (value) => { + if (!value && !is_capturing) { + await restoreBackgroundColor(); + } +}); + +watch([output_extension, include_ui], ([extension, with_ui]) => { + if (extension !== "png" || with_ui) { include_background.value = true; } }); @@ -161,11 +265,22 @@ function handleClose(): void { + + + + + Background color + + diff --git a/app/components/ViewToolbar.vue b/app/components/ViewToolbar.vue index 1d182bbc..5aeae820 100644 --- a/app/components/ViewToolbar.vue +++ b/app/components/ViewToolbar.vue @@ -101,6 +101,15 @@ function toggleTool(toolRef: Ref): void { toolRef.value = !toolRef.value; } +// Best effort: the operating system may intercept PrintScreen before the browser, and only fires keyup when it does not +onKeyStroke( + "PrintScreen", + () => { + toggleTool(showScreenshot); + }, + { eventName: "keyup" }, +); + function toggleHoverHighlight(fieldType: PickedFieldType): void { const activate = !hybridViewerStore.is_hover_highlight || diff --git a/internal/stores/hybrid_viewer/scene.ts b/internal/stores/hybrid_viewer/scene.ts index 91de92a5..89807953 100644 --- a/internal/stores/hybrid_viewer/scene.ts +++ b/internal/stores/hybrid_viewer/scene.ts @@ -1,15 +1,35 @@ +import { ACTOR_COLOR, RGB_MAX } from "./constants"; import type { HybridDb, vtkActor as VtkActorInstance } from "./vtk_types"; import { requireRenderWindow, useHybridViewerCore } from "./core"; -import { ACTOR_COLOR } from "./constants"; +import viewer_schemas, { + type ViewerSetBackgroundColorParams, +} from "@geode/opengeodeweb-viewer/opengeodeweb_viewer_typed_schemas.js"; import { base64ToArrayBuffer } from "./base64"; import { useDataStore } from "@ogw_front/stores/data"; import { useViewerStore } from "@ogw_front/stores/viewer"; -import viewer_schemas from "@geode/opengeodeweb-viewer/opengeodeweb_viewer_typed_schemas.js"; import { newInstance as vtkActor } from "@kitware/vtk.js/Rendering/Core/Actor"; import { newInstance as vtkMapper } from "@kitware/vtk.js/Rendering/Core/Mapper"; import type vtkPolyData from "@kitware/vtk.js/Common/DataModel/PolyData"; import { newInstance as vtkXMLPolyDataReader } from "@kitware/vtk.js/IO/XML/XMLPolyDataReader"; +async function setBackgroundColor(color: ViewerSetBackgroundColorParams["color"]): Promise { + const { genericRenderWindow, remoteRender } = useHybridViewerCore(); + const renderer = requireRenderWindow(genericRenderWindow).getRenderer(); + renderer.setBackground([color.r / RGB_MAX, color.g / RGB_MAX, color.b / RGB_MAX]); + const renderWindow = requireRenderWindow(genericRenderWindow).getRenderWindow(); + renderWindow.render(); + const viewerStore = useViewerStore(); + const schema = viewer_schemas.opengeodeweb_viewer.viewer.set_background_color; + const params = { + color, + }; + await viewerStore.request({ + schema, + params, + }); + await remoteRender(); +} + // Shared via createSharedComposable (rather than merged into the parent hybridViewer store) so sibling slices, e.g. camera.ts and ruler.ts, can read hybridDb/setZScaling directly without importing the parent store and creating a cycle. A Pinia store would work too but its $id/$patch/... properties would leak into the composed store's spread and collapse its inferred type. const useHybridViewerScene = createSharedComposable(() => { const hybridDb = reactive({}); @@ -122,6 +142,7 @@ const useHybridViewerScene = createSharedComposable(() => { removeItem, setVisibility, setZScaling, + setBackgroundColor, clear, }; }); diff --git a/server/api/microservice/app/set_is_app_ready.post.ts b/server/api/microservice/app/set_is_app_ready.post.ts index 0607eee3..b47b1de1 100644 --- a/server/api/microservice/app/set_is_app_ready.post.ts +++ b/server/api/microservice/app/set_is_app_ready.post.ts @@ -1,6 +1,5 @@ // Third party imports import { consola } from "consola"; -import { createError } from "h3"; // Local imports import { setIsAppReady } from "@geode/opengeodeweb-front/server/utils/server_config.ts"; @@ -11,9 +10,6 @@ import schemas from "@geode/opengeodeweb-front/opengeodeweb_front_typed_schemas. export default defineTypedEventHandler( schemas.api.microservice.app.set_is_app_ready, ({ isReady }) => { - if (!isReady) { - throw createError({ statusCode: 400, statusMessage: "isReady is required" }); - } setIsAppReady(isReady); consola.info(`Updated IS_APP_READY to ${isReady}`); return { statusCode: 200, isReady }; diff --git a/tests/unit/components/Screenshot.nuxt.test.ts b/tests/unit/components/Screenshot.nuxt.test.ts new file mode 100644 index 00000000..ab70116f --- /dev/null +++ b/tests/unit/components/Screenshot.nuxt.test.ts @@ -0,0 +1,83 @@ +// oxlint-disable id-length since the viewer schema requires { r, g, b } +// Third party imports +import { describe, expect, test, vi } from "vitest"; +import { flushPromises } from "@vue/test-utils"; +import { mountSuspended } from "@nuxt/test-utils/runtime"; + +// Local imports +import Screenshot from "@ogw_front/components/Screenshot.vue"; +import { vuetify } from "@ogw_tests/utils"; + +const DEFAULT_GREY = 180; +const request = vi.fn<() => Promise>().mockResolvedValue(); +const setBackgroundColor = vi.fn<(color: { r: number; g: number; b: number }) => Promise>(); +const add_error = vi.fn<(...args: unknown[]) => void>(); + +vi.mock(import("@ogw_front/stores/viewer") as Promise, () => ({ + useViewerStore: (): { request: typeof request } => ({ request }), +})); +vi.mock(import("@ogw_front/stores/hybrid_viewer") as Promise, () => ({ + useHybridViewerStore: (): { setBackgroundColor: typeof setBackgroundColor } => ({ + setBackgroundColor, + }), +})); +vi.mock(import("@ogw_front/stores/feedback") as Promise, () => ({ + useFeedbackStore: (): Record => ({ + add_error, + add_success: vi.fn<() => void>(), + }), +})); + +async function mountScreenshot(): Promise> { + const wrapper = await mountSuspended(Screenshot, { + global: { plugins: [vuetify] }, + props: { modelValue: true }, + }); + return wrapper.vm; +} + +describe("screenshot", () => { + test("previews the background color and restores it on close", async () => { + const state = await mountScreenshot(); + state.background_color = { red: 10, green: 20, blue: 30, alpha: 1 }; + await flushPromises(); + expect(setBackgroundColor).toHaveBeenLastCalledWith({ r: 10, g: 20, b: 30 }); + + state.show = false; + await flushPromises(); + expect(setBackgroundColor).toHaveBeenLastCalledWith({ + r: DEFAULT_GREY, + g: DEFAULT_GREY, + b: DEFAULT_GREY, + }); + }); + + test("captures the page instead of the scene when UI is included", async () => { + const getDisplayMedia = vi + .fn<(options: unknown) => Promise>() + .mockRejectedValue(new Error("Permission denied")); + Object.defineProperty(navigator, "mediaDevices", { + value: { getDisplayMedia }, + configurable: true, + }); + request.mockClear(); + const state = await mountScreenshot(); + state.filename = "capture"; + state.include_ui = true; + await flushPromises(); + + // oxlint-disable-next-line no-unsafe-type-assertion -- script setup functions are not typed on vm + await (state.takeScreenshot as () => Promise)(); + + expect(getDisplayMedia).toHaveBeenCalledWith( + expect.objectContaining({ preferCurrentTab: true }), + ); + expect(request).not.toHaveBeenCalled(); + expect(add_error).toHaveBeenCalledWith( + 0, + "", + "Screenshot Error", + expect.stringContaining("Permission denied"), + ); + }); +}); From 0d0b40f504301706d8f5b2dd93ae972ae28bea98 Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Thu, 22 Oct 2026 10:36:51 +0200 Subject: [PATCH 2/2] The Print key cannot be used to open the screenshot tool: the operating system grabs it before the page receives it. - Ubuntu/GNOME: the compositor (Mutter) binds Print to its own screenshot tool. The key never reaches any window, browser or Electron, so no keydown/keyup event is fired. - Windows 11: Print opens the Snipping Tool by default and is intercepted the same way. - Web apps have no API to register a global shortcut. navigator.keyboard.lock() only works in fullscreen on Chromium. --- app/components/ViewToolbar.vue | 9 --------- 1 file changed, 9 deletions(-) diff --git a/app/components/ViewToolbar.vue b/app/components/ViewToolbar.vue index 5aeae820..1d182bbc 100644 --- a/app/components/ViewToolbar.vue +++ b/app/components/ViewToolbar.vue @@ -101,15 +101,6 @@ function toggleTool(toolRef: Ref): void { toolRef.value = !toolRef.value; } -// Best effort: the operating system may intercept PrintScreen before the browser, and only fires keyup when it does not -onKeyStroke( - "PrintScreen", - () => { - toggleTool(showScreenshot); - }, - { eventName: "keyup" }, -); - function toggleHoverHighlight(fieldType: PickedFieldType): void { const activate = !hybridViewerStore.is_hover_highlight ||