mirror of
https://github.com/Hommy-master/capcut-mate.git
synced 2026-09-03 06:25:40 +08:00
增强编辑体验,添加上下文菜单功能。
This commit is contained in:
@@ -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);
|
||||
|
||||
51
desktop-client/nodeapi/editContextMenu.js
Normal file
51
desktop-client/nodeapi/editContextMenu.js
Normal file
@@ -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,
|
||||
};
|
||||
40
desktop-client/nodeapi/editContextMenu.test.js
Normal file
40
desktop-client/nodeapi/editContextMenu.test.js
Normal 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, []);
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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),
|
||||
});
|
||||
98
desktop-client/web/src/components/TextEditContextMenu.jsx
Normal file
98
desktop-client/web/src/components/TextEditContextMenu.jsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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实现
|
||||
|
||||
47
desktop-client/web/src/utils/draftUrlInput.js
Normal file
47
desktop-client/web/src/utils/draftUrlInput.js
Normal 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,
|
||||
};
|
||||
}
|
||||
52
desktop-client/web/src/utils/draftUrlInput.test.cjs
Normal file
52
desktop-client/web/src/utils/draftUrlInput.test.cjs
Normal 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');
|
||||
});
|
||||
Reference in New Issue
Block a user