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

View File

@@ -41,6 +41,121 @@ let state = {
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 = {
loopPairs: [[0, 119], [44, 56]], // can derive A, B, C & color coding from index
activeLoopPair: 0
@@ -205,6 +320,18 @@ document.addEventListener('keydown', evt => {
evt.preventDefault()
copySelected()
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') {
console.log('delete selected')
deleteSelected()
@@ -593,6 +720,11 @@ function addMediaWithPath(path, type = 'img', loadedState) {
//adjustFontSize2(mediaElement, path, loadedState.width)
} else
setTransformForElement(zIndex)
// 保存历史记录(仅在添加新元素时,不在恢复状态时)
if (isNewElement) {
saveStateToHistory()
}
}
function adjustFontSize2(mediaElement, text, maxWidth = window.innerWidth) {
temp = document.createElement('div')
@@ -631,17 +763,24 @@ document.addEventListener('drop', (event) => {
event.stopPropagation();
// Todo check if file is valid
for (const f of event.dataTransfer.files) {
// Using the path attribute to get absolute file path
console.log('File Path of dragged files: ', f.path, state)
if (f.path.endsWith('.mp4')) {
addMediaWithPath(f.path, 'video')
} else
addMediaWithPath(f.path)
} else {
// 发送文件路径到主进程,让主进程转换为 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() {
document.documentElement.addEventListener('mousedown', (event) => {
var target = event.target
@@ -739,7 +878,13 @@ contextBridge.exposeInMainWorld('myAPI', {
interact('.draggable')
.draggable({
listeners: { move: dragMoveListener },
listeners: {
move: dragMoveListener,
end: function(event) {
// 拖动结束时保存历史记录
saveStateToHistory()
}
},
inertia: false,
}).on('tap', function (event) {
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)
forceRedraw()
},
end(event) {
// 调整大小结束时保存历史记录
saveStateToHistory()
}
}],
modifiers: [
@@ -790,7 +939,13 @@ interact('.selectedItem').resizable({
],
inertia: true
}).draggable({
listeners: { move: dragMoveListener },
listeners: {
move: dragMoveListener,
end: function(event) {
// 拖动结束时保存历史记录
saveStateToHistory()
}
},
inertia: false
}).on('tap', function (event) {
@@ -837,20 +992,41 @@ function copySelected() {
function copyImageToClipboard(selectedElement) {
try {
const img = selectedElement.element
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth || img.width
canvas.height = img.naturalHeight || img.height
const imgSrc = img.src || selectedElement.path
const ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0)
console.log('Copying image - type:', selectedElement.type)
console.log('Image src:', imgSrc.substring(0, 100))
// 转换为 PNG 格式的 dataURL(更好的兼容性
const pngDataURL = canvas.toDataURL('image/png')
// 检查是否是 GIF通过 dataURL 或文件扩展名
const isGif = imgSrc.startsWith('data:image/gif') ||
imgSrc.toLowerCase().endsWith('.gif') ||
(selectedElement.path && selectedElement.path.toLowerCase().endsWith('.gif'))
console.log('Copying image - type:', selectedElement.type, 'size:', canvas.width, 'x', canvas.height)
ipcRenderer.send('copy-image', pngDataURL)
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')
canvas.width = img.naturalWidth || img.width
canvas.height = img.naturalHeight || img.height
const ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0)
// 转换为 PNG 格式的 dataURL
const pngDataURL = canvas.toDataURL('image/png')
console.log('Copying image converted to PNG, size:', canvas.width, 'x', canvas.height)
ipcRenderer.send('copy-image', pngDataURL)
}
} catch (err) {
console.error('Error converting image to canvas:', err)
console.error('Error copying image:', err)
// 降级方案:直接使用原始路径
const imgSrc = selectedElement.element.src || selectedElement.path
ipcRenderer.send('copy-image', imgSrc)
@@ -871,80 +1047,53 @@ async function copyMultipleImages(selectedElements) {
console.log('Copying', imageElements.length, 'images')
// 收集所有图片的 Blob
const imageBlobs = []
// 收集所有图片的原始数据(保持 GIF 动画)
const imageDataURLs = []
for (const el of imageElements) {
try {
const img = el.element
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth || img.width
canvas.height = img.naturalHeight || img.height
const imgSrc = img.src || el.path
const ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0)
// 检查是否是 GIF
const isGif = imgSrc.startsWith('data:image/gif') ||
imgSrc.toLowerCase().endsWith('.gif') ||
(el.path && el.path.toLowerCase().endsWith('.gif'))
// 转换为 Blob
const blob = await new Promise(resolve => {
canvas.toBlob(resolve, 'image/png')
})
if (blob) {
imageBlobs.push(blob)
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')
canvas.width = img.naturalWidth || img.width
canvas.height = img.naturalHeight || img.height
const ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0)
const pngDataURL = canvas.toDataURL('image/png')
console.log(' Converted to PNG for element')
imageDataURLs.push(pngDataURL)
}
} 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')
return
}
// 使用 Clipboard API 复制多张图片
try {
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)
} 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)
}
// 发送到主进程处理
console.log('Sending', imageDataURLs.length, 'images to main process')
ipcRenderer.send('copy-multiple-images', imageDataURLs)
} catch (err) {
console.error('Error copying multiple images:', err)
@@ -955,11 +1104,36 @@ async function copyMultipleImages(selectedElements) {
function deleteSelected() {
if (state.elements.length == 0) return
if (!state.elements[state.elements.length - 1].element.classList.contains('selectedItem')) return
state.elements[state.elements.length - 1].element.remove();
//delete state.elements[state.elements.length - 1].element
state.elements.pop();
// 收集所有选中的元素
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
})
clearAllSelected()
}