Копирование Markdown-ссылки в iframe без clipboard-write: execCommand, «Скопійовано», окно ручного копирования
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
27
frontend/src/CopyButton.tsx
Normal file
27
frontend/src/CopyButton.tsx
Normal 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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
34
frontend/src/clipboard.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user