From f7a9004c73f065fd384da9a069dc17a1f9a75e17 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:26:34 -0800 Subject: [PATCH] fix: add execCommand fallback for clipboard --- .../src/components/common/copy-button.tsx | 33 +++++++++ apps/client/src/components/common/copy.tsx | 3 +- .../mfa/components/mfa-backup-codes-modal.tsx | 2 +- .../src/ee/mfa/components/mfa-setup-modal.tsx | 2 +- .../components/code-block/code-block-view.tsx | 3 +- .../components/header/page-header-menu.tsx | 3 +- .../page/tree/components/space-tree.tsx | 2 +- .../share/components/share-action-menu.tsx | 2 +- .../members/components/invite-action-menu.tsx | 2 +- .../components/workspace-invite-section.tsx | 3 +- apps/client/src/hooks/use-clipboard.ts | 71 +++++++++++++++++++ 11 files changed, 117 insertions(+), 9 deletions(-) create mode 100644 apps/client/src/components/common/copy-button.tsx create mode 100644 apps/client/src/hooks/use-clipboard.ts diff --git a/apps/client/src/components/common/copy-button.tsx b/apps/client/src/components/common/copy-button.tsx new file mode 100644 index 00000000..eb0721d7 --- /dev/null +++ b/apps/client/src/components/common/copy-button.tsx @@ -0,0 +1,33 @@ +// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/core/src/components/CopyButton/CopyButton.tsx - MIT +// modified to use the polyfilled clipboard api +import React from "react"; +import { useClipboard } from "@/hooks/use-clipboard"; +import { useProps } from "@mantine/core"; + +interface CopyButtonProps { + /** Children callback, provides current status and copy function as an argument */ + children: (payload: { copied: boolean; copy: () => void }) => React.ReactNode; + + /** Value that is copied to the clipboard when the button is clicked */ + value: string; + + /** Copied status timeout in ms @default `1000` */ + timeout?: number; +} + +const defaultProps = { + timeout: 1000, +} satisfies Partial; + +export function CopyButton(props: CopyButtonProps) { + const { children, timeout, value, ...others } = useProps( + "CopyButton", + defaultProps, + props, + ); + const clipboard = useClipboard({ timeout }); + const copy = () => clipboard.copy(value); + return <>{children({ copy, copied: clipboard.copied, ...others })}; +} + +CopyButton.displayName = "@mantine/core/CopyButton"; diff --git a/apps/client/src/components/common/copy.tsx b/apps/client/src/components/common/copy.tsx index efae5750..81a70771 100644 --- a/apps/client/src/components/common/copy.tsx +++ b/apps/client/src/components/common/copy.tsx @@ -1,4 +1,5 @@ -import { ActionIcon, CopyButton, Tooltip } from "@mantine/core"; +import { ActionIcon, Tooltip } from "@mantine/core"; +import { CopyButton } from "@/components/common/copy-button"; import { IconCheck, IconCopy } from "@tabler/icons-react"; import React from "react"; import { useTranslation } from "react-i18next"; diff --git a/apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx b/apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx index c24638fe..6b439ef5 100644 --- a/apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx +++ b/apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx @@ -8,10 +8,10 @@ import { Group, List, Code, - CopyButton, Alert, PasswordInput, } from "@mantine/core"; +import { CopyButton } from "@/components/common/copy-button"; import { IconRefresh, IconCopy, diff --git a/apps/client/src/ee/mfa/components/mfa-setup-modal.tsx b/apps/client/src/ee/mfa/components/mfa-setup-modal.tsx index d01f2c9f..89d479d7 100644 --- a/apps/client/src/ee/mfa/components/mfa-setup-modal.tsx +++ b/apps/client/src/ee/mfa/components/mfa-setup-modal.tsx @@ -11,7 +11,6 @@ import { PinInput, Alert, List, - CopyButton, ActionIcon, Tooltip, Paper, @@ -20,6 +19,7 @@ import { Collapse, UnstyledButton, } from "@mantine/core"; +import { CopyButton } from "@/components/common/copy-button"; import { IconQrcode, IconShieldCheck, diff --git a/apps/client/src/features/editor/components/code-block/code-block-view.tsx b/apps/client/src/features/editor/components/code-block/code-block-view.tsx index 130016a3..0ff2fe36 100644 --- a/apps/client/src/features/editor/components/code-block/code-block-view.tsx +++ b/apps/client/src/features/editor/components/code-block/code-block-view.tsx @@ -1,5 +1,6 @@ import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; -import { ActionIcon, CopyButton, Group, Select, Tooltip } from "@mantine/core"; +import { ActionIcon, Group, Select, Tooltip } from "@mantine/core"; +import { CopyButton } from "@/components/common/copy-button"; import { useEffect, useState } from "react"; import { IconCheck, IconCopy } from "@tabler/icons-react"; import classes from "./code-block.module.css"; diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index 9cd4362d..3932a2c9 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -17,7 +17,8 @@ import React, { useEffect, useRef, useState } from "react"; import useToggleAside from "@/hooks/use-toggle-aside.tsx"; import { useAtom, useAtomValue } from "jotai"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; -import { useClipboard, useDisclosure, useHotkeys } from "@mantine/hooks"; +import { useDisclosure, useHotkeys } from "@mantine/hooks"; +import { useClipboard } from "@/hooks/use-clipboard"; import { useParams } from "react-router-dom"; import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts"; diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx index d889a89e..b985d7bb 100644 --- a/apps/client/src/features/page/tree/components/space-tree.tsx +++ b/apps/client/src/features/page/tree/components/space-tree.tsx @@ -54,11 +54,11 @@ import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts"; import { queryClient } from "@/main.tsx"; import { OpenMap } from "react-arborist/dist/main/state/open-slice"; import { - useClipboard, useDisclosure, useElementSize, useMergedRef, } from "@mantine/hooks"; +import { useClipboard } from "@/hooks/use-clipboard"; import { dfs } from "react-arborist/dist/module/utils"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts"; diff --git a/apps/client/src/features/share/components/share-action-menu.tsx b/apps/client/src/features/share/components/share-action-menu.tsx index 398e25e9..52dad0da 100644 --- a/apps/client/src/features/share/components/share-action-menu.tsx +++ b/apps/client/src/features/share/components/share-action-menu.tsx @@ -13,7 +13,7 @@ import { buildPageUrl, buildSharedPageUrl, } from "@/features/page/page.utils.ts"; -import { useClipboard } from "@mantine/hooks"; +import { useClipboard } from "@/hooks/use-clipboard"; import { notifications } from "@mantine/notifications"; import { useNavigate } from "react-router-dom"; import { useDeleteShareMutation } from "@/features/share/queries/share-query.ts"; diff --git a/apps/client/src/features/workspace/components/members/components/invite-action-menu.tsx b/apps/client/src/features/workspace/components/members/components/invite-action-menu.tsx index 6fc06cf5..e2fcf512 100644 --- a/apps/client/src/features/workspace/components/members/components/invite-action-menu.tsx +++ b/apps/client/src/features/workspace/components/members/components/invite-action-menu.tsx @@ -8,7 +8,7 @@ import { } from "@/features/workspace/queries/workspace-query.ts"; import { useTranslation } from "react-i18next"; import { notifications } from "@mantine/notifications"; -import { useClipboard } from "@mantine/hooks"; +import { useClipboard } from "@/hooks/use-clipboard"; import { getInviteLink } from "@/features/workspace/services/workspace-service.ts"; import useUserRole from "@/hooks/use-user-role.tsx"; import { isCloud } from "@/lib/config.ts"; diff --git a/apps/client/src/features/workspace/components/members/components/workspace-invite-section.tsx b/apps/client/src/features/workspace/components/members/components/workspace-invite-section.tsx index 11596d0a..a49ae5ee 100644 --- a/apps/client/src/features/workspace/components/members/components/workspace-invite-section.tsx +++ b/apps/client/src/features/workspace/components/members/components/workspace-invite-section.tsx @@ -1,7 +1,8 @@ import { useAtom } from "jotai"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; import { useEffect, useState } from "react"; -import { Button, CopyButton, Group, Text, TextInput } from "@mantine/core"; +import { Button, Group, Text, TextInput } from "@mantine/core"; +import { CopyButton } from "@/components/common/copy-button"; import { useTranslation } from "react-i18next"; export default function WorkspaceInviteSection() { diff --git a/apps/client/src/hooks/use-clipboard.ts b/apps/client/src/hooks/use-clipboard.ts new file mode 100644 index 00000000..e05fa56d --- /dev/null +++ b/apps/client/src/hooks/use-clipboard.ts @@ -0,0 +1,71 @@ +// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-clipboard/use-clipboard.ts +// polyfilled to support execCommand fallback +import { useState } from "react"; + +export type UseClipboardOptions = { + timeout?: number; +}; + +export type UseClipboardReturnValue = { + copy: (value: string) => void; + reset: () => void; + error: Error | null; + copied: boolean; +}; + +export function useClipboard( + options: UseClipboardOptions = { timeout: 2000 }, +): UseClipboardReturnValue { + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const [copyTimeout, setCopyTimeout] = useState(null); + + const handleCopyResult = (value: boolean) => { + window.clearTimeout(copyTimeout!); + setCopyTimeout(window.setTimeout(() => setCopied(false), options.timeout)); + setCopied(value); + }; + + const copy = (value: string) => { + if ("clipboard" in navigator) { + navigator.clipboard + .writeText(value) + .then(() => handleCopyResult(true)) + .catch(() => { + try { + fallbackCopy(value); + handleCopyResult(true); + } catch (err) { + setError(err instanceof Error ? err : new Error("Failed to copy")); + } + }); + } else { + try { + fallbackCopy(value); + handleCopyResult(true); + } catch (err) { + setError(err instanceof Error ? err : new Error("Failed to copy")); + } + } + }; + + const reset = () => { + setCopied(false); + setError(null); + window.clearTimeout(copyTimeout!); + }; + + return { copy, reset, error, copied }; +} + +function fallbackCopy(value: string): void { + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + textarea.style.top = "-9999px"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); +}