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 ( + <> +
+ + + ); +} + +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'); +});