Копирование Markdown-ссылки в iframe без clipboard-write: execCommand, «Скопійовано», окно ручного копирования

This commit is contained in:
crm-wiki agent
2026-10-13 13:57:37 +00:00
parent 426ed6f248
commit c8453ac6bb
4 changed files with 84 additions and 2 deletions

View File

@@ -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 }) {
<button className="btn sm ghost" onClick={() => setDlg({ kind: "rename", node: n })}><Icon name="edit" />Перейменувати</button>
</>}
<button className="btn sm ghost" onClick={() => setDlg({ kind: "move", node: n })}><Icon name="move" />Перемістити</button>
{n.kind === "page" && <button className="btn sm ghost" title="Посилання виду /p/<id> не ламається при перейменуванні та перенесенні"
onClick={() => void navigator.clipboard?.writeText(`[${n.title}](/p/${n.id})`)}><Icon name="copy" />Markdown-посилання</button>}
{n.kind === "page" && <CopyButton text={`[${n.title}](/p/${n.id})`} label="Markdown-посилання"
title="Посилання виду /p/<id> не ламається при перейменуванні та перенесенні" />}
<button className="btn sm ghost danger" onClick={() => askDelete(n)}><Icon name="trash" />Видалити</button>
</div>
);

View File

@@ -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<number | undefined>(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 (
<>
<button className={props.className ?? "btn sm ghost"} title={props.title} onClick={() => void click()} aria-live="polite">
<Icon name={done ? "check" : "copy"} />{done ? "Скопійовано" : props.label}
</button>
{manual && <ManualCopyDialog text={props.text} onClose={() => setManual(false)} />}
</>
);
}

View File

@@ -156,3 +156,23 @@ export function PagePickDialog(props: { nodes: Node[]; exclude?: string; onPick:
</Sheet>
);
}
/** Не удалось скопировать автоматически: поле с уже выделенным текстом. */
export function ManualCopyDialog(props: { text: string; onClose: () => void }) {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []);
return (
<Sheet title="Скопіюйте посилання" onClose={props.onClose} footer={<>
<span className="sp" />
<button className="btn gold" onClick={props.onClose}>Готово</button>
</>}>
<div className="f">
<label>Скопіюйте вручну (Ctrl+C)</label>
<div className="in">
<input ref={ref} readOnly value={props.text} onFocus={(e) => e.target.select()} onClick={(e) => e.currentTarget.select()} />
</div>
<div className="small mute mt-6">Автоматично скопіювати не вдалося: браузер не дав доступу до буфера обміну.</div>
</div>
</Sheet>
);
}

34
frontend/src/clipboard.ts Normal file
View File

@@ -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<boolean> {
// 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);
}