diff --git a/desktop-client/main.js b/desktop-client/main.js
index 31367e1..632c0d4 100644
--- a/desktop-client/main.js
+++ b/desktop-client/main.js
@@ -4,6 +4,7 @@ const logger = require('./nodeapi/logger');
// 引入IPC处理程序模块
const { setupIpcHandlers } = require('./nodeapi/ipcHandlers');
+const { attachEditContextMenu } = require('./nodeapi/editContextMenu');
let mainWindow;
let ipcHandlersInitialized = false;
@@ -101,6 +102,7 @@ function isExternalUrl(url) {
// 全局拦截所有 webContents 的新窗口打开行为
app.on('web-contents-created', (event, contents) => {
+ attachEditContextMenu(contents);
contents.setWindowOpenHandler(({ url }) => {
if (isExternalUrl(url)) {
shell.openExternal(url);
diff --git a/desktop-client/nodeapi/editContextMenu.js b/desktop-client/nodeapi/editContextMenu.js
new file mode 100644
index 0000000..aa60dcf
--- /dev/null
+++ b/desktop-client/nodeapi/editContextMenu.js
@@ -0,0 +1,51 @@
+/**
+ * 可编辑区域 / 选中文本的右键菜单。
+ * 使用 Electron role,Windows 为 Ctrl、macOS 为 Command,系统会自动套对应快捷键。
+ */
+function buildEditMenuTemplate(params = {}) {
+ const { isEditable, editFlags = {}, selectionText = '' } = params;
+ const canCopy = Boolean(editFlags.canCopy || selectionText);
+
+ if (isEditable) {
+ return [
+ { role: 'undo', label: '撤销', enabled: Boolean(editFlags.canUndo) },
+ { role: 'redo', label: '重做', enabled: Boolean(editFlags.canRedo) },
+ { type: 'separator' },
+ { role: 'cut', label: '剪切', enabled: Boolean(editFlags.canCut) },
+ { role: 'copy', label: '复制', enabled: canCopy },
+ { role: 'paste', label: '粘贴', enabled: Boolean(editFlags.canPaste) },
+ { role: 'delete', label: '删除', enabled: Boolean(editFlags.canDelete) },
+ { type: 'separator' },
+ { role: 'selectAll', label: '全选', enabled: Boolean(editFlags.canSelectAll) },
+ ];
+ }
+
+ if (canCopy) {
+ return [{ role: 'copy', label: '复制' }];
+ }
+ return [];
+}
+
+function attachEditContextMenu(contents) {
+ if (!contents || contents.isDestroyed()) {
+ return;
+ }
+ if (typeof contents.getType === 'function' && contents.getType() === 'devtools') {
+ return;
+ }
+ contents.on('context-menu', (event, params) => {
+ const template = buildEditMenuTemplate(params);
+ if (!template.length) {
+ return;
+ }
+ const { Menu, BrowserWindow } = require('electron');
+ const menu = Menu.buildFromTemplate(template);
+ const window = BrowserWindow.fromWebContents(contents);
+ menu.popup({ window });
+ });
+}
+
+module.exports = {
+ attachEditContextMenu,
+ buildEditMenuTemplate,
+};
diff --git a/desktop-client/nodeapi/editContextMenu.test.js b/desktop-client/nodeapi/editContextMenu.test.js
new file mode 100644
index 0000000..4105133
--- /dev/null
+++ b/desktop-client/nodeapi/editContextMenu.test.js
@@ -0,0 +1,40 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const { buildEditMenuTemplate } = require('./editContextMenu');
+
+test('editable field includes copy and paste', () => {
+ const template = buildEditMenuTemplate({
+ isEditable: true,
+ editFlags: {
+ canUndo: true,
+ canRedo: false,
+ canCut: true,
+ canCopy: true,
+ canPaste: true,
+ canDelete: true,
+ canSelectAll: true,
+ },
+ selectionText: 'https://example.com',
+ });
+ const roles = template.filter((item) => item.role).map((item) => item.role);
+ assert.deepEqual(roles, ['undo', 'redo', 'cut', 'copy', 'paste', 'delete', 'selectAll']);
+ assert.equal(template.find((item) => item.role === 'copy').label, '复制');
+ assert.equal(template.find((item) => item.role === 'paste').label, '粘贴');
+});
+
+test('non-editable selection only offers copy', () => {
+ const template = buildEditMenuTemplate({
+ isEditable: false,
+ selectionText: 'selected',
+ });
+ assert.deepEqual(template, [{ role: 'copy', label: '复制' }]);
+});
+
+test('empty non-editable area has no menu', () => {
+ const template = buildEditMenuTemplate({
+ isEditable: false,
+ selectionText: '',
+ editFlags: {},
+ });
+ assert.deepEqual(template, []);
+});
diff --git a/desktop-client/nodeapi/ipcHandlers.js b/desktop-client/nodeapi/ipcHandlers.js
index cd21cdc..8d79045 100644
--- a/desktop-client/nodeapi/ipcHandlers.js
+++ b/desktop-client/nodeapi/ipcHandlers.js
@@ -1,4 +1,4 @@
-const { ipcMain, dialog, app } = require('electron');
+const { ipcMain, dialog, app, clipboard } = require('electron');
// 引入logger模块
const logger = require('./logger');
@@ -90,6 +90,13 @@ function setupIpcHandlers(mainWindow) {
ipcMain.handle('get-app-version', async () => {
return app.getVersion();
});
+
+ ipcMain.handle('clipboard-read-text', () => clipboard.readText());
+
+ ipcMain.handle('clipboard-write-text', (event, text) => {
+ clipboard.writeText(String(text || ''));
+ return true;
+ });
}
module.exports = { setupIpcHandlers };
\ No newline at end of file
diff --git a/desktop-client/preload.js b/desktop-client/preload.js
index e5d650a..e44f855 100644
--- a/desktop-client/preload.js
+++ b/desktop-client/preload.js
@@ -47,4 +47,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
getHistoryRecord: () => ipcRenderer.invoke('get-history-record'),
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
+
+ clipboardReadText: () => ipcRenderer.invoke('clipboard-read-text'),
+
+ clipboardWriteText: (text) => ipcRenderer.invoke('clipboard-write-text', text),
});
\ No newline at end of file
diff --git a/desktop-client/web/src/components/TextEditContextMenu.jsx b/desktop-client/web/src/components/TextEditContextMenu.jsx
new file mode 100644
index 0000000..7a6108d
--- /dev/null
+++ b/desktop-client/web/src/components/TextEditContextMenu.jsx
@@ -0,0 +1,98 @@
+import { useEffect } from 'react';
+import { shortcutModifierLabel } from '../utils/draftUrlInput';
+
+const MENU_WIDTH = 220;
+const MENU_ESTIMATED_HEIGHT = 220;
+
+function clampPosition(x, y) {
+ const maxX = Math.max(8, window.innerWidth - MENU_WIDTH - 8);
+ const maxY = Math.max(8, window.innerHeight - MENU_ESTIMATED_HEIGHT - 8);
+ return {
+ left: Math.min(Math.max(8, x), maxX),
+ top: Math.min(Math.max(8, y), maxY),
+ };
+}
+
+function TextEditContextMenu({
+ x,
+ y,
+ canCut,
+ canCopy,
+ canPaste,
+ canSelectAll,
+ canClear,
+ onCut,
+ onCopy,
+ onPaste,
+ onSelectAll,
+ onClear,
+ onClose,
+}) {
+ const mod = shortcutModifierLabel();
+ const position = clampPosition(x, y);
+
+ useEffect(() => {
+ const handleKey = (event) => {
+ if (event.key === 'Escape') {
+ onClose();
+ }
+ };
+ const handleDismiss = () => onClose();
+ window.addEventListener('keydown', handleKey);
+ window.addEventListener('resize', handleDismiss);
+ window.addEventListener('scroll', handleDismiss, true);
+ return () => {
+ window.removeEventListener('keydown', handleKey);
+ window.removeEventListener('resize', handleDismiss);
+ window.removeEventListener('scroll', handleDismiss, true);
+ };
+ }, [onClose]);
+
+ const items = [
+ { key: 'cut', label: '剪切', shortcut: `${mod}+X`, enabled: canCut, action: onCut },
+ { key: 'copy', label: '复制', shortcut: `${mod}+C`, enabled: canCopy, action: onCopy },
+ { key: 'paste', label: '粘贴', shortcut: `${mod}+V`, enabled: canPaste, action: onPaste },
+ { key: 'sep-1', separator: true },
+ { key: 'selectAll', label: '全选', shortcut: `${mod}+A`, enabled: canSelectAll, action: onSelectAll },
+ { key: 'clear', label: '清空', enabled: canClear, action: onClear },
+ ];
+
+ return (
+ <>
+
+ event.preventDefault()}
+ >
+ {items.map((item) =>
+ item.separator ? (
+
+ ) : (
+ -
+
+
+ )
+ )}
+
+ >
+ );
+}
+
+export default TextEditContextMenu;
diff --git a/desktop-client/web/src/components/Textarea.jsx b/desktop-client/web/src/components/Textarea.jsx
index f1669a1..8dbe1e9 100644
--- a/desktop-client/web/src/components/Textarea.jsx
+++ b/desktop-client/web/src/components/Textarea.jsx
@@ -1,40 +1,190 @@
-import React, { useState, useEffect } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import TextEditContextMenu from './TextEditContextMenu';
+import electronService from '../services/electronService';
+import {
+ isElectronRenderer,
+ isMacPlatform,
+ normalizeDraftUrlPaste,
+ replaceSelection,
+ shortcutModifierLabel,
+} from '../utils/draftUrlInput';
+
+const MIN_HEIGHT = 140;
+const MAX_HEIGHT = 320;
function Textarea({ value, onChange }) {
- const [textareaHeight, setTextareaHeight] = useState('auto');
+ const textareaRef = useRef(null);
+ const [textareaHeight, setTextareaHeight] = useState(`${MIN_HEIGHT}px`);
+ const [menu, setMenu] = useState(null);
+ const mod = shortcutModifierLabel();
+ const useAppContextMenu = !isElectronRenderer();
- const handleInput = (e) => {
- onChange(e.target.value);
- adjustTextareaHeight(e.target);
+ const closeMenu = useCallback(() => setMenu(null), []);
+
+ const adjustTextareaHeight = useCallback((textarea) => {
+ if (!textarea) {
+ return;
+ }
+ textarea.style.height = 'auto';
+ const next = Math.min(Math.max(textarea.scrollHeight, MIN_HEIGHT), MAX_HEIGHT);
+ setTextareaHeight(`${next}px`);
+ }, []);
+
+ const focusAndSetCaret = (caret) => {
+ const el = textareaRef.current;
+ if (!el) {
+ return;
+ }
+ el.focus();
+ const pos = Math.max(0, Math.min(caret, el.value.length));
+ el.setSelectionRange(pos, pos);
+ adjustTextareaHeight(el);
};
- const adjustTextareaHeight = (textarea) => {
- textarea.style.height = 'auto';
- const newHeight = Math.min(textarea.scrollHeight, 300); // 设置最大高度
- setTextareaHeight(`${newHeight}px`);
+ const applyInsert = (insert) => {
+ const el = textareaRef.current;
+ const start = el ? el.selectionStart : value.length;
+ const end = el ? el.selectionEnd : value.length;
+ const { next, caret } = replaceSelection(value, start, end, insert);
+ onChange(next);
+ requestAnimationFrame(() => focusAndSetCaret(caret));
+ };
+
+ const selectedText = () => {
+ const el = textareaRef.current;
+ if (!el) {
+ return '';
+ }
+ return value.slice(el.selectionStart, el.selectionEnd);
+ };
+
+ const handleInput = (event) => {
+ onChange(event.target.value);
+ adjustTextareaHeight(event.target);
+ };
+
+ const handlePaste = (event) => {
+ const clipboard = event.clipboardData?.getData('text');
+ if (clipboard == null) {
+ return;
+ }
+ const normalized = normalizeDraftUrlPaste(clipboard);
+ if (normalized === clipboard) {
+ return;
+ }
+ event.preventDefault();
+ applyInsert(normalized);
+ };
+
+ const handleKeyDown = (event) => {
+ const withMod = isMacPlatform() ? event.metaKey : event.ctrlKey;
+ if (withMod && event.key.toLowerCase() === 'a') {
+ event.preventDefault();
+ textareaRef.current?.select();
+ }
+ };
+
+ const handleContextMenu = (event) => {
+ if (!useAppContextMenu) {
+ return;
+ }
+ event.preventDefault();
+ const el = textareaRef.current;
+ if (el && document.activeElement !== el) {
+ el.focus();
+ }
+ setMenu({
+ x: event.clientX,
+ y: event.clientY,
+ hasSelection: Boolean(selectedText()),
+ hasValue: Boolean(value),
+ });
+ };
+
+ const handleCut = async () => {
+ const text = selectedText();
+ if (!text) {
+ return;
+ }
+ await electronService.clipboardWriteText(text);
+ applyInsert('');
+ };
+
+ const handleCopy = async () => {
+ const text = selectedText();
+ if (!text) {
+ return;
+ }
+ await electronService.clipboardWriteText(text);
+ };
+
+ const handleMenuPaste = async () => {
+ try {
+ const clip = await electronService.clipboardReadText();
+ applyInsert(normalizeDraftUrlPaste(clip));
+ } catch (error) {
+ console.error('Paste failed:', error);
+ }
+ };
+
+ const handleSelectAll = () => {
+ textareaRef.current?.select();
+ };
+
+ const handleClear = () => {
+ onChange('');
+ requestAnimationFrame(() => focusAndSetCaret(0));
};
useEffect(() => {
- const textarea = document.querySelector('.auto-resize-textarea');
- if (textarea) {
- adjustTextareaHeight(textarea);
- }
- }, [value]);
+ adjustTextareaHeight(textareaRef.current);
+ }, [value, adjustTextareaHeight]);
return (
+ spellCheck={false}
+ autoComplete="off"
+ autoCorrect="off"
+ autoCapitalize="off"
+ wrap="soft"
+ inputMode="url"
+ />
+
+ 每行一个地址。右键可复制、粘贴、全选
+ {isMacPlatform() ? ';macOS 使用 ⌘,Ctrl+点击也可打开菜单' : ';Windows 使用 Ctrl'}。
+
+ {useAppContextMenu && menu ? (
+
+ ) : null}
);
}
diff --git a/desktop-client/web/src/pages/Download/index.less b/desktop-client/web/src/pages/Download/index.less
index fedc27d..e012ceb 100644
--- a/desktop-client/web/src/pages/Download/index.less
+++ b/desktop-client/web/src/pages/Download/index.less
@@ -61,15 +61,30 @@
.auto-resize-textarea {
width: 100%;
- min-height: 120px;
- padding: 8px;
+ min-height: 140px;
+ max-height: 320px;
+ padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 8px;
resize: none;
- font-size: 1rem;
- line-height: 1.5;
- transition: border-color 0.3s;
+ overflow-y: auto;
+ overflow-x: hidden;
+ font-size: 0.95rem;
+ line-height: 1.55;
+ letter-spacing: 0;
+ word-break: break-all;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+ font-family: ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, monospace;
+ color: #2c3e50;
+ background-color: #fff;
+ transition: border-color 0.3s, box-shadow 0.3s;
display: block;
+ tab-size: 4;
+}
+
+.auto-resize-textarea:hover {
+ border-color: #c5cdd6;
}
.auto-resize-textarea:focus {
@@ -78,6 +93,76 @@
box-shadow: 0 0 0 2px rgba(var(--secondary-color-rgb), 0.2);
}
+.auto-resize-textarea::placeholder {
+ color: #95a5a6;
+ font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
+ font-size: 0.92rem;
+ line-height: 1.5;
+}
+
+.textarea-hint {
+ margin: 8px 2px 0;
+ color: #7f8c8d;
+ font-size: 0.8125rem;
+ line-height: 1.4;
+}
+
+.text-edit-context-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+}
+
+.text-edit-context-menu {
+ position: fixed;
+ z-index: 1001;
+ min-width: 200px;
+ margin: 0;
+ padding: 6px 0;
+ list-style: none;
+ background: #fff;
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+ box-shadow: 0 8px 24px rgba(15, 23, 42, 0.16);
+}
+
+.text-edit-context-separator {
+ height: 1px;
+ margin: 6px 8px;
+ background: #eef0f2;
+}
+
+.text-edit-context-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+ width: 100%;
+ padding: 8px 14px;
+ border: 0;
+ background: transparent;
+ color: #2c3e50;
+ font-size: 0.875rem;
+ line-height: 1.2;
+ text-align: left;
+ cursor: pointer;
+}
+
+.text-edit-context-item:hover:not(:disabled) {
+ background: #f3f6f9;
+}
+
+.text-edit-context-item:disabled {
+ color: #b0b8c1;
+ cursor: default;
+}
+
+.text-edit-context-shortcut {
+ color: #95a5a6;
+ font-size: 0.75rem;
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+}
+
/* Tab样式 */
.tabs-module {
display: flex;
diff --git a/desktop-client/web/src/services/electronService.js b/desktop-client/web/src/services/electronService.js
index d5df9c0..01c6ba3 100644
--- a/desktop-client/web/src/services/electronService.js
+++ b/desktop-client/web/src/services/electronService.js
@@ -78,6 +78,17 @@ const mockElectronAPI = {
getAppVersion: async () => {
return null;
},
+ clipboardReadText: async () => {
+ if (navigator.clipboard?.readText) {
+ return await navigator.clipboard.readText();
+ }
+ return '';
+ },
+ clipboardWriteText: async (text) => {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(text);
+ }
+ },
};
// 实际的Electron API(用于Electron环境)
@@ -118,6 +129,12 @@ const electronAPI = {
getAppVersion: async () => {
return await window.electronAPI.getAppVersion();
},
+ clipboardReadText: async () => {
+ return await window.electronAPI.clipboardReadText();
+ },
+ clipboardWriteText: async (text) => {
+ return await window.electronAPI.clipboardWriteText(text);
+ },
};
// 根据环境选择使用哪个API实现
diff --git a/desktop-client/web/src/utils/draftUrlInput.js b/desktop-client/web/src/utils/draftUrlInput.js
new file mode 100644
index 0000000..61ff6d7
--- /dev/null
+++ b/desktop-client/web/src/utils/draftUrlInput.js
@@ -0,0 +1,47 @@
+/**
+ * 草稿地址输入:识别 macOS / Windows,规范化粘贴内容。
+ */
+
+export function isMacPlatform() {
+ if (typeof navigator === 'undefined') {
+ return false;
+ }
+ const platform = navigator.platform || '';
+ const ua = navigator.userAgent || '';
+ return /Mac|iPhone|iPad|iPod/i.test(platform) || /Mac OS X/i.test(ua);
+}
+
+export function isElectronRenderer() {
+ return Boolean(typeof window !== 'undefined' && window.electronAPI);
+}
+
+export function shortcutModifierLabel() {
+ return isMacPlatform() ? '⌘' : 'Ctrl';
+}
+
+/**
+ * 粘贴多个草稿 URL 时,拆成一行一个;普通文本保持原样(仅统一换行)。
+ */
+export function normalizeDraftUrlPaste(text) {
+ const raw = String(text || '')
+ .replace(/\r\n/g, '\n')
+ .replace(/\r/g, '\n');
+ const urlTokens = raw
+ .split(/\s+/)
+ .map((token) => token.trim())
+ .filter((token) => /^https?:\/\//i.test(token));
+ if (urlTokens.length >= 2) {
+ return urlTokens.join('\n');
+ }
+ return raw;
+}
+
+export function replaceSelection(value, start, end, insert) {
+ const from = Math.max(0, start ?? 0);
+ const to = Math.max(from, end ?? from);
+ const next = `${value.slice(0, from)}${insert}${value.slice(to)}`;
+ return {
+ next,
+ caret: from + String(insert).length,
+ };
+}
diff --git a/desktop-client/web/src/utils/draftUrlInput.test.cjs b/desktop-client/web/src/utils/draftUrlInput.test.cjs
new file mode 100644
index 0000000..6852b72
--- /dev/null
+++ b/desktop-client/web/src/utils/draftUrlInput.test.cjs
@@ -0,0 +1,52 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+
+function loadModule() {
+ const source = fs.readFileSync(path.join(__dirname, 'draftUrlInput.js'), 'utf8');
+ const wrapped = source
+ .replace(/export function /g, 'function ')
+ .concat('\nmodule.exports = { isMacPlatform, isElectronRenderer, shortcutModifierLabel, normalizeDraftUrlPaste, replaceSelection };\n');
+ const context = { module: { exports: {} }, exports: {}, navigator: { platform: 'Win32', userAgent: 'Windows' } };
+ context.module.exports = context.exports;
+ vm.runInNewContext(wrapped, context);
+ return context.module.exports;
+}
+
+const {
+ normalizeDraftUrlPaste,
+ replaceSelection,
+ shortcutModifierLabel,
+} = loadModule();
+
+test('keeps a single url unchanged', () => {
+ const url = 'https://capcut-mate.jcaigc.cn/openapi/capcut-mate/v1/get_draft?draft_id=1';
+ assert.equal(normalizeDraftUrlPaste(url), url);
+});
+
+test('splits multiple urls onto their own lines', () => {
+ const pasted = 'https://a.example/x?draft_id=1 https://b.example/y?draft_id=2';
+ assert.equal(
+ normalizeDraftUrlPaste(pasted),
+ 'https://a.example/x?draft_id=1\nhttps://b.example/y?draft_id=2'
+ );
+});
+
+test('normalizes windows newlines for ordinary text', () => {
+ assert.equal(normalizeDraftUrlPaste('a\r\nb\rc'), 'a\nb\nc');
+});
+
+test('replaceSelection inserts at the caret', () => {
+ const inserted = replaceSelection('ab', 1, 1, 'X');
+ assert.equal(inserted.next, 'aXb');
+ assert.equal(inserted.caret, 2);
+ const replaced = replaceSelection('abcd', 1, 3, 'Z');
+ assert.equal(replaced.next, 'aZd');
+ assert.equal(replaced.caret, 2);
+});
+
+test('windows shortcut label is Ctrl', () => {
+ assert.equal(shortcutModifierLabel(), 'Ctrl');
+});