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 ``;
+ }
+
+ 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;