diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7f2b6d2..8d2aee8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Api, ApiError, type Crumb, type Node, type Page, type SearchHit } from "./api"; import { retryReady, useAuth } from "./crm"; +import { CopyButton } from "./CopyButton"; import { ConfirmDialog, MoveDialog, PromptDialog } from "./Dialogs"; import { Icon } from "./Icon"; import { PageView } from "./PageView"; @@ -164,8 +165,8 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) { setDlg({ kind: "rename", node: n })}>Перейменувати >} setDlg({ kind: "move", node: n })}>Перемістити - {n.kind === "page" && void navigator.clipboard?.writeText(`[${n.title}](/p/${n.id})`)}>Markdown-посилання} + {n.kind === "page" && } askDelete(n)}>Видалити ); diff --git a/frontend/src/CopyButton.tsx b/frontend/src/CopyButton.tsx new file mode 100644 index 0000000..c47a03e --- /dev/null +++ b/frontend/src/CopyButton.tsx @@ -0,0 +1,27 @@ +import { useEffect, useRef, useState } from "react"; +import { copyText } from "./clipboard"; +import { ManualCopyDialog } from "./Dialogs"; +import { Icon } from "./Icon"; + +/** Кнопка «скопировать»: после успеха пару секунд пишет «Скопійовано», при неудаче — окно для ручного копирования. */ +export function CopyButton(props: { text: string; label: string; title?: string; className?: string }) { + const [done, setDone] = useState(false); + const [manual, setManual] = useState(false); + const timer = useRef(undefined); + useEffect(() => () => window.clearTimeout(timer.current), []); + const click = async () => { + if (await copyText(props.text)) { + setDone(true); + window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => setDone(false), 2000); + } else setManual(true); + }; + return ( + <> + void click()} aria-live="polite"> + {done ? "Скопійовано" : props.label} + + {manual && setManual(false)} />} + > + ); +} diff --git a/frontend/src/Dialogs.tsx b/frontend/src/Dialogs.tsx index 908cee7..9fe63ed 100644 --- a/frontend/src/Dialogs.tsx +++ b/frontend/src/Dialogs.tsx @@ -156,3 +156,23 @@ export function PagePickDialog(props: { nodes: Node[]; exclude?: string; onPick: ); } + +/** Не удалось скопировать автоматически: поле с уже выделенным текстом. */ +export function ManualCopyDialog(props: { text: string; onClose: () => void }) { + const ref = useRef(null); + useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []); + return ( + + + Готово + >}> + + Скопіюйте вручну (Ctrl+C) + + e.target.select()} onClick={(e) => e.currentTarget.select()} /> + + Автоматично скопіювати не вдалося: браузер не дав доступу до буфера обміну. + + + ); +} diff --git a/frontend/src/clipboard.ts b/frontend/src/clipboard.ts new file mode 100644 index 0000000..828a4f2 --- /dev/null +++ b/frontend/src/clipboard.ts @@ -0,0 +1,34 @@ +// Копирование в буфер обмена внутри iframe CRM. +// CRM создаёт iframe без allow="clipboard-write", поэтому navigator.clipboard.writeText там отклоняется. +// Запасной путь — временный textarea + document.execCommand("copy"): он работает от клика пользователя. + +function copyViaTextarea(text: string): boolean { + const prev = document.activeElement as HTMLElement | null; + const ta = document.createElement("textarea"); + ta.value = text; + ta.setAttribute("readonly", ""); + // Вне экрана, но в документе: иначе выделение не сработает. + ta.style.position = "fixed"; + ta.style.top = "-1000px"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.select(); + ta.setSelectionRange(0, text.length); + let ok = false; + try { ok = document.execCommand("copy"); } catch { ok = false; } + ta.remove(); + prev?.focus?.({ preventScroll: true }); + return ok; +} + +/** true — скопировано; false — оба способа не сработали (показать окно для ручного копирования). */ +export async function copyText(text: string): Promise { + // Chrome сообщает заранее, что clipboard-write запрещён: тогда сразу синхронный путь, пока жива активация от клика. + const fp = (document as Document & { featurePolicy?: { allowsFeature(f: string): boolean } }).featurePolicy; + const blocked = fp ? !fp.allowsFeature("clipboard-write") : false; + if (!blocked && navigator.clipboard?.writeText && window.isSecureContext) { + try { await navigator.clipboard.writeText(text); return true; } + catch { /* нет разрешения clipboard-write — пробуем запасной путь */ } + } + return copyViaTextarea(text); +}