Skip to content
Merged
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
173 changes: 148 additions & 25 deletions app/components/Screenshot.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,14 +28,54 @@ const output_extensions =
const filename = ref<string>("");
const output_extension = ref<ViewerTakeScreenshotParams["output_extension"]>("png");
const include_background = ref<boolean>(true);
const include_ui = ref<boolean>(false);
const screenshot_type = ref<string>("file");
const DEFAULT_BACKGROUND_COLOR: RGBAColor = {
red: BACKGROUND_GREY_VALUE,
green: BACKGROUND_GREY_VALUE,
blue: BACKGROUND_GREY_VALUE,
alpha: 1,
};
const background_color = ref<RGBAColor>({ ...DEFAULT_BACKGROUND_COLOR });
let is_capturing = false;

const { copy } = useClipboardItems();

async function takeScreenshot(): Promise<void> {
const viewerStore = useViewerStore();
function applyBackgroundColor(color: RGBAColor): Promise<void> {
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<void> {
background_color.value = { ...DEFAULT_BACKGROUND_COLOR };
await applyBackgroundColor(DEFAULT_BACKGROUND_COLOR);
}

async function deliverScreenshot(blob: Blob | Uint8Array, current_filename: string): Promise<void> {
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<void> {
const viewerStore = useViewerStore();
const schema = viewer_schemas.opengeodeweb_viewer.viewer.take_screenshot;
const params = {
filename: current_filename,
Expand All @@ -46,32 +90,92 @@ async function takeScreenshot(): Promise<void> {
{
// `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<Blob> {
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<void> {
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<void> {
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;
}
});
Expand Down Expand Up @@ -161,18 +265,37 @@ function handleClose(): void {
</v-row>

<v-row dense>
<v-col cols="12" class="py-1">
<v-switch
data-testid="screenshotIncludeUiSwitch"
v-model="include_ui"
label="Include UI"
density="compact"
hide-details
inset
class="text-caption"
></v-switch>
</v-col>
<v-col cols="12" class="py-1">
<v-switch
data-testid="screenshotIncludeBackgroundSwitch"
v-model="include_background"
:disabled="screenshot_type === 'file' && output_extension !== 'png'"
:disabled="(screenshot_type === 'file' && output_extension !== 'png') || include_ui"
label="Include background"
density="compact"
hide-details
inset
class="text-caption"
></v-switch>
</v-col>
<v-col v-if="include_background" cols="12" class="py-1">
<span class="text-caption">Background color</span>
<ViewerOptionsColorPicker
data-testid="screenshotBackgroundColorPicker"
v-model="background_color"
disabled-alpha
/>
</v-col>
</v-row>
</v-container>

Expand Down
25 changes: 23 additions & 2 deletions internal/stores/hybrid_viewer/scene.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<HybridDb>({});
Expand Down Expand Up @@ -122,6 +142,7 @@ const useHybridViewerScene = createSharedComposable(() => {
removeItem,
setVisibility,
setZScaling,
setBackgroundColor,
clear,
};
});
Expand Down
4 changes: 0 additions & 4 deletions server/api/microservice/app/set_is_app_ready.post.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 };
Expand Down
83 changes: 83 additions & 0 deletions tests/unit/components/Screenshot.nuxt.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>>().mockResolvedValue();
const setBackgroundColor = vi.fn<(color: { r: number; g: number; b: number }) => Promise<void>>();
const add_error = vi.fn<(...args: unknown[]) => void>();

vi.mock(import("@ogw_front/stores/viewer") as Promise<unknown>, () => ({
useViewerStore: (): { request: typeof request } => ({ request }),
}));
vi.mock(import("@ogw_front/stores/hybrid_viewer") as Promise<unknown>, () => ({
useHybridViewerStore: (): { setBackgroundColor: typeof setBackgroundColor } => ({
setBackgroundColor,
}),
}));
vi.mock(import("@ogw_front/stores/feedback") as Promise<unknown>, () => ({
useFeedbackStore: (): Record<string, unknown> => ({
add_error,
add_success: vi.fn<() => void>(),
}),
}));

async function mountScreenshot(): Promise<Record<string, unknown>> {
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<MediaStream>>()
.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<void>)();

expect(getDisplayMedia).toHaveBeenCalledWith(
expect.objectContaining({ preferCurrentTab: true }),
);
expect(request).not.toHaveBeenCalled();
expect(add_error).toHaveBeenCalledWith(
0,
"",
"Screenshot Error",
expect.stringContaining("Permission denied"),
);
});
});
Loading