mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-29 16:18:55 +00:00
fix: prevent repeated menu activations
This commit is contained in:
@@ -5,6 +5,7 @@ import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { CheckIcon } from "lucide-vue-next";
|
||||
import { ContextMenuCheckboxItem, ContextMenuItemIndicator, useForwardPropsEmits } from "reka-ui";
|
||||
import { shouldSuppressRepeatedActivation, suppressEvent, type ActionActivationGuard } from "@/lib/actionActivation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<ContextMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>();
|
||||
@@ -13,12 +14,21 @@ const emits = defineEmits<ContextMenuCheckboxItemEmits>();
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
const activationGuard: ActionActivationGuard = {};
|
||||
|
||||
function guardRepeatedClick(event: MouseEvent) {
|
||||
if (props.disabled) return;
|
||||
if (shouldSuppressRepeatedActivation(activationGuard)) {
|
||||
suppressEvent(event);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuCheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
v-bind="forwarded"
|
||||
@click.capture="guardRepeatedClick"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ContextMenuItemEmits, ContextMenuItemProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ContextMenuItem, useForwardPropsEmits } from "reka-ui";
|
||||
import { shouldSuppressRepeatedActivation, suppressEvent, type ActionActivationGuard } from "@/lib/actionActivation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -22,6 +23,14 @@ const emits = defineEmits<ContextMenuItemEmits>();
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
const activationGuard: ActionActivationGuard = {};
|
||||
|
||||
function guardRepeatedClick(event: MouseEvent) {
|
||||
if (props.disabled) return;
|
||||
if (shouldSuppressRepeatedActivation(activationGuard)) {
|
||||
suppressEvent(event);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -30,6 +39,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
:data-inset="inset ? '' : undefined"
|
||||
:data-variant="variant"
|
||||
v-bind="forwarded"
|
||||
@click.capture="guardRepeatedClick"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive focus:*:[svg]:text-accent-foreground gap-2 rounded-md px-2 py-1 text-[13px] data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 group/context-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { CheckIcon } from "lucide-vue-next";
|
||||
import { ContextMenuItemIndicator, ContextMenuRadioItem, useForwardPropsEmits } from "reka-ui";
|
||||
import { shouldSuppressRepeatedActivation, suppressEvent, type ActionActivationGuard } from "@/lib/actionActivation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<ContextMenuRadioItemProps & { class?: HTMLAttributes["class"] }>();
|
||||
@@ -13,12 +14,21 @@ const emits = defineEmits<ContextMenuRadioItemEmits>();
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
const activationGuard: ActionActivationGuard = {};
|
||||
|
||||
function guardRepeatedClick(event: MouseEvent) {
|
||||
if (props.disabled) return;
|
||||
if (shouldSuppressRepeatedActivation(activationGuard)) {
|
||||
suppressEvent(event);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuRadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
v-bind="forwarded"
|
||||
@click.capture="guardRepeatedClick"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { CheckIcon } from "lucide-vue-next";
|
||||
import { DropdownMenuCheckboxItem, DropdownMenuItemIndicator, useForwardPropsEmits } from "reka-ui";
|
||||
import { shouldSuppressRepeatedActivation, suppressEvent, type ActionActivationGuard } from "@/lib/actionActivation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>();
|
||||
@@ -13,12 +14,21 @@ const emits = defineEmits<DropdownMenuCheckboxItemEmits>();
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
const activationGuard: ActionActivationGuard = {};
|
||||
|
||||
function guardRepeatedClick(event: MouseEvent) {
|
||||
if (props.disabled) return;
|
||||
if (shouldSuppressRepeatedActivation(activationGuard)) {
|
||||
suppressEvent(event);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuCheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
v-bind="forwarded"
|
||||
@click.capture="guardRepeatedClick"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { DropdownMenuItemProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { DropdownMenuItem, useForwardProps } from "reka-ui";
|
||||
import { shouldSuppressRepeatedActivation, suppressEvent, type ActionActivationGuard } from "@/lib/actionActivation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -21,6 +22,14 @@ const props = withDefaults(
|
||||
const delegatedProps = reactiveOmit(props, "inset", "variant", "class");
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
const activationGuard: ActionActivationGuard = {};
|
||||
|
||||
function guardRepeatedClick(event: MouseEvent) {
|
||||
if (props.disabled) return;
|
||||
if (shouldSuppressRepeatedActivation(activationGuard)) {
|
||||
suppressEvent(event);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -29,6 +38,7 @@ const forwardedProps = useForwardProps(delegatedProps);
|
||||
:data-inset="inset ? '' : undefined"
|
||||
:data-variant="variant"
|
||||
v-bind="forwardedProps"
|
||||
@click.capture="guardRepeatedClick"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { CheckIcon } from "lucide-vue-next";
|
||||
import { DropdownMenuItemIndicator, DropdownMenuRadioItem, useForwardPropsEmits } from "reka-ui";
|
||||
import { shouldSuppressRepeatedActivation, suppressEvent, type ActionActivationGuard } from "@/lib/actionActivation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }>();
|
||||
@@ -14,12 +15,21 @@ const emits = defineEmits<DropdownMenuRadioItemEmits>();
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
const activationGuard: ActionActivationGuard = {};
|
||||
|
||||
function guardRepeatedClick(event: MouseEvent) {
|
||||
if (props.disabled) return;
|
||||
if (shouldSuppressRepeatedActivation(activationGuard)) {
|
||||
suppressEvent(event);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
v-bind="forwarded"
|
||||
@click.capture="guardRepeatedClick"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/lib/gridSelection";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { displayCellValue, type CellValue } from "@/lib/cellValue";
|
||||
import { tryStartExclusiveActivation, type ActionActivationGuard } from "@/lib/actionActivation";
|
||||
|
||||
interface RowItem {
|
||||
id: number;
|
||||
@@ -46,6 +47,7 @@ export interface UseDataGridExportOptions {
|
||||
export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const exportGuard: ActionActivationGuard = {};
|
||||
|
||||
const {
|
||||
columns,
|
||||
@@ -239,56 +241,74 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
}
|
||||
|
||||
// --- Export functions ---
|
||||
async function exportCsv(rowIds?: number[]) {
|
||||
async function runExclusiveExport(action: () => Promise<void>) {
|
||||
const finish = tryStartExclusiveActivation(exportGuard);
|
||||
if (!finish) return;
|
||||
try {
|
||||
const rows = rowsToExport(rowIds).map((item) => item.data.map((c) => displayCellValue(c)));
|
||||
if (await saveFileContent(formatCsv(columns.value, rows), "export.csv", "CSV", "csv")) {
|
||||
toast(t("grid.exported"));
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
await action();
|
||||
} finally {
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
async function exportCsv(rowIds?: number[]) {
|
||||
await runExclusiveExport(async () => {
|
||||
try {
|
||||
const rows = rowsToExport(rowIds).map((item) => item.data.map((c) => displayCellValue(c)));
|
||||
if (await saveFileContent(formatCsv(columns.value, rows), "export.csv", "CSV", "csv")) {
|
||||
toast(t("grid.exported"));
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function exportJson(rowIds?: number[]) {
|
||||
try {
|
||||
const rows = rowsToExport(rowIds).map((item) => item.data);
|
||||
if (await saveFileContent(formatJson(columns.value, rows), "export.json", "JSON", "json")) {
|
||||
toast(t("grid.exported"));
|
||||
await runExclusiveExport(async () => {
|
||||
try {
|
||||
const rows = rowsToExport(rowIds).map((item) => item.data);
|
||||
if (await saveFileContent(formatJson(columns.value, rows), "export.json", "JSON", "json")) {
|
||||
toast(t("grid.exported"));
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function exportMarkdown(rowIds?: number[]) {
|
||||
try {
|
||||
const cols = columns.value;
|
||||
const visibleRows = rowsToExport(rowIds).map((item) => item.data);
|
||||
const { formatMarkdownTable } = await import("@/lib/markdownTable");
|
||||
const md = formatMarkdownTable({ columns: cols, rows: visibleRows });
|
||||
if (await saveFileContent(md, "export.md", "Markdown", "md")) {
|
||||
toast(t("grid.exported"));
|
||||
await runExclusiveExport(async () => {
|
||||
try {
|
||||
const cols = columns.value;
|
||||
const visibleRows = rowsToExport(rowIds).map((item) => item.data);
|
||||
const { formatMarkdownTable } = await import("@/lib/markdownTable");
|
||||
const md = formatMarkdownTable({ columns: cols, rows: visibleRows });
|
||||
if (await saveFileContent(md, "export.md", "Markdown", "md")) {
|
||||
toast(t("grid.exported"));
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function exportXlsx(rowIds?: number[]) {
|
||||
try {
|
||||
const { buildXlsxWorkbook } = await import("@/lib/xlsxExport");
|
||||
const workbook = buildXlsxWorkbook({
|
||||
sheetName: tableMeta.value?.tableName || "Export",
|
||||
columns: columns.value,
|
||||
rows: rowsToExport(rowIds).map((item) => item.data),
|
||||
});
|
||||
if (await saveBinaryFileContent(workbook, "export.xlsx", "Excel", "xlsx")) {
|
||||
toast(t("grid.exported"));
|
||||
await runExclusiveExport(async () => {
|
||||
try {
|
||||
const { buildXlsxWorkbook } = await import("@/lib/xlsxExport");
|
||||
const workbook = buildXlsxWorkbook({
|
||||
sheetName: tableMeta.value?.tableName || "Export",
|
||||
columns: columns.value,
|
||||
rows: rowsToExport(rowIds).map((item) => item.data),
|
||||
});
|
||||
if (await saveBinaryFileContent(workbook, "export.xlsx", "Excel", "xlsx")) {
|
||||
toast(t("grid.exported"));
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function copySql() {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
export const DEFAULT_REPEATED_ACTIVATION_WINDOW_MS = 500;
|
||||
|
||||
export interface ActionActivationGuard {
|
||||
lastActivatedAt?: number;
|
||||
running?: boolean;
|
||||
}
|
||||
|
||||
function nowMs(): number {
|
||||
if (typeof performance !== "undefined" && typeof performance.now === "function") {
|
||||
return performance.now();
|
||||
}
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
export function shouldSuppressRepeatedActivation(
|
||||
guard: ActionActivationGuard,
|
||||
now = nowMs(),
|
||||
windowMs = DEFAULT_REPEATED_ACTIVATION_WINDOW_MS,
|
||||
): boolean {
|
||||
if (guard.lastActivatedAt !== undefined && now - guard.lastActivatedAt < windowMs) {
|
||||
return true;
|
||||
}
|
||||
guard.lastActivatedAt = now;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function suppressEvent(event: Event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation?.();
|
||||
}
|
||||
|
||||
export function tryStartExclusiveActivation(guard: ActionActivationGuard): (() => void) | null {
|
||||
if (guard.running) return null;
|
||||
guard.running = true;
|
||||
let finished = false;
|
||||
return () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
guard.running = false;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user