fix(desktop): add legacy webview fallbacks for snapshots and dialogs

This commit is contained in:
知食分子
2026-08-27 10:55:05 +08:00
committed by GitHub
parent afcfa59d0b
commit ba8df70fe6
8 changed files with 232 additions and 13 deletions
@@ -158,7 +158,7 @@ const previewVisible = computed(() => open.value && !!props.source);
<Dialog v-model:open="open">
<!-- 对话框整体限高:高度上限跟随 --dbx-viewport-height(兼容旧版 WebView 中 vh 不准的情况);
内部采用纵向 flex 布局,header/footer 固定,只有中间预览区滚动,保证底部按钮始终可见 -->
<DialogContent class="flex max-h-[calc(var(--dbx-viewport-height)-2rem)] flex-col overflow-hidden border border-border !bg-background text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[860px]">
<DialogContent class="dbx-code-snapshot-dialog flex max-h-[calc(var(--dbx-viewport-height)-2rem)] flex-col overflow-hidden border border-border !bg-background text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[860px]">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Camera class="h-5 w-5 text-primary" />
@@ -209,7 +209,7 @@ const previewVisible = computed(() => open.value && !!props.source);
</div>
</div>
<DialogFooter>
<DialogFooter class="dbx-code-snapshot-dialog__footer">
<Button variant="outline" @click="open = false">{{ t("codeSnapshot.close") }}</Button>
<Button variant="outline" :disabled="exporting" @click="exportSnapshot('clipboard')">
<Check v-if="copied" class="mr-1.5 h-4 w-4 text-green-500" />
@@ -224,3 +224,21 @@ const previewVisible = computed(() => open.value && !!props.source);
</DialogContent>
</Dialog>
</template>
<style>
html.dbx-legacy-webview [data-slot="dialog-content"].dbx-code-snapshot-dialog {
display: flex !important;
flex-direction: column !important;
width: calc(100vw - 2rem) !important;
max-width: 860px !important;
min-width: 0 !important;
}
html.dbx-legacy-webview .dbx-code-snapshot-dialog__footer {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: flex-end !important;
flex-wrap: nowrap !important;
}
</style>
@@ -86,7 +86,7 @@ const emit = defineEmits<{
<span class="text-background/70">{{ columnNameLabel }}</span>
<span class="flex min-w-0 items-center gap-2">
<span class="min-w-0 flex-1 truncate font-mono">{{ name }}</span>
<button type="button" class="flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-background/10" :title="copyColumnNameLabel" @click.stop="emit('copyName')">
<button data-column-header-copy-name type="button" class="flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-background/10" :title="copyColumnNameLabel" @click.stop="emit('copyName')">
<Copy class="h-3 w-3" />
</button>
</span>
@@ -157,3 +157,13 @@ const emit = defineEmits<{
border-right: 2px solid rgb(100, 116, 139) !important;
}
</style>
<style>
html.dbx-legacy-webview [data-column-header-copy-name]:hover {
background-color: rgba(255, 255, 255, 0.1);
}
html.dbx-legacy-webview.dark [data-column-header-copy-name]:hover {
background-color: rgba(0, 0, 0, 0.1);
}
</style>
@@ -90,7 +90,7 @@ watch(
<template>
<Dialog :open="open" @update:open="handleOpenChange">
<DialogContent
class="sm:max-w-[520px]"
class="dbx-update-dialog sm:max-w-[520px]"
:show-close-button="!isCloseBlocked"
@interact-outside="
(e: Event) => {
@@ -167,3 +167,14 @@ watch(
</DialogContent>
</Dialog>
</template>
<style>
html.dbx-legacy-webview [data-slot="dialog-content"].dbx-update-dialog[class~="max-w-sm"] {
max-width: 520px !important;
}
html.dbx-legacy-webview [data-slot="dialog-content"].dbx-update-dialog [data-slot="dialog-footer"] {
flex-direction: row !important;
justify-content: flex-end !important;
}
</style>
@@ -224,7 +224,7 @@ function onClose() {
<template>
<Dialog :open="props.open" @update:open="onClose">
<DialogContent class="sm:max-w-190" @close-auto-focus="onDdlDialogCloseAutoFocus">
<DialogContent class="dbx-ddl-view-dialog sm:max-w-190" @close-auto-focus="onDdlDialogCloseAutoFocus">
<DialogHeader>
<DialogTitle>DDL - {{ props.tableName }}</DialogTitle>
</DialogHeader>
@@ -276,4 +276,13 @@ function onClose() {
.ddl-view-editor :deep(.cm-content ::selection) {
background: var(--dbx-editor-selection-background, rgba(59, 130, 246, 0.35)) !important;
}
html.dbx-legacy-webview [data-slot="dialog-content"].dbx-ddl-view-dialog[class~="max-w-sm"] {
max-width: 47.5rem !important;
}
html.dbx-legacy-webview [data-slot="dialog-content"].dbx-ddl-view-dialog [data-slot="dialog-footer"] {
flex-direction: row !important;
justify-content: flex-end !important;
}
</style>
@@ -1353,10 +1353,8 @@ async function saveConfigTask() {
</template>
<style>
@media (min-width: 640px) {
html.dbx-legacy-webview [data-slot="dialog-content"].dbx-transfer-dialog[class~="max-w-sm"] {
/* Override the legacy default cap without pinning width, so native resize remains effective. */
max-width: calc(100vw - 2rem) !important;
}
html.dbx-legacy-webview [data-slot="dialog-content"].dbx-transfer-dialog[class~="max-w-sm"] {
/* Override the legacy default cap without pinning width, so native resize remains effective. */
max-width: calc(100vw - 2rem) !important;
}
</style>
@@ -1,5 +1,12 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CODE_SNAPSHOT_CSS, renderCodeSnapshotHtml, savePngDataUrlToFile, snapshotElementToPng } from "@/lib/codeSnapshot/codeSnapshot";
import { CODE_SNAPSHOT_CSS, materializeSnapshotCloneLineNumbers, renderCodeSnapshotHtml, savePngDataUrlToFile, snapshotElementToPng } from "@/lib/codeSnapshot/codeSnapshot";
import { LEGACY_WEBVIEW_CLASS } from "@/lib/ui/legacyWebView";
const codeSnapshotDialogSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/codeSnapshot/CodeSnapshotDialog.vue"), "utf8");
const { createCodeHighlighter, highlightCode, toPng, isTauriRuntime, save, writeFile } = vi.hoisted(() => {
const highlightCode = vi.fn((content: string, _lang: string) => `<span class="line">${content}</span>`);
@@ -78,6 +85,7 @@ describe("renderCodeSnapshotHtml", () => {
expect(html).toContain(".dbx-code-snapshot *");
expect(html).toContain("border: 0");
expect(html).toContain("outline: 0");
expect(html).toContain(".dbx-code-snapshot__line-number");
});
it("renders macOS traffic lights and an optional title by default", async () => {
@@ -124,8 +132,10 @@ describe("renderCodeSnapshotHtml", () => {
expect(dark).toContain("background:#0d1117");
expect(dark).toContain("--dbx-snapshot-line-number:#484f58");
expect(dark).toContain("color:#e1e4e8");
expect(light).toContain("background:#ffffff");
expect(light).toContain("--dbx-snapshot-line-number:#d0d7de");
expect(light).toContain("color:#24292f");
});
it("applies custom padding and font size", async () => {
@@ -135,12 +145,35 @@ describe("renderCodeSnapshotHtml", () => {
expect(html).toContain("padding:0 24px 24px");
});
it("keeps the snapshot dialog responsive layout classes in source", () => {
expect(codeSnapshotDialogSource).toContain("sm:max-w-[860px]");
expect(codeSnapshotDialogSource).toContain("md:flex-row");
expect(codeSnapshotDialogSource).toContain("md:w-52");
});
it("materializes real line-number nodes in the export clone", () => {
document.body.innerHTML = '<div class="dbx-code-snapshot"><pre class="dbx-code-snapshot__pre dbx-code-snapshot__pre--numbered"><code><span class="line">SELECT 1</span><span class="line">FROM dual</span></code></pre></div>';
const root = document.body.firstElementChild as HTMLElement;
materializeSnapshotCloneLineNumbers(root);
const pre = root.querySelector(".dbx-code-snapshot__pre") as HTMLElement;
const lines = Array.from(root.querySelectorAll<HTMLElement>("code > .line"));
expect(pre.classList.contains("dbx-code-snapshot__pre--numbered")).toBe(false);
expect(lines).toHaveLength(2);
expect(lines[0]?.querySelector(".dbx-code-snapshot__line-number")?.textContent).toBe("1");
expect(lines[1]?.querySelector(".dbx-code-snapshot__line-number")?.textContent).toBe("2");
expect(lines[0]?.querySelector(".dbx-code-snapshot__line-content")?.textContent).toBe("SELECT 1");
expect(lines[1]?.querySelector(".dbx-code-snapshot__line-content")?.textContent).toBe("FROM dual");
});
it("exports at a capped device-pixel ratio for crisp high-DPI snapshots", async () => {
toPng.mockResolvedValue("data:image/png;base64,test");
vi.stubGlobal("window", { devicePixelRatio: 3 });
try {
await snapshotElementToPng({ offsetWidth: 320, offsetHeight: 160, scrollWidth: 640, scrollHeight: 240 } as HTMLElement);
const options = toPng.mock.calls.at(-1)?.[1];
expect(toPng).toHaveBeenLastCalledWith(
expect.anything(),
expect.objectContaining({
@@ -153,6 +186,8 @@ describe("renderCodeSnapshotHtml", () => {
},
}),
);
expect(options?.adjustPseudoElement).toBeUndefined();
expect(options?.onclone).toBeUndefined();
vi.stubGlobal("window", { devicePixelRatio: 0 });
await snapshotElementToPng({ offsetWidth: 320, offsetHeight: 160, scrollWidth: 320, scrollHeight: 160 } as HTMLElement);
@@ -162,6 +197,31 @@ describe("renderCodeSnapshotHtml", () => {
}
});
it("enables the legacy WebView export fallback only when the runtime is marked legacy", async () => {
toPng.mockResolvedValue("data:image/png;base64,test");
document.documentElement.classList.add(LEGACY_WEBVIEW_CLASS);
try {
await snapshotElementToPng({ offsetWidth: 320, offsetHeight: 160, scrollWidth: 320, scrollHeight: 160 } as HTMLElement);
const options = toPng.mock.calls.at(-1)?.[1];
expect(typeof options?.adjustPseudoElement).toBe("function");
expect(typeof options?.onclone).toBe("function");
expect(options.adjustPseudoElement?.(document.createElement("span"), ":after", {} as CSSStyleDeclaration)).toBeUndefined();
const clone = document.createElement("div");
clone.innerHTML = '<div class="dbx-code-snapshot"><pre class="dbx-code-snapshot__pre dbx-code-snapshot__pre--numbered" style="--dbx-snapshot-line-number:#d0d7de"><code><span class="line">SELECT 1</span><span class="line">FROM dual</span></code></pre></div>';
options.onclone?.(clone);
const line = clone.querySelector<HTMLElement>(".dbx-code-snapshot__line-number");
expect(line?.textContent).toBe("1");
expect(line?.getAttribute("style")).toContain("display: inline-block");
expect(line?.getAttribute("style")).toContain("margin-right: 1.4em");
expect(line?.getAttribute("style")).toContain("color: #d0d7de");
} finally {
document.documentElement.classList.remove(LEGACY_WEBVIEW_CLASS);
}
});
it("reduces the export scale when a wide snapshot reaches the canvas edge limit", async () => {
toPng.mockResolvedValue("data:image/png;base64,test");
vi.stubGlobal("window", { devicePixelRatio: 2 });
@@ -14,6 +14,7 @@
import type { AppThemeAppearance } from "@/lib/app/appTheme";
import { createAiShikiBlockCodeHighlighter, type AiCodeHighlighter } from "@/lib/ai/aiCodeHighlighter";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { LEGACY_WEBVIEW_CLASS } from "@/lib/ui/legacyWebView";
export interface CodeSnapshotSource {
code: string;
@@ -27,7 +28,7 @@ export interface CodeSnapshotStyleOptions {
appearance: AppThemeAppearance;
/** Render the macOS-style traffic light dots. Defaults to true. */
showTrafficLights?: boolean;
/** Render line numbers via CSS counters. Defaults to true. */
/** Render line numbers. Defaults to true. */
showLineNumbers?: boolean;
/** Padding (px) around the code area. Defaults to 16. */
padding?: number;
@@ -55,6 +56,11 @@ const SNAPSHOT_LINE_NUMBER: Record<AppThemeAppearance, string> = {
dark: "#484f58",
};
const SNAPSHOT_CODE_TEXT: Record<AppThemeAppearance, string> = {
light: "#24292f",
dark: "#e1e4e8",
};
const TRAFFIC_LIGHT_COLORS = ["#ff5f57", "#febc2e", "#28c840"] as const;
const MAX_EXPORT_PIXEL_RATIO = 2;
const MAX_EXPORT_CANVAS_EDGE = 16_384;
@@ -112,6 +118,16 @@ export const CODE_SNAPSHOT_CSS = `
display: block;
min-height: 1.6em;
}
.dbx-code-snapshot__line-number {
display: inline-block;
min-width: 2.2em;
margin-right: 1.4em;
text-align: right;
color: var(--dbx-snapshot-line-number, #8b949e);
font-variant-numeric: tabular-nums;
user-select: none;
-webkit-user-select: none;
}
.dbx-code-snapshot__pre--numbered {
counter-reset: dbx-snapshot-line;
}
@@ -160,6 +176,48 @@ function renderSnapshotBar(title: string | undefined, appearance: AppThemeAppear
return `<div class="dbx-code-snapshot__bar" style="background:${SNAPSHOT_BAR_BACKGROUND[appearance]};color:${SNAPSHOT_BAR_TEXT[appearance]}">${dots}${titleHtml}</div>`;
}
function isSnapshotNumberedLineNode(node: Node): node is Element {
return node instanceof Element && node.classList.contains("line") && node.closest(".dbx-code-snapshot__pre--numbered") !== null;
}
function hasMaterializedLineNumber(line: Element): boolean {
return Array.from(line.children).some((child) => child.classList.contains("dbx-code-snapshot__line-number"));
}
function legacyWebViewExportFallbackEnabled(): boolean {
return typeof document !== "undefined" && document.documentElement.classList.contains(LEGACY_WEBVIEW_CLASS);
}
/**
* Keep preview rendering on CSS counters, but rewrite the cloned export DOM to
* use real line-number nodes. Older WebKit foreignObject exports can flatten
* every counter() value to "1", while the live preview still looks correct.
*/
export function materializeSnapshotCloneLineNumbers(root: ParentNode): void {
for (const block of Array.from(root.querySelectorAll<HTMLElement>(".dbx-code-snapshot__pre--numbered"))) {
block.classList.remove("dbx-code-snapshot__pre--numbered");
const lineNumberColor = block.style.getPropertyValue("--dbx-snapshot-line-number").trim() || SNAPSHOT_LINE_NUMBER.dark;
const lines = Array.from(block.querySelectorAll<HTMLElement>("code > .line"));
for (const [index, line] of lines.entries()) {
if (hasMaterializedLineNumber(line)) continue;
const lineNumber = block.ownerDocument.createElement("span");
lineNumber.className = "dbx-code-snapshot__line-number";
lineNumber.setAttribute("aria-hidden", "true");
lineNumber.textContent = String(index + 1);
lineNumber.style.cssText = ["display:inline-block", "min-width:2.2em", "margin-right:1.4em", "text-align:right", `color:${lineNumberColor}`, "font-variant-numeric:tabular-nums", "user-select:none", "-webkit-user-select:none"].join(";");
const lineContent = block.ownerDocument.createElement("span");
lineContent.className = "dbx-code-snapshot__line-content";
lineContent.style.display = "inline";
while (line.firstChild) {
lineContent.append(line.firstChild);
}
line.append(lineNumber, lineContent);
}
}
}
/**
* Render a self-contained snapshot DOM (as an HTML string) for the given code.
* The returned string includes its own `<style>` tag and can be injected via
@@ -189,7 +247,7 @@ export async function renderCodeSnapshotHtml(source: CodeSnapshotSource, options
`<style>${CODE_SNAPSHOT_CSS}</style>` +
`<div class="dbx-code-snapshot" data-snapshot-appearance="${appearance}" style="background:${SNAPSHOT_BACKGROUND[appearance]};font-size:${fontSize}px">` +
bar +
`<pre class="${preClass}" style="--dbx-snapshot-line-number:${lineNumberColor};padding:0 ${padding}px ${padding}px"><code>${body}</code></pre>` +
`<pre class="${preClass}" style="--dbx-snapshot-line-number:${lineNumberColor};color:${SNAPSHOT_CODE_TEXT[appearance]};padding:0 ${padding}px ${padding}px"><code>${body}</code></pre>` +
`</div>`
);
}
@@ -207,12 +265,28 @@ export async function snapshotElementToPng(element: HTMLElement): Promise<string
const targetScale = Math.min(MAX_EXPORT_PIXEL_RATIO, Math.max(1, typeof window === "undefined" ? 1 : window.devicePixelRatio || 1));
const scale = Math.min(targetScale, MAX_EXPORT_CANVAS_EDGE / width, MAX_EXPORT_CANVAS_EDGE / height, Math.sqrt(MAX_EXPORT_CANVAS_PIXELS / basePixels));
const useLegacyLineNumberFallback = legacyWebViewExportFallbackEnabled();
return domtoimage.toPng(element, {
quality: 1,
width,
height,
// dom-to-image-more 使用 scale 放大实际画布;pixelRatio 不是该库支持的参数。
scale,
adjustPseudoElement: useLegacyLineNumberFallback
? (node: Node, pseudoElement: ":before" | ":after") => {
if (pseudoElement === ":before" && isSnapshotNumberedLineNode(node)) {
return false;
}
return undefined;
}
: undefined,
onclone: useLegacyLineNumberFallback
? (clone: Node) => {
if (typeof (clone as ParentNode).querySelectorAll === "function") {
materializeSnapshotCloneLineNumbers(clone as ParentNode);
}
}
: undefined,
style: {
width: `${width}px`,
height: `${height}px`,
@@ -6,7 +6,11 @@ const globalsCss = readCascadeCss();
const dialogContentSource = readFileSync(new URL("../../components/ui/dialog/DialogContent.vue", import.meta.url), "utf8");
const dialogScrollContentSource = readFileSync(new URL("../../components/ui/dialog/DialogScrollContent.vue", import.meta.url), "utf8");
const dialogOverlaySource = readFileSync(new URL("../../components/ui/dialog/DialogOverlay.vue", import.meta.url), "utf8");
const codeSnapshotDialogSource = readFileSync(new URL("../../components/codeSnapshot/CodeSnapshotDialog.vue", import.meta.url), "utf8");
const dataTransferDialogSource = readFileSync(new URL("../../components/transfer/DataTransferDialog.vue", import.meta.url), "utf8");
const updateDialogSource = readFileSync(new URL("../../components/layout/UpdateDialog.vue", import.meta.url), "utf8");
const dataGridColumnHeaderSource = readFileSync(new URL("../../components/grid/DataGridColumnHeader.vue", import.meta.url), "utf8");
const ddlViewDialogSource = readFileSync(new URL("../../components/objects/DdlViewDialog.vue", import.meta.url), "utf8");
const connectionDialogSource = readFileSync(new URL("../../components/connection/ConnectionDialog.vue", import.meta.url), "utf8");
const connectionTreeSource = readFileSync(new URL("../../components/sidebar/ConnectionTree.vue", import.meta.url), "utf8");
const activeConnectionFilterSource = readFileSync(new URL("../../components/sidebar/ActiveConnectionFilterButton.vue", import.meta.url), "utf8");
@@ -86,10 +90,45 @@ describe("legacy WebView CSS fallbacks", () => {
expect(dataTransferDialogSource).toContain('width: "min(1120px, calc(100vw - 2rem))"');
expect(dataTransferDialogSource).toContain('html.dbx-legacy-webview [data-slot="dialog-content"].dbx-transfer-dialog[class~="max-w-sm"]');
expect(dataTransferDialogSource).toContain("max-width: calc(100vw - 2rem) !important;");
expect(dataTransferDialogSource).not.toContain("@media (min-width: 640px)");
expect(dataTransferDialogSource).not.toMatch(/^\s+width: calc\(100vw - 2rem\) !important;$/m);
expect(globalsCss).not.toContain(".dbx-transfer-dialog");
});
it("keeps the code snapshot dialog layout scoped to legacy WebViews without media queries", () => {
expect(codeSnapshotDialogSource).toContain('class="dbx-code-snapshot-dialog flex max-h-[calc(var(--dbx-viewport-height)-2rem)] flex-col overflow-hidden border border-border !bg-background text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[860px]"');
expect(codeSnapshotDialogSource).toContain('class="dbx-code-snapshot-dialog__footer"');
expect(codeSnapshotDialogSource).toContain('html.dbx-legacy-webview [data-slot="dialog-content"].dbx-code-snapshot-dialog');
expect(codeSnapshotDialogSource).toContain("width: calc(100vw - 2rem) !important;");
expect(codeSnapshotDialogSource).toContain("flex-direction: row !important;");
expect(codeSnapshotDialogSource).toContain("flex-wrap: nowrap !important;");
expect(codeSnapshotDialogSource).toContain("justify-content: flex-end !important;");
expect(codeSnapshotDialogSource).not.toContain("@media");
});
it("keeps the update dialog's wide layout fallback scoped to legacy WebViews without media queries", () => {
expect(updateDialogSource).toContain('class="dbx-update-dialog sm:max-w-[520px]"');
expect(updateDialogSource).toContain('html.dbx-legacy-webview [data-slot="dialog-content"].dbx-update-dialog[class~="max-w-sm"]');
expect(updateDialogSource).toContain('dbx-update-dialog [data-slot="dialog-footer"]');
expect(updateDialogSource).not.toContain("@media");
});
it("keeps the DDL dialog's desktop layout scoped to legacy WebViews without media queries", () => {
expect(ddlViewDialogSource).toContain('class="dbx-ddl-view-dialog sm:max-w-190"');
expect(ddlViewDialogSource).toContain('html.dbx-legacy-webview [data-slot="dialog-content"].dbx-ddl-view-dialog[class~="max-w-sm"]');
expect(ddlViewDialogSource).toContain("max-width: 47.5rem !important;");
expect(ddlViewDialogSource).toContain('dbx-ddl-view-dialog [data-slot="dialog-footer"]');
expect(ddlViewDialogSource).not.toContain("@media");
});
it("uses an explicit tooltip copy-button hover color in legacy WebViews", () => {
expect(dataGridColumnHeaderSource).toContain("data-column-header-copy-name");
expect(dataGridColumnHeaderSource).toContain("html.dbx-legacy-webview [data-column-header-copy-name]:hover");
expect(dataGridColumnHeaderSource).toContain("background-color: rgba(255, 255, 255, 0.1);");
expect(dataGridColumnHeaderSource).toContain("html.dbx-legacy-webview.dark [data-column-header-copy-name]:hover");
expect(dataGridColumnHeaderSource).toContain("background-color: rgba(0, 0, 0, 0.1);");
});
it("keeps primary alpha utilities readable in legacy WebViews", () => {
expect(globalsCss).toContain("--dbx-primary-rgb: 23, 23, 23;");
expect(globalsCss).toContain("--dbx-primary-rgb: 46, 95, 166;");