Avoid false clipboard success on insecure origins

This commit is contained in:
Codex
2026-05-13 01:55:34 +00:00
parent 015184bca7
commit 7b3235b218
4 changed files with 120 additions and 66 deletions

View File

@@ -131,9 +131,11 @@ function getWorkReferenceImages(work: GalleryWork): string[] {
}
async function copyGalleryText(text: string, successMessage: string) {
const copied = await copyTextToClipboard(text);
if (copied) {
const copyResult = await copyTextToClipboard(text);
if (copyResult === 'copied') {
toast.success(successMessage);
} else if (copyResult === 'manual') {
toast.info('已选中文本,请按 Ctrl+C 复制');
} else {
toast.error('复制失败,请手动选择文本复制');
}

View File

@@ -82,9 +82,11 @@ const IMAGE_TO_IMAGE_DRAFT_KEY = 'miaojing:image-to-image-draft';
async function copyText(value: string) {
const text = value.trim();
if (!text) return;
const copied = await copyTextToClipboard(text);
if (copied) {
const copyResult = await copyTextToClipboard(text);
if (copyResult === 'copied') {
toast.success('已复制');
} else if (copyResult === 'manual') {
toast.info('已选中文本,请按 Ctrl+C 复制');
} else {
toast.error('复制失败,请手动选择文本复制');
}

View File

@@ -175,9 +175,11 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange }:
const handleCopyPrompt = async () => {
if (!record.prompt) return;
const copied = await copyTextToClipboard(record.prompt);
if (copied) {
const copyResult = await copyTextToClipboard(record.prompt);
if (copyResult === 'copied') {
toast.success('提示词已复制');
} else if (copyResult === 'manual') {
toast.info('已选中提示词,请按 Ctrl+C 复制');
} else {
toast.error('复制失败,请手动选择文本复制');
}
@@ -185,9 +187,11 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange }:
const handleCopyNegativePrompt = async () => {
if (!record.negativePrompt) return;
const copied = await copyTextToClipboard(record.negativePrompt);
if (copied) {
const copyResult = await copyTextToClipboard(record.negativePrompt);
if (copyResult === 'copied') {
toast.success('反向提示词已复制');
} else if (copyResult === 'manual') {
toast.info('已选中反向提示词,请按 Ctrl+C 复制');
} else {
toast.error('复制失败,请手动选择文本复制');
}

View File

@@ -5,74 +5,120 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export async function copyTextToClipboard(text: string): Promise<boolean> {
if (typeof window === 'undefined' || typeof document === 'undefined') return false;
export type ClipboardCopyResult = 'copied' | 'manual' | 'failed';
function openManualCopyDialog(value: string): ClipboardCopyResult {
const existing = document.getElementById('miaojing-manual-copy-dialog');
existing?.remove();
const overlay = document.createElement('div');
overlay.id = 'miaojing-manual-copy-dialog';
overlay.style.position = 'fixed';
overlay.style.inset = '0';
overlay.style.zIndex = '2147483647';
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
overlay.style.padding = '24px';
overlay.style.background = 'rgba(16, 12, 8, 0.52)';
overlay.style.backdropFilter = 'blur(8px)';
const panel = document.createElement('div');
panel.style.width = 'min(720px, 100%)';
panel.style.maxHeight = 'min(620px, 90vh)';
panel.style.display = 'flex';
panel.style.flexDirection = 'column';
panel.style.gap = '12px';
panel.style.padding = '18px';
panel.style.borderRadius = '18px';
panel.style.border = '1px solid rgba(120, 82, 38, 0.22)';
panel.style.background = 'rgba(255, 252, 246, 0.98)';
panel.style.boxShadow = '0 24px 80px rgba(0, 0, 0, 0.28)';
panel.style.color = '#24170f';
const title = document.createElement('div');
title.textContent = '浏览器限制了自动复制';
title.style.fontSize = '16px';
title.style.fontWeight = '700';
const help = document.createElement('div');
help.textContent = '下面的提示词已自动选中,请按 Ctrl+C 复制Mac 请按 Command+C。';
help.style.fontSize = '13px';
help.style.lineHeight = '1.6';
help.style.color = '#6f5f4d';
const textarea = document.createElement('textarea');
textarea.value = value;
textarea.readOnly = true;
textarea.style.width = '100%';
textarea.style.minHeight = '260px';
textarea.style.maxHeight = '46vh';
textarea.style.resize = 'vertical';
textarea.style.padding = '12px';
textarea.style.borderRadius = '12px';
textarea.style.border = '1px solid rgba(120, 82, 38, 0.22)';
textarea.style.background = '#fffaf2';
textarea.style.color = '#24170f';
textarea.style.fontSize = '13px';
textarea.style.lineHeight = '1.65';
textarea.style.outline = 'none';
const actions = document.createElement('div');
actions.style.display = 'flex';
actions.style.justifyContent = 'flex-end';
actions.style.gap = '10px';
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.textContent = '关闭';
closeButton.style.height = '36px';
closeButton.style.padding = '0 14px';
closeButton.style.borderRadius = '10px';
closeButton.style.border = '1px solid rgba(120, 82, 38, 0.22)';
closeButton.style.background = '#ffffff';
closeButton.style.color = '#24170f';
closeButton.style.cursor = 'pointer';
const close = () => {
window.removeEventListener('keydown', handleKeyDown, true);
overlay.remove();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') close();
};
closeButton.addEventListener('click', close);
window.addEventListener('keydown', handleKeyDown, true);
actions.appendChild(closeButton);
panel.append(title, help, textarea, actions);
overlay.appendChild(panel);
document.body.appendChild(overlay);
window.setTimeout(() => {
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, value.length);
}, 0);
return 'manual';
}
export async function copyTextToClipboard(text: string): Promise<ClipboardCopyResult> {
if (typeof window === 'undefined' || typeof document === 'undefined') return 'failed';
const value = text.trim();
if (!value) return false;
if (!value) return 'failed';
if (navigator.clipboard?.writeText && window.isSecureContext) {
try {
await navigator.clipboard.writeText(value);
return true;
return 'copied';
} catch {
// Fall back below. Some browsers expose the API but still reject it.
return openManualCopyDialog(value);
}
}
const textarea = document.createElement('textarea');
textarea.value = value;
textarea.setAttribute('aria-hidden', 'true');
textarea.style.position = 'fixed';
textarea.style.left = '12px';
textarea.style.top = '12px';
textarea.style.width = '2px';
textarea.style.height = '2px';
textarea.style.padding = '0';
textarea.style.border = '0';
textarea.style.outline = '0';
textarea.style.boxShadow = 'none';
textarea.style.background = 'transparent';
textarea.style.color = 'transparent';
textarea.style.caretColor = 'transparent';
textarea.style.zIndex = '-1';
document.body.appendChild(textarea);
const selection = document.getSelection();
const previousRange = selection && selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null;
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
let cleanupScheduled = false;
try {
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, value.length);
const copied = document.queryCommandSupported?.('copy') !== false && document.execCommand('copy');
if (copied) {
cleanupScheduled = true;
window.setTimeout(() => {
textarea.remove();
previousFocus?.focus({ preventScroll: true });
if (selection && previousRange) {
selection.removeAllRanges();
selection.addRange(previousRange);
}
}, 80);
return true;
}
return false;
} catch {
return false;
} finally {
if (!cleanupScheduled && document.body.contains(textarea)) {
document.body.removeChild(textarea);
previousFocus?.focus({ preventScroll: true });
if (selection && previousRange) {
selection.removeAllRanges();
selection.addRange(previousRange);
}
}
}
return openManualCopyDialog(value);
}
/**