feat: 实现撤销重做功能并优化剪贴板处理
添加撤销/重做历史记录功能,支持Ctrl+Z/Y/Shift+Z快捷键 优化剪贴板处理逻辑,支持GIF动画和多文件复制 更新帮助文档和图标资源,完善打包配置
This commit is contained in:
332
preload.js
332
preload.js
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user