增强编辑体验,添加上下文菜单功能。

This commit is contained in:
Hommy
2026-08-24 10:17:54 +08:00
parent 7cad08774b
commit b2b4d74deb
11 changed files with 578 additions and 25 deletions

View File

@@ -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);

View File

@@ -0,0 +1,51 @@
/**
* 可编辑区域 / 选中文本的右键菜单。
* 使用 Electron roleWindows 为 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,
};

View File

@@ -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, []);
});

View File

@@ -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 };

View File

@@ -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),
});

View File

@@ -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 (
<>
<div className="text-edit-context-backdrop" onMouseDown={onClose} />
<ul
className="text-edit-context-menu"
style={{ left: position.left, top: position.top }}
role="menu"
onMouseDown={(event) => event.preventDefault()}
>
{items.map((item) =>
item.separator ? (
<li key={item.key} className="text-edit-context-separator" role="separator" />
) : (
<li key={item.key} role="none">
<button
type="button"
role="menuitem"
className="text-edit-context-item"
disabled={!item.enabled}
onClick={() => {
if (!item.enabled) {
return;
}
item.action();
onClose();
}}
>
<span>{item.label}</span>
{item.shortcut ? <span className="text-edit-context-shortcut">{item.shortcut}</span> : null}
</button>
</li>
)
)}
</ul>
</>
);
}
export default TextEditContextMenu;

View File

@@ -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 (
<section className="module">
<div className="textarea-container">
<textarea
ref={textareaRef}
className="auto-resize-textarea"
placeholder={`请输入草稿地址,多个使用回车换行分隔例如:
草稿地址1
草稿地址2
草稿地址3`}
placeholder={`请输入草稿地址,每行一个。支持右键菜单与 ${mod}+C / ${mod}+V。
例如:
https://example.com/get_draft?draft_id=草稿1
https://example.com/get_draft?draft_id=草稿2`}
value={value}
onChange={handleInput}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onContextMenu={handleContextMenu}
style={{ height: textareaHeight }}
></textarea>
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
wrap="soft"
inputMode="url"
/>
<p className="textarea-hint">
每行一个地址右键可复制粘贴全选
{isMacPlatform() ? 'macOS 使用 ⌘Ctrl+点击也可打开菜单' : 'Windows 使用 Ctrl'}
</p>
</div>
{useAppContextMenu && menu ? (
<TextEditContextMenu
x={menu.x}
y={menu.y}
canCut={menu.hasSelection}
canCopy={menu.hasSelection}
canPaste
canSelectAll={menu.hasValue}
canClear={menu.hasValue}
onCut={handleCut}
onCopy={handleCopy}
onPaste={handleMenuPaste}
onSelectAll={handleSelectAll}
onClear={handleClear}
onClose={closeMenu}
/>
) : null}
</section>
);
}

View File

@@ -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;

View File

@@ -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实现

View File

@@ -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,
};
}

View File

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