fix(desktop): respect native clipboard regions
This commit is contained in:
parent
8ae9863c51
commit
c32a95c477
|
|
@ -182,10 +182,10 @@ onBeforeUnmount(() => {
|
|||
</div>
|
||||
|
||||
<!-- Shiki highlighted SQL -->
|
||||
<div v-else-if="highlightedHtml" class="p-3 text-xs leading-relaxed [&_pre]:!bg-transparent [&_pre]:!p-0 [&_code]:!font-mono [&_code]:text-xs" v-html="highlightedHtml" />
|
||||
<div v-else-if="highlightedHtml" data-native-clipboard class="p-3 text-xs leading-relaxed [&_pre]:!bg-transparent [&_pre]:!p-0 [&_code]:!font-mono [&_code]:text-xs" v-html="highlightedHtml" />
|
||||
|
||||
<!-- Plain text fallback -->
|
||||
<pre v-else class="p-3 text-xs font-mono whitespace-pre-wrap select-text">{{ displaySql }}</pre>
|
||||
<pre v-else data-native-clipboard class="p-3 text-xs font-mono whitespace-pre-wrap select-text">{{ displaySql }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ import { parseClipboardTable } from "@/lib/gridSelection";
|
|||
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useDataGridExport } from "@/composables/useDataGridExport";
|
||||
import { readTextFromClipboard } from "@/lib/clipboard";
|
||||
import { eventTargetAllowsNativeClipboard, isPlainClipboardShortcut, readTextFromClipboard } from "@/lib/clipboard";
|
||||
import ExportProgressDialog from "@/components/export/ExportProgressDialog.vue";
|
||||
import { DATA_GRID_ROW_NUM_WIDTH, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
|
||||
import { useDataGridSelection } from "@/composables/useDataGridSelection";
|
||||
|
|
@ -4746,25 +4746,6 @@ function openImagePreview(src: string, title: string) {
|
|||
imagePreviewOpen.value = true;
|
||||
}
|
||||
|
||||
function selectionNodeElement(node: Node | null): Element | null {
|
||||
if (!node) return null;
|
||||
return node instanceof Element ? node : node.parentElement;
|
||||
}
|
||||
|
||||
function hasNativeClipboardSelection(): boolean {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed) return false;
|
||||
const anchorRegion = selectionNodeElement(selection.anchorNode)?.closest("[data-native-clipboard]");
|
||||
const focusRegion = selectionNodeElement(selection.focusNode)?.closest("[data-native-clipboard]");
|
||||
return !!anchorRegion && anchorRegion === focusRegion;
|
||||
}
|
||||
|
||||
function eventTargetAllowsNativeClipboard(event: KeyboardEvent): boolean {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest("input, textarea, [contenteditable='true'], [role='textbox']")) return true;
|
||||
return clipboardShortcut(event, "c") && hasNativeClipboardSelection();
|
||||
}
|
||||
|
||||
function onDrawerContextMenu(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
const target = event.target as HTMLElement | null;
|
||||
|
|
@ -4773,7 +4754,7 @@ function onDrawerContextMenu(event: MouseEvent) {
|
|||
}
|
||||
|
||||
function clipboardShortcut(event: KeyboardEvent, key: string): boolean {
|
||||
return (event.metaKey || event.ctrlKey) && !event.altKey && event.key.toLowerCase() === key;
|
||||
return isPlainClipboardShortcut(event, key);
|
||||
}
|
||||
|
||||
let lastPasteEventAt = 0;
|
||||
|
|
|
|||
|
|
@ -37,6 +37,55 @@ export interface ClipboardEnvironment {
|
|||
document?: ClipboardDocument;
|
||||
}
|
||||
|
||||
export interface ClipboardShortcutEvent {
|
||||
key: string;
|
||||
metaKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
altKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
target?: EventTarget | null;
|
||||
}
|
||||
|
||||
interface SelectionLike {
|
||||
anchorNode: Node | null;
|
||||
focusNode: Node | null;
|
||||
isCollapsed: boolean;
|
||||
}
|
||||
|
||||
interface NativeClipboardSelectionEnvironment {
|
||||
getSelection?: () => SelectionLike | null;
|
||||
}
|
||||
|
||||
const EDITABLE_CLIPBOARD_TARGET_SELECTOR = "input, textarea, [contenteditable='true'], [role='textbox']";
|
||||
const NATIVE_CLIPBOARD_REGION_SELECTOR = "[data-native-clipboard]";
|
||||
|
||||
function closestElement(target: unknown, selector: string): unknown {
|
||||
return (target as { closest?: (selector: string) => unknown } | null)?.closest?.(selector) ?? null;
|
||||
}
|
||||
|
||||
function selectionNodeElement(node: Node | null): Element | null {
|
||||
if (!node) return null;
|
||||
if (typeof Element !== "undefined" && node instanceof Element) return node;
|
||||
return (node as { parentElement?: Element | null }).parentElement ?? null;
|
||||
}
|
||||
|
||||
export function isPlainClipboardShortcut(event: ClipboardShortcutEvent, key: string): boolean {
|
||||
return !!(event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === key;
|
||||
}
|
||||
|
||||
export function hasNativeClipboardSelection(env: NativeClipboardSelectionEnvironment = globalThis as unknown as NativeClipboardSelectionEnvironment): boolean {
|
||||
const selection = env.getSelection?.();
|
||||
if (!selection || selection.isCollapsed) return false;
|
||||
const anchorRegion = selectionNodeElement(selection.anchorNode)?.closest(NATIVE_CLIPBOARD_REGION_SELECTOR);
|
||||
const focusRegion = selectionNodeElement(selection.focusNode)?.closest(NATIVE_CLIPBOARD_REGION_SELECTOR);
|
||||
return !!anchorRegion && anchorRegion === focusRegion;
|
||||
}
|
||||
|
||||
export function eventTargetAllowsNativeClipboard(event: ClipboardShortcutEvent, env: NativeClipboardSelectionEnvironment = globalThis as unknown as NativeClipboardSelectionEnvironment): boolean {
|
||||
if (closestElement(event.target, EDITABLE_CLIPBOARD_TARGET_SELECTOR)) return true;
|
||||
return isPlainClipboardShortcut(event, "c") && hasNativeClipboardSelection(env);
|
||||
}
|
||||
|
||||
export async function readTextFromClipboard(env: ClipboardEnvironment = globalThis as unknown as ClipboardEnvironment): Promise<string> {
|
||||
if (isTauriRuntime(env as unknown as Record<string, unknown>)) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import { test } from "vitest";
|
||||
import { copyToClipboard, readTextFromClipboard } from "../../apps/desktop/src/lib/clipboard.ts";
|
||||
import { copyToClipboard, eventTargetAllowsNativeClipboard, hasNativeClipboardSelection, isPlainClipboardShortcut, readTextFromClipboard } from "../../apps/desktop/src/lib/clipboard.ts";
|
||||
|
||||
test("copyToClipboard falls back when navigator clipboard is unavailable", async () => {
|
||||
const appended: unknown[] = [];
|
||||
|
|
@ -60,3 +60,55 @@ test("readTextFromClipboard uses navigator clipboard when available", async () =
|
|||
|
||||
assert.equal(text, "orders\t42");
|
||||
});
|
||||
|
||||
test("clipboard shortcut detection requires a plain mod shortcut", () => {
|
||||
assert.equal(isPlainClipboardShortcut({ key: "C", ctrlKey: true }, "c"), true);
|
||||
assert.equal(isPlainClipboardShortcut({ key: "c", metaKey: true }, "c"), true);
|
||||
assert.equal(isPlainClipboardShortcut({ key: "c", ctrlKey: true, shiftKey: true }, "c"), false);
|
||||
assert.equal(isPlainClipboardShortcut({ key: "c", altKey: true }, "c"), false);
|
||||
});
|
||||
|
||||
test("eventTargetAllowsNativeClipboard lets editable targets keep clipboard shortcuts", () => {
|
||||
const inputTarget = {
|
||||
closest: (selector: string) => (selector.includes("input") ? {} : null),
|
||||
} as unknown as EventTarget;
|
||||
|
||||
assert.equal(eventTargetAllowsNativeClipboard({ key: "v", ctrlKey: true, target: inputTarget }), true);
|
||||
});
|
||||
|
||||
test("hasNativeClipboardSelection detects selections inside one native clipboard region", () => {
|
||||
const region = {};
|
||||
const element = {
|
||||
closest: (selector: string) => (selector === "[data-native-clipboard]" ? region : null),
|
||||
};
|
||||
const textNode = { parentElement: element } as unknown as Node;
|
||||
|
||||
assert.equal(
|
||||
hasNativeClipboardSelection({
|
||||
getSelection: () => ({
|
||||
anchorNode: textNode,
|
||||
focusNode: textNode,
|
||||
isCollapsed: false,
|
||||
}),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("eventTargetAllowsNativeClipboard lets native regions handle copy only with a text selection", () => {
|
||||
const region = {};
|
||||
const element = {
|
||||
closest: (selector: string) => (selector === "[data-native-clipboard]" ? region : null),
|
||||
};
|
||||
const textNode = { parentElement: element } as unknown as Node;
|
||||
const env = {
|
||||
getSelection: () => ({
|
||||
anchorNode: textNode,
|
||||
focusNode: textNode,
|
||||
isCollapsed: false,
|
||||
}),
|
||||
};
|
||||
|
||||
assert.equal(eventTargetAllowsNativeClipboard({ key: "c", ctrlKey: true }, env), true);
|
||||
assert.equal(eventTargetAllowsNativeClipboard({ key: "x", ctrlKey: true }, env), false);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue