Копирование Markdown-ссылки в iframe без clipboard-write: execCommand, «Скопійовано», окно ручного копирования
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Api, ApiError, type Crumb, type Node, type Page, type SearchHit } from "./api";
|
import { Api, ApiError, type Crumb, type Node, type Page, type SearchHit } from "./api";
|
||||||
import { retryReady, useAuth } from "./crm";
|
import { retryReady, useAuth } from "./crm";
|
||||||
|
import { CopyButton } from "./CopyButton";
|
||||||
import { ConfirmDialog, MoveDialog, PromptDialog } from "./Dialogs";
|
import { ConfirmDialog, MoveDialog, PromptDialog } from "./Dialogs";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { PageView } from "./PageView";
|
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: "rename", node: n })}><Icon name="edit" />Перейменувати</button>
|
||||||
</>}
|
</>}
|
||||||
<button className="btn sm ghost" onClick={() => setDlg({ kind: "move", node: n })}><Icon name="move" />Перемістити</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> не ламається при перейменуванні та перенесенні"
|
{n.kind === "page" && <CopyButton text={`[${n.title}](/p/${n.id})`} label="Markdown-посилання"
|
||||||
onClick={() => void navigator.clipboard?.writeText(`[${n.title}](/p/${n.id})`)}><Icon name="copy" />Markdown-посилання</button>}
|
title="Посилання виду /p/<id> не ламається при перейменуванні та перенесенні" />}
|
||||||
<button className="btn sm ghost danger" onClick={() => askDelete(n)}><Icon name="trash" />Видалити</button>
|
<button className="btn sm ghost danger" onClick={() => askDelete(n)}><Icon name="trash" />Видалити</button>
|
||||||
</div>
|
</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>
|
</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