feat: 实现撤销重做功能并优化剪贴板处理

添加撤销/重做历史记录功能,支持Ctrl+Z/Y/Shift+Z快捷键
优化剪贴板处理逻辑,支持GIF动画和多文件复制
更新帮助文档和图标资源,完善打包配置
This commit is contained in:
2025-12-17 22:13:10 +08:00
parent 73aaab6d36
commit ffb387bd20
9 changed files with 1553 additions and 523 deletions

2
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,2 @@
{
}

View File

@@ -2,7 +2,7 @@
<html> <html>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>快捷键帮助 - AnimRef</title> <title>快捷键帮助 - RefVroid</title>
<style> <style>
* { * {
margin: 0; margin: 0;
@@ -99,6 +99,9 @@
<h3>文件操作</h3> <h3>文件操作</h3>
<div class="help-item"><kbd>Ctrl+C</kbd><span>复制选中元素</span></div> <div class="help-item"><kbd>Ctrl+C</kbd><span>复制选中元素</span></div>
<div class="help-item"><kbd>Ctrl+V</kbd><span>粘贴图片/文本</span></div> <div class="help-item"><kbd>Ctrl+V</kbd><span>粘贴图片/文本</span></div>
<div class="help-item"><kbd>Ctrl+Z</kbd><span>撤销</span></div>
<div class="help-item"><kbd>Ctrl+Y</kbd><span>重做</span></div>
<div class="help-item"><kbd>Ctrl+Shift+Z</kbd><span>重做(备选)</span></div>
<div class="help-item"><kbd>Ctrl+L</kbd><span>加载场景</span></div> <div class="help-item"><kbd>Ctrl+L</kbd><span>加载场景</span></div>
<div class="help-item"><kbd>Ctrl+S</kbd><span>保存场景</span></div> <div class="help-item"><kbd>Ctrl+S</kbd><span>保存场景</span></div>
<div class="help-item"><kbd>Ctrl+N</kbd><span>新建场景</span></div> <div class="help-item"><kbd>Ctrl+N</kbd><span>新建场景</span></div>

BIN
images/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 B

After

Width:  |  Height:  |  Size: 131 KiB

485
main.js
View File

@@ -185,39 +185,162 @@ app.whenReady().then(() => {
}); });
function handlePaste() { function handlePaste() {
console.log('handlePaste') console.log('========== handlePaste ==========')
let payload = {} let payload = {}
///strimg = JSON.stringify(img)
var formats = clipboard.availableFormats(); var formats = clipboard.availableFormats();
console.log('Available clipboard formats:', formats)
// 尝试读取所有可能的格式
console.log('Trying FileNameW...')
var rawFilePath = clipboard.read('FileNameW'); var rawFilePath = clipboard.read('FileNameW');
console.log('FileNameW result:', rawFilePath ? 'found' : 'not found')
// 尝试读取 HTML 格式(可能包含文件路径)
if (!rawFilePath && formats.indexOf('text/html') > -1) {
try {
const html = clipboard.readHTML();
console.log('HTML content:', html.substring(0, 200));
// 尝试从 HTML 中提取文件路径
const srcMatch = html.match(/src=["']file:\/\/\/([^"']+)["']/i);
if (srcMatch) {
rawFilePath = srcMatch[1].replace(/\//g, '\\');
console.log('Extracted file path from HTML:', rawFilePath);
}
} catch (err) {
console.log('Failed to read HTML:', err.message);
}
}
if (rawFilePath) { if (rawFilePath) {
var filePath = rawFilePath.replace(new RegExp(String.fromCharCode(0), 'g'), ''); var filePath = rawFilePath.replace(new RegExp(String.fromCharCode(0), 'g'), '');
payload.type = 'filePath'
payload.filePath = filePath // 检查是否是图片文件,如果是则转换为 dataURL
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'];
const isImage = imageExtensions.some(ext => filePath.toLowerCase().endsWith(ext));
if (isImage && fs.existsSync(filePath)) {
try {
// 读取文件并转换为 base64
const imageBuffer = fs.readFileSync(filePath);
const base64 = imageBuffer.toString('base64');
// 根据文件扩展名确定 MIME 类型
let mimeType = 'image/png';
if (filePath.toLowerCase().endsWith('.jpg') || filePath.toLowerCase().endsWith('.jpeg')) {
mimeType = 'image/jpeg';
} else if (filePath.toLowerCase().endsWith('.gif')) {
mimeType = 'image/gif';
} else if (filePath.toLowerCase().endsWith('.bmp')) {
mimeType = 'image/bmp';
} else if (filePath.toLowerCase().endsWith('.webp')) {
mimeType = 'image/webp';
}
payload.type = 'dataURL';
payload.dataURL = `data:${mimeType};base64,${base64}`;
console.log('Converted file to dataURL:', filePath, mimeType);
} catch (err) {
console.error('Error converting file to dataURL:', err);
payload.type = 'filePath';
payload.filePath = filePath;
}
} else {
payload.type = 'filePath';
payload.filePath = filePath;
}
console.log(filePath) console.log(filePath)
} else if (formats.indexOf('image/png') > -1) {
img = clipboard.readImage();
payload.type = 'dataURL'
payload.dataURL = img.toDataURL().replace('png', 'gif')
} else if (formats.indexOf('text/plain') > -1) {
//potential link
var potentialUrl = clipboard.readText()
console.log("potentialUrl", potentialUrl)
if (validateUrl(potentialUrl)) {
payload.type = 'filePath'
payload.filePath = potentialUrl
} else { } else {
// paste as text element // 尝试读取原始图片数据(支持 GIF 动画)
payload.type = 'text' let imageBuffer = null;
payload.text = clipboard.readText() let mimeType = null;
console.log('No file path found, trying to read image data...')
// 尝试所有可能的图片格式
const imageFormats = [
'image/gif',
'image/png',
'image/jpeg',
'image/jpg',
'image/bmp',
'image/webp',
'CF_DIB', // Windows DIB format
'CF_DIBV5' // Windows DIB v5 format
];
for (const format of imageFormats) {
if (formats.indexOf(format) > -1) {
try {
console.log(`Trying to read format: ${format}`)
const buffer = clipboard.readBuffer(format);
if (buffer && buffer.length > 0) {
imageBuffer = buffer;
// 检测实际的图片类型(通过文件头)
const header = buffer.slice(0, 6).toString('hex');
console.log(`Buffer header: ${header}, size: ${buffer.length}`)
if (header.startsWith('474946')) {
mimeType = 'image/gif';
console.log('Detected GIF by header');
} else if (header.startsWith('89504e')) {
mimeType = 'image/png';
console.log('Detected PNG by header');
} else if (header.startsWith('ffd8ff')) {
mimeType = 'image/jpeg';
console.log('Detected JPEG by header');
} else {
// 使用格式名称作为后备
mimeType = format.startsWith('image/') ? format : 'image/png';
console.log('Using format name as mime type:', mimeType);
}
break;
}
} catch (err) {
console.log(`Failed to read ${format}:`, err.message);
}
} }
} }
console.log(formats) if (imageBuffer && imageBuffer.length > 0) {
//console.log(payload) // 成功读取到原始图片数据
const base64 = imageBuffer.toString('base64');
payload.type = 'dataURL';
payload.dataURL = `data:${mimeType};base64,${base64}`;
console.log('✓ Successfully converted clipboard image to dataURL');
console.log(' Type:', mimeType);
console.log(' Size:', imageBuffer.length, 'bytes');
console.log(' Base64 length:', base64.length);
} else if (formats.indexOf('image/png') > -1 || formats.some(f => f.startsWith('image/'))) {
// 降级方案:使用 readImage会丢失 GIF 动画)
console.log('⚠ Using fallback readImage (will lose GIF animation)');
img = clipboard.readImage();
payload.type = 'dataURL';
payload.dataURL = img.toDataURL();
console.log(' Fallback image size:', img.getSize());
} else if (formats.indexOf('text/plain') > -1) {
// 文本内容
var potentialUrl = clipboard.readText();
console.log("Checking if text is URL:", potentialUrl);
if (validateUrl(potentialUrl)) {
payload.type = 'filePath';
payload.filePath = potentialUrl;
console.log('✓ Detected URL');
} else {
// paste as text element
payload.type = 'text';
payload.text = clipboard.readText();
console.log('✓ Pasting as text');
}
} else {
console.log('✗ No supported format found in clipboard');
}
}
console.log('========== Final payload type:', payload.type, '==========')
mainWin.webContents.send('clipboard', JSON.stringify(payload)) // send to web page mainWin.webContents.send('clipboard', JSON.stringify(payload)) // send to web page
} }
function addToRecent(filePath) { function addToRecent(filePath) {
@@ -464,6 +587,36 @@ app.whenReady().then(() => {
ipcMain.on('show-help', (event) => { ipcMain.on('show-help', (event) => {
createHelpWindow(mainWin) createHelpWindow(mainWin)
}) })
ipcMain.on('convert-file-to-dataurl', (event, filePath) => {
try {
console.log('Converting file to dataURL:', filePath)
// 读取文件
const imageBuffer = fs.readFileSync(filePath)
const base64 = imageBuffer.toString('base64')
// 根据文件扩展名确定 MIME 类型
let mimeType = 'image/png'
const lowerPath = filePath.toLowerCase()
if (lowerPath.endsWith('.jpg') || lowerPath.endsWith('.jpeg')) {
mimeType = 'image/jpeg'
} else if (lowerPath.endsWith('.gif')) {
mimeType = 'image/gif'
} else if (lowerPath.endsWith('.bmp')) {
mimeType = 'image/bmp'
} else if (lowerPath.endsWith('.webp')) {
mimeType = 'image/webp'
}
const dataURL = `data:${mimeType};base64,${base64}`
console.log('Converted to dataURL, type:', mimeType)
// 发送回渲染进程
event.sender.send('file-converted-to-dataurl', dataURL)
} catch (err) {
console.error('Error converting file to dataURL:', err)
}
})
ipcMain.on('copy-text', (event, text) => { ipcMain.on('copy-text', (event, text) => {
clipboard.writeText(text) clipboard.writeText(text)
console.log('Copied text to clipboard:', text) console.log('Copied text to clipboard:', text)
@@ -473,7 +626,40 @@ app.whenReady().then(() => {
console.log('Received', imageDataURLs.length, 'images to copy') console.log('Received', imageDataURLs.length, 'images to copy')
if (imageDataURLs.length === 1) { if (imageDataURLs.length === 1) {
// 只有一张图片,直接复制 // 只有一张图片,使用 copy-image 的逻辑(支持 GIF
console.log('Single image, using copy-image handler')
// 触发 copy-image 事件
mainWin.webContents.send('trigger-copy-single-image')
// 直接调用 copy-image 的逻辑
const imagePath = imageDataURLs[0]
// 检测是否是 GIF
if (imagePath.startsWith('data:')) {
const base64Data = imagePath.split(',')[1]
const buffer = Buffer.from(base64Data, 'base64')
const header = buffer.slice(0, 6).toString('hex')
if (header.startsWith('474946')) {
// 是 GIF保存为临时文件
console.log('Detected GIF in multiple selection')
const tempDir = path.join(app.getPath('temp'), 'RefVroid-clipboard')
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true })
}
const tempFilePath = path.join(tempDir, `animated_${Date.now()}.gif`)
fs.writeFileSync(tempFilePath, buffer)
clipboard.clear()
const filePathBuffer = Buffer.from(tempFilePath + '\0', 'ucs2')
clipboard.writeBuffer('FileNameW', filePathBuffer)
console.log('✓ Copied GIF from multiple selection')
return
}
}
// 不是 GIF使用普通方法
const img = nativeImage.createFromDataURL(imageDataURLs[0]) const img = nativeImage.createFromDataURL(imageDataURLs[0])
if (!img.isEmpty()) { if (!img.isEmpty()) {
clipboard.clear() clipboard.clear()
@@ -505,14 +691,37 @@ app.whenReady().then(() => {
// 保存所有图片为临时文件 // 保存所有图片为临时文件
const filePaths = [] const filePaths = []
const timestamp = Date.now()
imageDataURLs.forEach((dataURL, index) => { imageDataURLs.forEach((dataURL, index) => {
try { try {
// 检测图片类型
const base64Data = dataURL.split(',')[1]
const buffer = Buffer.from(base64Data, 'base64')
const header = buffer.slice(0, 6).toString('hex')
let extension = 'png'
let fileBuffer = null
if (header.startsWith('474946')) {
// GIF - 保存原始数据
extension = 'gif'
fileBuffer = buffer
console.log(' Image', index + 1, 'is GIF, preserving animation')
} else {
// 其他格式 - 转换为 PNG
const img = nativeImage.createFromDataURL(dataURL) const img = nativeImage.createFromDataURL(dataURL)
if (!img.isEmpty()) { if (!img.isEmpty()) {
const filePath = path.join(tempDir, `image_${Date.now()}_${index + 1}.png`) fileBuffer = img.toPNG()
fs.writeFileSync(filePath, img.toPNG()) console.log(' Image', index + 1, 'converted to PNG')
}
}
if (fileBuffer) {
const filePath = path.join(tempDir, `image_${timestamp}_${index + 1}.${extension}`)
fs.writeFileSync(filePath, fileBuffer)
filePaths.push(filePath) filePaths.push(filePath)
console.log('Saved temp image:', filePath) console.log(' Saved:', filePath)
} }
} catch (err) { } catch (err) {
console.error('Error saving image', index, ':', err) console.error('Error saving image', index, ':', err)
@@ -524,27 +733,58 @@ app.whenReady().then(() => {
return return
} }
console.log('Copying', filePaths.length, 'files:', filePaths) console.log('Copying', filePaths.length, 'files')
// 清空剪贴板 // 清空剪贴板
clipboard.clear() clipboard.clear()
// 方法1: 使用 HTML 格式QQ 支持 // 方法1: 使用 Windows 文件格式FileNameW
// 这是 Windows 文件复制的标准格式,大多数应用都支持
try {
// 构建文件列表(每个路径以 null 结尾,最后再加一个 null
let fileListBuffer = Buffer.alloc(0)
filePaths.forEach(fp => {
const pathBuffer = Buffer.from(fp + '\0', 'ucs2')
fileListBuffer = Buffer.concat([fileListBuffer, pathBuffer])
})
// 添加最后的 null 终止符
fileListBuffer = Buffer.concat([fileListBuffer, Buffer.from('\0', 'ucs2')])
clipboard.writeBuffer('FileNameW', fileListBuffer)
console.log(' Wrote FileNameW format')
} catch (err) {
console.error('Failed to write FileNameW:', err)
}
// 方法2: 使用 HTML 格式(某些应用支持)
try {
const htmlImages = filePaths.map(fp => const htmlImages = filePaths.map(fp =>
`<img src="file:///${fp.replace(/\\/g, '/')}" />` `<img src="file:///${fp.replace(/\\/g, '/')}" />`
).join('') ).join('')
const html = `<html><body>${htmlImages}</body></html>` const html = `<html><body>${htmlImages}</body></html>`
// 方法2: 使用文件路径格式 clipboard.writeHTML(html)
const fileList = filePaths.join('\n') console.log(' Wrote HTML format')
} catch (err) {
console.error('Failed to write HTML:', err)
}
// 同时写入多种格式 // 方法3: 如果只有一张图片,同时写入图片数据(提高兼容性)
clipboard.write({ if (filePaths.length === 1) {
text: fileList, try {
html: html const buffer = fs.readFileSync(filePaths[0])
}) const img = nativeImage.createFromBuffer(buffer)
if (!img.isEmpty()) {
clipboard.writeImage(img)
console.log(' Also wrote image data for single image')
}
} catch (err) {
console.error('Failed to write image data:', err)
}
}
console.log('Copied', filePaths.length, 'images in multiple formats') console.log('Copied', filePaths.length, 'file(s) in multiple formats')
console.log(' Note: Some apps (like Feishu) may show file paths instead of images')
} catch (err) { } catch (err) {
console.error('Error copying multiple images:', err) console.error('Error copying multiple images:', err)
@@ -552,20 +792,121 @@ app.whenReady().then(() => {
}) })
ipcMain.on('copy-image', (event, imagePath) => { ipcMain.on('copy-image', (event, imagePath) => {
try { try {
let img = null console.log('========== copy-image ==========')
console.log('Image path/dataURL:', imagePath.substring(0, 100))
// 如果是 dataURL直接写入 // 如果是 dataURL检测真实的图片类型
if (imagePath.startsWith('data:')) { if (imagePath.startsWith('data:')) {
img = nativeImage.createFromDataURL(imagePath) try {
console.log('Processing dataURL image, size:', img.getSize()) // 从 dataURL 提取 base64 数据
const base64Data = imagePath.split(',')[1]
const buffer = Buffer.from(base64Data, 'base64')
// 确保图片不为空 // 检测文件头来确定真实类型
if (img.isEmpty()) { const header = buffer.slice(0, 6).toString('hex')
console.error('Created image from dataURL is empty') console.log('Image buffer header:', header, 'size:', buffer.length, 'bytes')
clipboard.writeText('图片复制失败')
let actualMimeType = null
let isGif = false
if (header.startsWith('474946')) {
// GIF 文件头: 47 49 46 = "GIF"
actualMimeType = 'image/gif'
isGif = true
console.log('✓ Detected GIF by file header')
} else if (header.startsWith('89504e')) {
// PNG 文件头: 89 50 4E 47
actualMimeType = 'image/png'
console.log('✓ Detected PNG by file header')
} else if (header.startsWith('ffd8ff')) {
// JPEG 文件头: FF D8 FF
actualMimeType = 'image/jpeg'
console.log('✓ Detected JPEG by file header')
} else {
console.log('⚠ Unknown image format, using declared MIME type')
actualMimeType = imagePath.split(';')[0].split(':')[1]
}
if (isGif) {
// GIF - 保存为临时文件并复制文件
console.log('Copying GIF with animation...')
try {
// 创建临时文件
const tempDir = path.join(app.getPath('temp'), 'RefVroid-clipboard')
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true })
}
// 清理旧的临时文件
try {
const files = fs.readdirSync(tempDir)
const now = Date.now()
files.forEach(file => {
const filePath = path.join(tempDir, file)
const stats = fs.statSync(filePath)
// 删除超过1小时的文件
if (now - stats.mtimeMs > 3600000) {
fs.unlinkSync(filePath)
}
})
} catch (err) {
// 忽略清理错误
}
const tempFilePath = path.join(tempDir, `animated_${Date.now()}.gif`)
fs.writeFileSync(tempFilePath, buffer)
console.log(' Created temp GIF file:', tempFilePath)
// 使用 Electron 的 clipboard.writeBuffer 写入文件路径
// 这是 Windows 文件复制的标准格式
clipboard.clear()
// 写入文件路径CF_HDROP 格式)
const filePathBuffer = Buffer.from(tempFilePath + '\0', 'ucs2')
clipboard.writeBuffer('FileNameW', filePathBuffer)
console.log('✓ Successfully copied GIF file to clipboard')
console.log(' Temp file:', tempFilePath)
console.log(' You can now paste it to QQ or other apps')
return
} catch (err) {
console.error('Failed to copy GIF with temp file:', err)
// 降级方案:只写入静态图片
clipboard.clear()
const img = nativeImage.createFromBuffer(buffer)
if (!img.isEmpty()) {
clipboard.writeImage(img)
console.log('⚠ Copied as static image (animation lost)')
return return
} }
} else if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) { }
} else {
// 其他格式 - 使用 nativeImage
console.log('Copying', actualMimeType, 'image...')
const correctedDataURL = `data:${actualMimeType};base64,${base64Data}`
const img = nativeImage.createFromDataURL(correctedDataURL)
if (!img.isEmpty()) {
clipboard.clear()
clipboard.writeImage(img)
console.log('✓ Successfully copied', actualMimeType, 'to clipboard')
console.log(' Image size:', img.getSize())
return
} else {
console.error('✗ Created image is empty')
}
}
} catch (err) {
console.error('Failed to process dataURL:', err)
clipboard.writeText('图片复制失败: ' + err.message)
return
}
}
// 处理网络图片
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
// 网络图片暂时只能复制 URL // 网络图片暂时只能复制 URL
clipboard.writeText(imagePath) clipboard.writeText(imagePath)
console.log('Copied image URL to clipboard:', imagePath) console.log('Copied image URL to clipboard:', imagePath)
@@ -591,23 +932,57 @@ app.whenReady().then(() => {
return return
} }
img = nativeImage.createFromPath(filePath) // 检查是否是 GIF 文件
console.log('Image loaded, size:', img.getSize()) const isGifFile = filePath.toLowerCase().endsWith('.gif')
}
if (isGifFile) {
// GIF 文件 - 直接复制文件路径
try {
console.log('Detected GIF file, copying file path')
const buffer = fs.readFileSync(filePath)
console.log('GIF buffer size:', buffer.length, 'bytes')
if (img && !img.isEmpty()) {
// 清空剪贴板
clipboard.clear() clipboard.clear()
// 写入图片到剪贴板 // 写入文件路径Windows 格式)
clipboard.writeImage(img) clipboard.write({
text: filePath,
bookmark: filePath
})
console.log('Successfully copied image to clipboard') // 同时写入 GIF buffer
console.log('Image size:', img.getSize()) clipboard.writeBuffer('image/gif', buffer)
console.log('Image aspect ratio:', img.getAspectRatio())
// 写入静态图片作为降级
const img = nativeImage.createFromBuffer(buffer)
if (!img.isEmpty()) {
clipboard.writeImage(img)
console.log(' Also wrote static image as fallback')
}
console.log('✓ Successfully copied GIF file')
return
} catch (err) {
console.error('Failed to copy GIF file:', err)
// 降级到普通方法
}
}
// 其他格式的文件
const img = nativeImage.createFromPath(filePath)
console.log('Image loaded, size:', img.getSize())
if (!img.isEmpty()) {
clipboard.clear()
clipboard.writeImage(img)
console.log('✓ Successfully copied image to clipboard')
console.log(' Image size:', img.getSize())
return
} else { } else {
console.log('Image is empty, copying error message') console.error('Image is empty')
clipboard.writeText('图片为空,无法复制') clipboard.writeText('图片为空')
return
}
} }
} catch (err) { } catch (err) {
console.error('Error copying image:', err) console.error('Error copying image:', err)

1202
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,13 +14,14 @@
"devDependencies": { "devDependencies": {
"@electron-forge/cli": "^7.4.0", "@electron-forge/cli": "^7.4.0",
"@electron-forge/maker-deb": "^7.4.0", "@electron-forge/maker-deb": "^7.4.0",
"@electron-forge/maker-dmg": "^7.4.0",
"@electron-forge/maker-flatpak": "^7.4.0",
"@electron-forge/maker-rpm": "^7.4.0", "@electron-forge/maker-rpm": "^7.4.0",
"@electron-forge/maker-squirrel": "^7.4.0", "@electron-forge/maker-squirrel": "^7.4.0",
"@electron-forge/maker-wix": "^7.10.2",
"@electron-forge/maker-zip": "^7.4.0", "@electron-forge/maker-zip": "^7.4.0",
"@electron-forge/maker-flatpak": "^7.4.0", "@electron/rebuild": "^3.6.0",
"@electron-forge/maker-dmg": "^7.4.0", "electron": "30.0.1"
"electron": "30.0.1",
"@electron/rebuild": "^3.6.0"
}, },
"dependencies": { "dependencies": {
"dombo": "^3.2.0", "dombo": "^3.2.0",
@@ -31,7 +32,9 @@
}, },
"config": { "config": {
"forge": { "forge": {
"packagerConfig": {}, "packagerConfig": {
"icon": "images/icon"
},
"make_targets": { "make_targets": {
"win32": [ "win32": [
"squirrel" "squirrel"
@@ -49,7 +52,8 @@
{ {
"name": "@electron-forge/maker-squirrel", "name": "@electron-forge/maker-squirrel",
"config": { "config": {
"name": "refvroid" "name": "refvroid",
"setupIcon": "images/icon.ico"
} }
}, },
{ {

View File

@@ -41,6 +41,121 @@ let state = {
isLocked: false isLocked: false
} }
// 撤销/重做历史记录
let history = {
undoStack: [],
redoStack: [],
maxSize: 50
}
// 保存当前状态到历史记录
function saveStateToHistory() {
// 创建状态快照(深拷贝)
const snapshot = {
elements: state.elements.map(el => ({
path: el.path,
type: el.type,
x: parseFloat(el.element.dataset.x) || 0,
y: parseFloat(el.element.dataset.y) || 0,
width: el.width,
height: el.height,
loopPairs: el.loopPairs,
activeLoopPair: el.activeLoopPair
})),
currentScale: state.currentScale,
translate: { ...state.translate }
}
history.undoStack.push(snapshot)
// 限制历史记录大小
if (history.undoStack.length > history.maxSize) {
history.undoStack.shift()
}
// 清空重做栈
history.redoStack = []
console.log('State saved to history, undo stack size:', history.undoStack.length)
}
// 撤销
function undo() {
if (history.undoStack.length === 0) {
console.log('Nothing to undo')
return
}
// 保存当前状态到重做栈
const currentSnapshot = {
elements: state.elements.map(el => ({
path: el.path,
type: el.type,
x: parseFloat(el.element.dataset.x) || 0,
y: parseFloat(el.element.dataset.y) || 0,
width: el.width,
height: el.height,
loopPairs: el.loopPairs,
activeLoopPair: el.activeLoopPair
})),
currentScale: state.currentScale,
translate: { ...state.translate }
}
history.redoStack.push(currentSnapshot)
// 恢复上一个状态
const snapshot = history.undoStack.pop()
restoreStateFromSnapshot(snapshot)
console.log('Undo performed, undo stack:', history.undoStack.length, 'redo stack:', history.redoStack.length)
}
// 重做
function redo() {
if (history.redoStack.length === 0) {
console.log('Nothing to redo')
return
}
// 保存当前状态到撤销栈
const currentSnapshot = {
elements: state.elements.map(el => ({
path: el.path,
type: el.type,
x: parseFloat(el.element.dataset.x) || 0,
y: parseFloat(el.element.dataset.y) || 0,
width: el.width,
height: el.height,
loopPairs: el.loopPairs,
activeLoopPair: el.activeLoopPair
})),
currentScale: state.currentScale,
translate: { ...state.translate }
}
history.undoStack.push(currentSnapshot)
// 恢复重做状态
const snapshot = history.redoStack.pop()
restoreStateFromSnapshot(snapshot)
console.log('Redo performed, undo stack:', history.undoStack.length, 'redo stack:', history.redoStack.length)
}
// 从快照恢复状态
function restoreStateFromSnapshot(snapshot) {
// 清除当前所有元素
state.elements.forEach(el => el.element.remove())
state.elements = []
// 恢复缩放和平移
updateScaleAndTranslate(snapshot.currentScale, snapshot.translate)
// 恢复所有元素
snapshot.elements.forEach(elementData => {
addMediaWithPath(elementData.path, elementData.type, elementData)
})
}
let videoExample = { let videoExample = {
loopPairs: [[0, 119], [44, 56]], // can derive A, B, C & color coding from index loopPairs: [[0, 119], [44, 56]], // can derive A, B, C & color coding from index
activeLoopPair: 0 activeLoopPair: 0
@@ -205,6 +320,18 @@ document.addEventListener('keydown', evt => {
evt.preventDefault() evt.preventDefault()
copySelected() copySelected()
console.log('Ctrl+C was pressed') console.log('Ctrl+C was pressed')
} else if (evt.key === 'z' && evt.ctrlKey && !evt.shiftKey) {
evt.preventDefault()
undo()
console.log('Ctrl+Z was pressed (Undo)')
} else if (evt.key === 'y' && evt.ctrlKey) {
evt.preventDefault()
redo()
console.log('Ctrl+Y was pressed (Redo)')
} else if (evt.key === 'z' && evt.ctrlKey && evt.shiftKey) {
evt.preventDefault()
redo()
console.log('Ctrl+Shift+Z was pressed (Redo)')
} else if (evt.key === 'Delete') { } else if (evt.key === 'Delete') {
console.log('delete selected') console.log('delete selected')
deleteSelected() deleteSelected()
@@ -593,6 +720,11 @@ function addMediaWithPath(path, type = 'img', loadedState) {
//adjustFontSize2(mediaElement, path, loadedState.width) //adjustFontSize2(mediaElement, path, loadedState.width)
} else } else
setTransformForElement(zIndex) setTransformForElement(zIndex)
// 保存历史记录(仅在添加新元素时,不在恢复状态时)
if (isNewElement) {
saveStateToHistory()
}
} }
function adjustFontSize2(mediaElement, text, maxWidth = window.innerWidth) { function adjustFontSize2(mediaElement, text, maxWidth = window.innerWidth) {
temp = document.createElement('div') temp = document.createElement('div')
@@ -631,16 +763,23 @@ document.addEventListener('drop', (event) => {
event.stopPropagation(); event.stopPropagation();
// Todo check if file is valid // Todo check if file is valid
for (const f of event.dataTransfer.files) { for (const f of event.dataTransfer.files) {
// Using the path attribute to get absolute file path // Using the path attribute to get absolute file path
console.log('File Path of dragged files: ', f.path, state) console.log('File Path of dragged files: ', f.path, state)
if (f.path.endsWith('.mp4')) { if (f.path.endsWith('.mp4')) {
addMediaWithPath(f.path, 'video') addMediaWithPath(f.path, 'video')
} else } else {
addMediaWithPath(f.path) // 发送文件路径到主进程,让主进程转换为 base64
ipcRenderer.send('convert-file-to-dataurl', f.path)
} }
}
});
// 接收主进程转换后的 dataURL
ipcRenderer.on('file-converted-to-dataurl', (event, dataURL) => {
console.log('Received dataURL from main process')
addMediaWithPath(dataURL, 'dataURL')
}); });
function init() { function init() {
document.documentElement.addEventListener('mousedown', (event) => { document.documentElement.addEventListener('mousedown', (event) => {
@@ -739,7 +878,13 @@ contextBridge.exposeInMainWorld('myAPI', {
interact('.draggable') interact('.draggable')
.draggable({ .draggable({
listeners: { move: dragMoveListener }, listeners: {
move: dragMoveListener,
end: function(event) {
// 拖动结束时保存历史记录
saveStateToHistory()
}
},
inertia: false, inertia: false,
}).on('tap', function (event) { }).on('tap', function (event) {
var target = event.target var target = event.target
@@ -775,6 +920,10 @@ interact('.selectedItem').resizable({
} }
setTransformForElement(target.dataset.zIndex, event.deltaRect.left, event.deltaRect.top, event.rect.width, event.rect.height) setTransformForElement(target.dataset.zIndex, event.deltaRect.left, event.deltaRect.top, event.rect.width, event.rect.height)
forceRedraw() forceRedraw()
},
end(event) {
// 调整大小结束时保存历史记录
saveStateToHistory()
} }
}], }],
modifiers: [ modifiers: [
@@ -790,7 +939,13 @@ interact('.selectedItem').resizable({
], ],
inertia: true inertia: true
}).draggable({ }).draggable({
listeners: { move: dragMoveListener }, listeners: {
move: dragMoveListener,
end: function(event) {
// 拖动结束时保存历史记录
saveStateToHistory()
}
},
inertia: false inertia: false
}).on('tap', function (event) { }).on('tap', function (event) {
@@ -837,6 +992,26 @@ function copySelected() {
function copyImageToClipboard(selectedElement) { function copyImageToClipboard(selectedElement) {
try { try {
const img = selectedElement.element const img = selectedElement.element
const imgSrc = img.src || selectedElement.path
console.log('Copying image - type:', selectedElement.type)
console.log('Image src:', imgSrc.substring(0, 100))
// 检查是否是 GIF通过 dataURL 或文件扩展名)
const isGif = imgSrc.startsWith('data:image/gif') ||
imgSrc.toLowerCase().endsWith('.gif') ||
(selectedElement.path && selectedElement.path.toLowerCase().endsWith('.gif'))
if (isGif && imgSrc.startsWith('data:')) {
// 如果是 GIF 的 dataURL直接使用原始数据
console.log('Copying GIF with original data (preserving animation)')
ipcRenderer.send('copy-image', imgSrc)
} else if (selectedElement.type === 'dataURL' && imgSrc.startsWith('data:')) {
// 如果是其他格式的 dataURL也直接使用
console.log('Copying image with original dataURL')
ipcRenderer.send('copy-image', imgSrc)
} else {
// 对于非 dataURL 的图片,使用 Canvas 转换
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth || img.width canvas.width = img.naturalWidth || img.width
canvas.height = img.naturalHeight || img.height canvas.height = img.naturalHeight || img.height
@@ -844,13 +1019,14 @@ function copyImageToClipboard(selectedElement) {
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0) ctx.drawImage(img, 0, 0)
// 转换为 PNG 格式的 dataURL(更好的兼容性) // 转换为 PNG 格式的 dataURL
const pngDataURL = canvas.toDataURL('image/png') const pngDataURL = canvas.toDataURL('image/png')
console.log('Copying image - type:', selectedElement.type, 'size:', canvas.width, 'x', canvas.height) console.log('Copying image converted to PNG, size:', canvas.width, 'x', canvas.height)
ipcRenderer.send('copy-image', pngDataURL) ipcRenderer.send('copy-image', pngDataURL)
}
} catch (err) { } catch (err) {
console.error('Error converting image to canvas:', err) console.error('Error copying image:', err)
// 降级方案:直接使用原始路径 // 降级方案:直接使用原始路径
const imgSrc = selectedElement.element.src || selectedElement.path const imgSrc = selectedElement.element.src || selectedElement.path
ipcRenderer.send('copy-image', imgSrc) ipcRenderer.send('copy-image', imgSrc)
@@ -871,12 +1047,29 @@ async function copyMultipleImages(selectedElements) {
console.log('Copying', imageElements.length, 'images') console.log('Copying', imageElements.length, 'images')
// 收集所有图片的 Blob // 收集所有图片的原始数据(保持 GIF 动画)
const imageBlobs = [] const imageDataURLs = []
for (const el of imageElements) { for (const el of imageElements) {
try { try {
const img = el.element const img = el.element
const imgSrc = img.src || el.path
// 检查是否是 GIF
const isGif = imgSrc.startsWith('data:image/gif') ||
imgSrc.toLowerCase().endsWith('.gif') ||
(el.path && el.path.toLowerCase().endsWith('.gif'))
if (isGif && imgSrc.startsWith('data:')) {
// GIF dataURL - 直接使用原始数据
console.log(' Preserving GIF animation for element')
imageDataURLs.push(imgSrc)
} else if (el.type === 'dataURL' && imgSrc.startsWith('data:')) {
// 其他 dataURL - 直接使用
console.log(' Using original dataURL for element')
imageDataURLs.push(imgSrc)
} else {
// 其他格式 - 转换为 PNG
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth || img.width canvas.width = img.naturalWidth || img.width
canvas.height = img.naturalHeight || img.height canvas.height = img.naturalHeight || img.height
@@ -884,68 +1077,24 @@ async function copyMultipleImages(selectedElements) {
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0) ctx.drawImage(img, 0, 0)
// 转换为 Blob const pngDataURL = canvas.toDataURL('image/png')
const blob = await new Promise(resolve => { console.log(' Converted to PNG for element')
canvas.toBlob(resolve, 'image/png') imageDataURLs.push(pngDataURL)
})
if (blob) {
imageBlobs.push(blob)
} }
} catch (err) { } catch (err) {
console.error('Error converting image:', err) console.error('Error processing image:', err)
} }
} }
if (imageBlobs.length === 0) { if (imageDataURLs.length === 0) {
console.log('No valid images to copy') console.log('No valid images to copy')
return return
} }
// 使用 Clipboard API 复制多张图片 // 发送到主进程处理
try { console.log('Sending', imageDataURLs.length, 'images to main process')
const clipboardItems = imageBlobs.map(blob =>
new ClipboardItem({ 'image/png': blob })
)
// 注意:标准 Clipboard API 一次只能写入一个 ClipboardItem
// 所以我们需要将多张图片合并或者只复制第一张
// 这里我们尝试写入所有图片,但浏览器可能只保留最后一张
for (const item of clipboardItems) {
await navigator.clipboard.write([item])
}
console.log('Copied', imageBlobs.length, 'images using Clipboard API')
// 降级方案:发送到主进程
const imageDataURLs = []
for (const el of imageElements) {
const img = el.element
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth || img.width
canvas.height = img.naturalHeight || img.height
const ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0)
imageDataURLs.push(canvas.toDataURL('image/png'))
}
ipcRenderer.send('copy-multiple-images', imageDataURLs) ipcRenderer.send('copy-multiple-images', imageDataURLs)
} catch (err) {
console.error('Clipboard API failed, using fallback:', err)
// 降级方案:发送到主进程
const imageDataURLs = []
for (const el of imageElements) {
const img = el.element
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth || img.width
canvas.height = img.naturalHeight || img.height
const ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0)
imageDataURLs.push(canvas.toDataURL('image/png'))
}
ipcRenderer.send('copy-multiple-images', imageDataURLs)
}
} catch (err) { } catch (err) {
console.error('Error copying multiple images:', err) console.error('Error copying multiple images:', err)
ipcRenderer.send('copy-text', '复制多张图片失败: ' + err.message) ipcRenderer.send('copy-text', '复制多张图片失败: ' + err.message)
@@ -955,11 +1104,36 @@ async function copyMultipleImages(selectedElements) {
function deleteSelected() { function deleteSelected() {
if (state.elements.length == 0) return if (state.elements.length == 0) return
if (!state.elements[state.elements.length - 1].element.classList.contains('selectedItem')) return // 收集所有选中的元素
const selectedElements = state.elements.filter(el =>
el.element.classList.contains('selectedItem')
)
if (selectedElements.length === 0) return
console.log('Deleting', selectedElements.length, 'selected element(s)')
// 保存历史记录
saveStateToHistory()
// 删除所有选中的元素
selectedElements.forEach(elementObj => {
// 从 DOM 中移除
elementObj.element.remove()
// 从 state.elements 数组中移除
const index = state.elements.indexOf(elementObj)
if (index > -1) {
state.elements.splice(index, 1)
}
})
// 重新设置所有剩余元素的 zIndex
state.elements.forEach((elementObj, index) => {
elementObj.element.style.zIndex = index
elementObj.element.dataset.zIndex = index
})
state.elements[state.elements.length - 1].element.remove();
//delete state.elements[state.elements.length - 1].element
state.elements.pop();
clearAllSelected() clearAllSelected()
} }