diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c5df4a..f29a718 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### 新增 - 支持抽屉模式设置;现在可以在 `挤压模式` 和 `浮层模式` 之间切换,浮层模式下支持点击抽屉外区域直接关闭。 - 支持在“首帖 + 最新回复”视图下手动刷新最新回复,刷新时会尽量保留当前抽屉滚动位置,不必关闭后重新打开主题。 +- 支持在抽屉快速回复框里直接粘贴图片自动上传,成功后会插入站点原生的 `upload://` Markdown。 - GitHub Release 现在会同时产出 Chrome ZIP 和 Firefox 未签名 XPI,方便在 Firefox 里做临时安装和验证。 ### 改进 diff --git a/README.md b/README.md index e9aea38..9eb7e5b 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - 智能预览和整页模式都会按站点正常进帖的方式发起计数请求,尽量兼容论坛浏览计数 - 接口不可用时,自动回退为右侧 iframe 整页预览 - 支持在抽屉内直接回复主题或指定楼层,支持 Markdown,`Ctrl+Enter` / `Cmd+Enter` 快捷发送 +- 回复框支持直接粘贴图片并自动上传,插入与站点原生回复一致的 `upload://` Markdown - 支持无限滚动自动加载更多帖子;下滑接近底部会自动拉取后续回复,底部显示加载进度 - 支持抽屉内图片点击全屏放大预览;点击遮罩层、右上角关闭或按 `Esc` 可退出 - 带普通外链的图片仍按原链接打开;指向原图的图片链接会优先走抽屉内预览 diff --git a/doc/agent-smoke-cases.md b/doc/agent-smoke-cases.md index 37c9978..3363f63 100644 --- a/doc/agent-smoke-cases.md +++ b/doc/agent-smoke-cases.md @@ -221,6 +221,26 @@ bash scripts/agent-smoke.sh --cdp-port 9222 --cases AGENT-CHROME-001,AGENT-CHROM 3. 刷新完成后按钮恢复为 `刷新` 4. 过程中 `location.href` 保持在列表页,抽屉不关闭 +### AGENT-CHROME-011:快速回复粘贴图片自动上传 + +范围: +抽屉快速回复框图片粘贴上传 + +状态: +当前不在默认批量脚本中;需要登录态和一张已复制到系统剪贴板的图片 + +步骤: + +1. 打开任一可回复主题的抽屉 +2. 点击右下角 `回复` 打开快速回复面板 +3. 聚焦回复输入框,直接粘贴剪贴板中的图片 + +断言: + +1. 回复状态立即进入“正在上传图片...”或“正在上传 x 张图片...”状态 +2. 上传成功后,输入框里会插入 `upload://` 形式的 Markdown,而不是 base64 文本 +3. 上传进行中时,`发送回复` 按钮应禁用,避免把占位文本提前发出去 + ### AGENT-CHROME-013:抽屉滚动不穿透页面 范围: diff --git a/src/content.js b/src/content.js index 4708711..5f083ca 100644 --- a/src/content.js +++ b/src/content.js @@ -10,6 +10,7 @@ const IMAGE_PREVIEW_SCALE_MIN = 1; const IMAGE_PREVIEW_SCALE_MAX = 4; const IMAGE_PREVIEW_SCALE_STEP = 0.2; + const REPLY_UPLOAD_MARKER = "\u2063"; const DEFAULT_SETTINGS = { previewMode: "smart", postMode: "all", @@ -124,6 +125,10 @@ abortController: null, loadMoreAbortController: null, replyAbortController: null, + replyUploadControllers: [], + replyUploadPendingCount: 0, + replyUploadSerial: 0, + replyComposerSessionId: 0, lastLocation: location.href, settings: loadSettings(), isResizing: false, @@ -238,7 +243,7 @@
回复主题
- +
@@ -292,6 +297,7 @@ state.replySubmitButton.addEventListener("click", handleReplySubmit); root.querySelector(".ld-reply-panel-close").addEventListener("click", () => setReplyPanelOpen(false)); state.replyTextarea.addEventListener("keydown", handleReplyTextareaKeydown); + state.replyTextarea.addEventListener("paste", handleReplyTextareaPaste); root.addEventListener("click", handleDrawerRootClick); root.addEventListener("wheel", handleDrawerRootWheel, { passive: false }); state.drawerBody.addEventListener("scroll", handleDrawerBodyScroll, { passive: true }); @@ -1221,11 +1227,305 @@ handleReplySubmit(); } + function handleReplyTextareaPaste(event) { + if ( + event.defaultPrevented || + event.target !== state.replyTextarea || + !state.currentTopic || + state.isReplySubmitting + ) { + return; + } + + const files = getReplyPasteImageFiles(event); + if (!files.length) { + return; + } + + event.preventDefault(); + queueReplyPasteUploads(files).catch(() => {}); + } + + function getReplyPasteImageFiles(event) { + const clipboardData = event?.clipboardData; + if (!clipboardData) { + return []; + } + + const types = Array.from(clipboardData.types || []); + if (types.includes("text/plain") || types.includes("text/html")) { + return []; + } + + return Array.from(clipboardData.files || []) + .map(normalizeReplyUploadFile) + .filter((file) => file instanceof File && isImageUploadFile(file)); + } + + function normalizeReplyUploadFile(file) { + if (!(file instanceof Blob)) { + return null; + } + + const fileName = resolveReplyUploadFileName(file); + if (file instanceof File && file.name) { + return file; + } + + if (typeof File === "function") { + return new File([file], fileName, { + type: file.type || "image/png", + lastModified: file instanceof File ? file.lastModified : Date.now() + }); + } + + try { + file.name = fileName; + } catch { + // 某些浏览器实现里 name 只读,忽略即可。 + } + + return file; + } + + function resolveReplyUploadFileName(file) { + const originalName = typeof file?.name === "string" + ? file.name.trim() + : ""; + if (originalName) { + return originalName; + } + + return `image.${mimeTypeToFileExtension(file?.type)}`; + } + + function mimeTypeToFileExtension(mimeType) { + const normalized = String(mimeType || "").toLowerCase(); + if (normalized === "image/jpeg") { + return "jpg"; + } + + if (normalized === "image/svg+xml") { + return "svg"; + } + + const match = normalized.match(/^image\/([a-z0-9.+-]+)$/i); + if (!match) { + return "png"; + } + + return match[1].replace("svg+xml", "svg"); + } + + function isImageUploadFile(file) { + if (!(file instanceof File)) { + return false; + } + + if (String(file.type || "").toLowerCase().startsWith("image/")) { + return true; + } + + return isImageUploadName(file.name || ""); + } + + async function queueReplyPasteUploads(files) { + if (!state.replyTextarea || !state.currentTopic) { + return; + } + + const sessionId = state.replyComposerSessionId; + const placeholders = insertReplyUploadPlaceholders(files); + if (!placeholders.length) { + return; + } + + state.replyUploadPendingCount += placeholders.length; + syncReplyUI(); + updateReplyUploadStatus(); + + const results = await Promise.allSettled( + placeholders.map((entry) => uploadReplyPasteFile(entry, sessionId)) + ); + + if (sessionId !== state.replyComposerSessionId || state.replyUploadPendingCount > 0 || !state.replyStatus) { + return; + } + + const successCount = results.filter((result) => result.status === "fulfilled").length; + const failures = results.filter((result) => result.status === "rejected"); + + if (!failures.length) { + state.replyStatus.textContent = successCount > 1 + ? `已上传 ${successCount} 张图片,已插入回复内容。` + : "图片已上传,已插入回复内容。"; + return; + } + + if (!successCount) { + state.replyStatus.textContent = failures.length > 1 + ? `图片上传失败(${failures.length} 张):${failures.map((item) => item.reason?.message || "未知错误").join(";")}` + : `图片上传失败:${failures[0].reason?.message || "未知错误"}`; + return; + } + + state.replyStatus.textContent = `图片上传完成:${successCount} 张成功,${failures.length} 张失败。`; + } + + function insertReplyUploadPlaceholders(files) { + if (!state.replyTextarea) { + return []; + } + + const entries = files.map((file) => buildReplyUploadPlaceholder(file)); + insertReplyTextareaText(entries.map((entry) => entry.insertedText).join("")); + return entries; + } + + function buildReplyUploadPlaceholder(file) { + const uploadId = `ld-upload-${Date.now()}-${++state.replyUploadSerial}`; + const visibleLabel = `[图片上传中:${sanitizeReplyUploadFileName(file.name || "image.png")}]`; + const marker = `${REPLY_UPLOAD_MARKER}${uploadId}${REPLY_UPLOAD_MARKER}${visibleLabel}${REPLY_UPLOAD_MARKER}/${uploadId}${REPLY_UPLOAD_MARKER}`; + + return { + file, + marker, + insertedText: `${marker}\n` + }; + } + + function sanitizeReplyUploadFileName(fileName) { + return String(fileName || "image.png") + .replace(/\s+/g, " ") + .trim(); + } + + function insertReplyTextareaText(text) { + if (!state.replyTextarea) { + return; + } + + const textarea = state.replyTextarea; + const start = Number.isFinite(textarea.selectionStart) + ? textarea.selectionStart + : textarea.value.length; + const end = Number.isFinite(textarea.selectionEnd) + ? textarea.selectionEnd + : start; + + textarea.focus(); + textarea.setRangeText(text, start, end, "end"); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + } + + async function uploadReplyPasteFile(entry, sessionId) { + const controller = new AbortController(); + addReplyUploadController(controller); + + try { + const upload = await createComposerUpload(entry.file, controller.signal, { pasted: true }); + if (controller.signal.aborted || sessionId !== state.replyComposerSessionId) { + return upload; + } + + const markdown = buildComposerUploadMarkdown(upload); + const inserted = replaceReplyUploadPlaceholder(entry.marker, `${markdown}\n`); + if (!inserted) { + insertReplyTextareaText(`\n${markdown}\n`); + } + + return upload; + } catch (error) { + if (!controller.signal.aborted && sessionId === state.replyComposerSessionId) { + removeReplyUploadPlaceholder(entry.marker); + } + + if (controller.signal.aborted) { + return null; + } + + throw error; + } finally { + removeReplyUploadController(controller); + if (state.replyUploadPendingCount > 0) { + state.replyUploadPendingCount -= 1; + } + + syncReplyUI(); + if (sessionId === state.replyComposerSessionId && state.replyUploadPendingCount > 0) { + updateReplyUploadStatus(); + } + } + } + + function replaceReplyUploadPlaceholder(marker, replacement) { + return replaceReplyTextareaText(marker, replacement); + } + + function removeReplyUploadPlaceholder(marker) { + replaceReplyTextareaText(marker, ""); + } + + function replaceReplyTextareaText(searchText, replacementText) { + if (!state.replyTextarea) { + return false; + } + + const textarea = state.replyTextarea; + const start = textarea.value.indexOf(searchText); + if (start === -1) { + return false; + } + + textarea.setRangeText( + replacementText, + start, + start + searchText.length, + "preserve" + ); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + return true; + } + + function addReplyUploadController(controller) { + state.replyUploadControllers.push(controller); + } + + function removeReplyUploadController(controller) { + state.replyUploadControllers = state.replyUploadControllers.filter((item) => item !== controller); + } + + function cancelReplyUploads() { + for (const controller of state.replyUploadControllers) { + controller.abort(); + } + + state.replyUploadControllers = []; + state.replyUploadPendingCount = 0; + } + + function updateReplyUploadStatus() { + if (!state.replyStatus || state.replyUploadPendingCount <= 0) { + return; + } + + state.replyStatus.textContent = state.replyUploadPendingCount > 1 + ? `正在上传 ${state.replyUploadPendingCount} 张图片...` + : "正在上传图片..."; + } + async function handleReplySubmit() { if (!state.currentTopic || state.isReplySubmitting || !state.replyTextarea || !state.replyStatus) { return; } + if (state.replyUploadPendingCount > 0) { + state.replyStatus.textContent = state.replyUploadPendingCount > 1 + ? `还有 ${state.replyUploadPendingCount} 张图片正在上传,请稍候再发送。` + : "图片还在上传中,请稍候再发送。"; + return; + } + const raw = state.replyTextarea.value.trim(); if (!raw) { state.replyStatus.textContent = "先写点内容再发送。"; @@ -1962,6 +2262,88 @@ return data; } + async function createComposerUpload(file, signal, options = {}) { + const csrfToken = getCsrfToken(); + if (!csrfToken) { + throw new Error("未找到登录令牌,请刷新页面后重试"); + } + + const formData = new FormData(); + formData.set("upload_type", "composer"); + formData.set("file", file, file.name || "image.png"); + if (options.pasted) { + formData.set("pasted", "true"); + } + + const response = await fetch(`${location.origin}/uploads.json`, { + method: "POST", + credentials: "include", + signal, + headers: { + Accept: "application/json", + "X-CSRF-Token": csrfToken + }, + body: formData + }); + + const contentType = response.headers.get("content-type") || ""; + const data = contentType.includes("json") + ? await response.json() + : null; + + if (!response.ok) { + const message = Array.isArray(data?.errors) && data.errors.length > 0 + ? data.errors.join(";") + : (data?.message || data?.error || `Unexpected response: ${response.status}`); + throw new Error(message); + } + + if (!data || typeof data !== "object") { + throw new Error(`Unexpected response: ${response.status}`); + } + + return data; + } + + function buildComposerUploadMarkdown(upload) { + const fileName = upload?.original_filename || "image.png"; + const uploadUrl = upload?.short_url || upload?.url || ""; + if (!uploadUrl) { + throw new Error("上传成功但未返回可用图片地址"); + } + + if (isImageUploadName(fileName)) { + return buildComposerImageMarkdown(upload, uploadUrl); + } + + return `[${fileName}|attachment](${uploadUrl})`; + } + + function buildComposerImageMarkdown(upload, uploadUrl) { + const altText = markdownNameFromFileName(upload?.original_filename || "image.png"); + const width = Number(upload?.thumbnail_width || upload?.width || 0); + const height = Number(upload?.thumbnail_height || upload?.height || 0); + const sizeSegment = width > 0 && height > 0 + ? `|${width}x${height}` + : ""; + + return `![${altText}${sizeSegment}](${uploadUrl})`; + } + + function markdownNameFromFileName(fileName) { + const normalized = String(fileName || "").trim(); + const dotIndex = normalized.lastIndexOf("."); + const baseName = dotIndex > 0 + ? normalized.slice(0, dotIndex) + : normalized; + + return (baseName || "image").replace(/[\[\]|]/g, ""); + } + + function isImageUploadName(fileName) { + return /\.(avif|bmp|gif|jpe?g|png|svg|webp)$/i.test(String(fileName || "")); + } + function getCsrfToken() { const token = document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; return token.trim(); @@ -2477,9 +2859,12 @@ } function resetReplyComposer() { + state.replyComposerSessionId += 1; + cancelReplyUploads(); + if (state.replyTextarea) { state.replyTextarea.value = ""; - state.replyTextarea.placeholder = "写点什么... 支持 Markdown。Ctrl+Enter 或 Cmd+Enter 可发送"; + state.replyTextarea.placeholder = buildReplyTextareaPlaceholder(); } if (state.replyStatus) { @@ -2494,7 +2879,7 @@ function syncReplyUI() { const hasTopic = Boolean(state.currentTopic?.id); const isTargetedReply = Number.isFinite(state.replyTargetPostNumber); - + const isReplyUploading = state.replyUploadPendingCount > 0; const isIframeMode = state.root?.classList.contains(IFRAME_MODE_CLASS); if (state.replyButton) { @@ -2507,8 +2892,8 @@ state.replyTextarea.disabled = !hasTopic || state.isReplySubmitting; if (hasTopic) { state.replyTextarea.placeholder = isTargetedReply - ? `回复 ${state.replyTargetLabel}... 支持 Markdown。Ctrl+Enter 或 Cmd+Enter 可发送` - : `回复《${state.currentTopic.title || state.currentFallbackTitle || "当前主题"}》... 支持 Markdown。Ctrl+Enter 或 Cmd+Enter 可发送`; + ? buildReplyTextareaPlaceholder(`回复 ${state.replyTargetLabel}`) + : buildReplyTextareaPlaceholder(`回复《${state.currentTopic.title || state.currentFallbackTitle || "当前主题"}》`); } } @@ -2519,8 +2904,14 @@ } if (state.replySubmitButton) { - state.replySubmitButton.disabled = !hasTopic || state.isReplySubmitting; - state.replySubmitButton.textContent = state.isReplySubmitting ? "发送中..." : "发送回复"; + state.replySubmitButton.disabled = !hasTopic || state.isReplySubmitting || isReplyUploading; + state.replySubmitButton.textContent = state.isReplySubmitting + ? "发送中..." + : (isReplyUploading + ? (state.replyUploadPendingCount > 1 + ? `上传 ${state.replyUploadPendingCount} 张图片中...` + : "图片上传中...") + : "发送回复"); } if (state.replyCancelButton) { @@ -2528,6 +2919,10 @@ } } + function buildReplyTextareaPlaceholder(prefix = "写点什么") { + return `${prefix}... 支持 Markdown,可直接粘贴图片自动上传。Ctrl+Enter 或 Cmd+Enter 可发送`; + } + function syncSettingsUI() { if (!state.settingsPanel) { return;