feat: 初始化RefVroid项目,添加核心功能与资源文件

- 添加主窗口、帮助窗口和系统托盘功能
- 实现图片、视频和文本的拖放与粘贴功能
- 添加场景保存与加载功能
- 包含基本UI样式和快捷键帮助文档
- 添加.gitattributes和.npmrc配置文件
- 包含LICENSE许可文件和图标资源
This commit is contained in:
2025-12-17 21:00:07 +08:00
commit 73aaab6d36
15 changed files with 15016 additions and 0 deletions

664
main.js Normal file
View File

@@ -0,0 +1,664 @@
const {
BrowserWindow,
Menu,
MenuItem,
ipcMain,
app,
clipboard,
screen,
globalShortcut,
dialog,
Tray,
nativeImage
} = require('electron')
const path = require('path')
const fs = require('fs');
const Store = require('electron-store');
const store = new Store();
//store.clear()
let width = 400;
let height = 300;
let isLocked = false;
let tray = null;
let helpWindow = null;
function createWindow() {
const win = new BrowserWindow({
backgroundColor: "#202020",
width: width,
height: height,
frame: false,
transparent: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: true,
}
})
win.setAlwaysOnTop(true);
win.loadFile('index.html')
return win;
}
function createHelpWindow(parentWindow) {
// 如果帮助窗口已存在,则聚焦它
if (helpWindow && !helpWindow.isDestroyed()) {
helpWindow.focus()
return
}
helpWindow = new BrowserWindow({
width: 700,
height: 600,
backgroundColor: "#2a2a2a",
title: "快捷键帮助 - RefVroid",
autoHideMenuBar: true,
parent: parentWindow,
modal: true,
alwaysOnTop: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true
}
})
helpWindow.loadFile('help.html')
// 窗口关闭时清理引用
helpWindow.on('closed', () => {
helpWindow = null
})
}
app.commandLine.appendSwitch('disable-site-isolation-trials');
const contextMenu = new Menu()
const recentSubmenu = new Menu()
const windowSubmenu = new Menu()
const opacitySubmenu = new Menu()
app.whenReady().then(() => {
const mainWin = createWindow()
// 创建系统托盘图标
let trayIcon
const iconPath = path.join(__dirname, 'images', 'tray-icon.png')
if (fs.existsSync(iconPath)) {
trayIcon = nativeImage.createFromPath(iconPath)
} else {
// 使用 base64 图标
const iconData = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAdgAAAHYBTnsmCAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAA5SURBVDiN7dMxDQAgEMCwA/+fGQZgABZgARZgARZgARZgARZgARZgARZgARZgARZgARZgARbwsgMvpQYH8VLsJwAAAABJRU5ErkJggg=='
trayIcon = nativeImage.createFromDataURL(iconData)
}
if (trayIcon.isEmpty()) {
trayIcon = nativeImage.createFromBuffer(
Buffer.from('iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAdgAAAHYBTnsmCAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAA5SURBVDiN7dMxDQAgEMCwA/+fGQZgABZgARZgARZgARZgARZgARZgARZgARZgARZgARZgARbwsgMvpQYH8VLsJwAAAABJRU5ErkJggg==', 'base64')
)
}
tray = new Tray(trayIcon)
// 创建更新托盘菜单的函数
function updateTrayMenu() {
const menuTemplate = [
{
label: 'RefVroid',
enabled: false
},
{
type: 'separator'
}
]
// 只在锁定时显示解锁选项
if (isLocked) {
menuTemplate.push({
label: '解锁窗口',
click: () => {
isLocked = false
mainWin.setIgnoreMouseEvents(false)
mainWin.webContents.send('unlock-window')
updateTrayMenu()
}
})
}
menuTemplate.push(
{
label: '显示窗口',
click: () => {
mainWin.show()
mainWin.focus()
}
},
{
type: 'separator'
},
{
label: '退出',
click: () => {
app.quit()
}
}
)
const trayMenu = Menu.buildFromTemplate(menuTemplate)
tray.setContextMenu(trayMenu)
}
updateTrayMenu()
tray.setToolTip('RefVroid - 动画参考工具')
tray.on('click', () => {
mainWin.isVisible() ? mainWin.hide() : mainWin.show()
})
mainWin.on('ready-to-show', () => {
console.log('Window ready to be presented');
mainWin.show();
});
mainWin.webContents.on('did-finish-load', () => {
console.log('Page fully loaded');
//setTimeout(loadMostRecent, 1000)
loadMostRecent()
});
contextMenu.append(new MenuItem({
label: '粘贴',
accelerator: process.platform === 'darwin' ? 'Cmd+V' : 'Ctrl+V',
click: (menuItem, browserWindow, event) => {
handlePaste();
}
}));
contextMenu.append(new MenuItem({ type: 'separator' }))
contextMenu.append(new MenuItem({
label: '总是置顶', type: 'checkbox', checked: true,
click: (menuItem, browserWindow, event) => {
mainWin.setAlwaysOnTop(menuItem.checked);
}
}));
// 只注册开发者工具的全局快捷键
globalShortcut.register('Control+Shift+I', () => {
mainWin.webContents.openDevTools()
});
function handlePaste() {
console.log('handlePaste')
let payload = {}
///strimg = JSON.stringify(img)
var formats = clipboard.availableFormats();
var rawFilePath = clipboard.read('FileNameW');
if (rawFilePath) {
var filePath = rawFilePath.replace(new RegExp(String.fromCharCode(0), 'g'), '');
payload.type = 'filePath'
payload.filePath = 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 {
// paste as text element
payload.type = 'text'
payload.text = clipboard.readText()
}
}
console.log(formats)
//console.log(payload)
mainWin.webContents.send('clipboard', JSON.stringify(payload)) // send to web page
}
function addToRecent(filePath) {
var recent = JSON.parse(store.get('recent') || "[]")
console.log("addToRecent", store.get('recent'), filePath)
let index = recent.indexOf(filePath)
let exists = index != -1
if (exists) {
recent.splice(index, 1)
}
recent.push(filePath)
store.set('recent', JSON.stringify(recent));
console.log("addedToRecent", store.get('recent'), filePath)
if (!exists) {
recentSubmenu.append(new MenuItem({
label: filePath,
click: (menuItem, browserWindow, event) => {
readAndLoadFilePath(menuItem.label)
}
}))
}
}
function populateRecent() {
var recent = JSON.parse(store.get('recent') || "[]")
console.log("populateRecent", store.get('recent'))
for (recentfile of recent) {
recentSubmenu.append(new MenuItem({
label: recentfile,
click: (menuItem, browserWindow, event) => {
readAndLoadFilePath(menuItem.label)
}
}))
}
}
function loadMostRecent() {
var recent = JSON.parse(store.get('recent') || "[]")
if (recent.length > 0)
readAndLoadFilePath(recent[recent.length - 1])
}
contextMenu.append(new MenuItem({ type: 'separator' }))
function readAndLoadFilePath(filePath) {
fs.readFile(filePath, (err, data) => {
if (err) throw err;
let newState = JSON.parse(data);
mainWin.webContents.send('load-scene', newState, filePath)
});
}
contextMenu.append(new MenuItem({
label: "最近使用", type: 'submenu',
submenu: recentSubmenu
}));
populateRecent()
// 添加窗口菜单
contextMenu.append(new MenuItem({
label: "窗口", type: 'submenu',
submenu: windowSubmenu
}));
// 添加透明度菜单
for (let opacity = 100; opacity >= 10; opacity -= 10) {
opacitySubmenu.append(new MenuItem({
label: `${opacity}%`,
click: (menuItem, browserWindow, event) => {
mainWin.setOpacity(opacity / 100)
}
}))
}
contextMenu.append(new MenuItem({
label: "透明度", type: 'submenu',
submenu: opacitySubmenu
}));
// 添加锁定窗口菜单
contextMenu.append(new MenuItem({
id: "lock-window",
label: '锁定窗口',
click: (menuItem, browserWindow, event) => {
isLocked = true
mainWin.setIgnoreMouseEvents(true, { forward: true })
mainWin.webContents.send('lock-window')
updateTrayMenu()
}
}));
contextMenu.append(new MenuItem({
label: '加载',
accelerator: process.platform === 'darwin' ? 'Cmd+L' : 'Ctrl+L',
click: (menuItem, browserWindow, event) => {
dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{ name: 'RefVroid 场景', extensions: ['purgif'] }
]
}).then(result => {
if (!result.canceled) {
readAndLoadFilePath(result.filePaths[0])
}
}).catch(err => {
console.log(err)
})
}
}));
contextMenu.append(new MenuItem({
label: '保存',
accelerator: process.platform === 'darwin' ? 'Cmd+S' : 'Ctrl+S',
click: (menuItem, browserWindow, event) => {
dialog.showSaveDialog({
defaultPath: 'scene.purgif',
filters: [
{ name: 'RefVroid 场景', extensions: ['purgif'] }
]
}).then(result => {
if (!result.canceled) {
mainWin.webContents.send('save-scene', result.filePath)
addToRecent(result.filePath)
}
}).catch(err => {
console.log(err)
})
}
}));
contextMenu.append(new MenuItem({
label: '新建场景',
accelerator: process.platform === 'darwin' ? 'Cmd+N' : 'Ctrl+N',
click: (menuItem, browserWindow, event) => {
mainWin.webContents.send('new-scene');
}
}));
contextMenu.append(new MenuItem({
label: '关闭',
accelerator: process.platform === 'darwin' ? 'Cmd+W' : 'Ctrl+W',
click: (menuItem, browserWindow, event) => {
browserWindow.close();
}
}));
Menu.setApplicationMenu(contextMenu)
if (process.argv.indexOf("debug") > -1)
mainWin.webContents.openDevTools()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
app.on('browser-window-created', (event, win) => {
win.webContents.on('context-menu', (e, params) => {
//menu.popup(win, params.x, params.y)
})
})
ipcMain.on('ready', (event, menuType) => {
//loadMostRecent()
windowIsReady = true;
});
ipcMain.on('show-context-menu', (event, menuType) => {
const win = BrowserWindow.fromWebContents(event.sender)
// 根据窗口状态动态更新窗口菜单
const isMaximized = win.isMaximized()
windowSubmenu.clear()
windowSubmenu.append(new MenuItem({
label: isMaximized ? "恢复默认" : "最大化",
accelerator: process.platform === 'darwin' ? 'Cmd+F' : 'Ctrl+F',
click: (menuItem, browserWindow, event) => {
if(!browserWindow.isMaximized()) {
browserWindow.maximize();
} else {
browserWindow.unmaximize();
}
}
}))
windowSubmenu.append(new MenuItem({
label: "最小化",
accelerator: process.platform === 'darwin' ? 'Cmd+M' : 'Ctrl+M',
click: (menuItem, browserWindow, event) => {
browserWindow.minimize();
}
}))
contextMenu.popup(win)
})
ipcMain.on('save-scene', (event, filePath, stateCopy) => {
console.log('save', filePath, stateCopy)
let data = JSON.stringify(stateCopy);
fs.writeFileSync(filePath, data);
//const win = BrowserWindow.fromWebContents(event.sender)
//menu.popup(win)
})
let dragState = {
dragging: false
}
ipcMain.on('handle-paste', (event, w, h) => {
handlePaste()
})
ipcMain.on('trigger-load', (event) => {
dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{ name: 'RefVroid 场景', extensions: ['purgif'] }
]
}).then(result => {
if (!result.canceled) {
readAndLoadFilePath(result.filePaths[0])
}
}).catch(err => {
console.log(err)
})
})
ipcMain.on('trigger-save', (event) => {
dialog.showSaveDialog({
defaultPath: 'scene.purgif',
filters: [
{ name: 'RefVroid 场景', extensions: ['purgif'] }
]
}).then(result => {
if (!result.canceled) {
mainWin.webContents.send('save-scene', result.filePath)
addToRecent(result.filePath)
}
}).catch(err => {
console.log(err)
})
})
ipcMain.on('trigger-close', (event) => {
mainWin.close()
})
ipcMain.on('trigger-maximize', (event) => {
if(!mainWin.isMaximized()) {
mainWin.maximize()
} else {
mainWin.unmaximize()
}
})
ipcMain.on('trigger-minimize', (event) => {
mainWin.minimize()
})
ipcMain.on('show-help', (event) => {
createHelpWindow(mainWin)
})
ipcMain.on('copy-text', (event, text) => {
clipboard.writeText(text)
console.log('Copied text to clipboard:', text)
})
ipcMain.on('copy-multiple-images', (event, imageDataURLs) => {
try {
console.log('Received', imageDataURLs.length, 'images to copy')
if (imageDataURLs.length === 1) {
// 只有一张图片,直接复制
const img = nativeImage.createFromDataURL(imageDataURLs[0])
if (!img.isEmpty()) {
clipboard.clear()
clipboard.writeImage(img)
console.log('Copied single image')
}
return
}
// 创建临时文件夹
const tempDir = path.join(app.getPath('temp'), 'RefVroid-copy')
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true })
}
// 清理旧文件
try {
const oldFiles = fs.readdirSync(tempDir)
oldFiles.forEach(file => {
try {
fs.unlinkSync(path.join(tempDir, file))
} catch (err) {
// 忽略删除错误
}
})
} catch (err) {
// 忽略读取目录错误
}
// 保存所有图片为临时文件
const filePaths = []
imageDataURLs.forEach((dataURL, index) => {
try {
const img = nativeImage.createFromDataURL(dataURL)
if (!img.isEmpty()) {
const filePath = path.join(tempDir, `image_${Date.now()}_${index + 1}.png`)
fs.writeFileSync(filePath, img.toPNG())
filePaths.push(filePath)
console.log('Saved temp image:', filePath)
}
} catch (err) {
console.error('Error saving image', index, ':', err)
}
})
if (filePaths.length === 0) {
console.log('No valid images to copy')
return
}
console.log('Copying', filePaths.length, 'files:', filePaths)
// 清空剪贴板
clipboard.clear()
// 方法1: 使用 HTML 格式QQ 支持)
const htmlImages = filePaths.map(fp =>
`<img src="file:///${fp.replace(/\\/g, '/')}" />`
).join('')
const html = `<html><body>${htmlImages}</body></html>`
// 方法2: 使用文件路径格式
const fileList = filePaths.join('\n')
// 同时写入多种格式
clipboard.write({
text: fileList,
html: html
})
console.log('Copied', filePaths.length, 'images in multiple formats')
} catch (err) {
console.error('Error copying multiple images:', err)
}
})
ipcMain.on('copy-image', (event, imagePath) => {
try {
let img = null
// 如果是 dataURL直接写入
if (imagePath.startsWith('data:')) {
img = nativeImage.createFromDataURL(imagePath)
console.log('Processing dataURL image, size:', img.getSize())
// 确保图片不为空
if (img.isEmpty()) {
console.error('Created image from dataURL is empty')
clipboard.writeText('图片复制失败')
return
}
} else if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
// 网络图片暂时只能复制 URL
clipboard.writeText(imagePath)
console.log('Copied image URL to clipboard:', imagePath)
return
} else {
// 本地文件路径
// 处理可能的 file:// 协议
let filePath = imagePath
if (filePath.startsWith('file://')) {
filePath = filePath.substring(7)
}
// Windows 路径处理
if (process.platform === 'win32' && filePath.startsWith('/')) {
filePath = filePath.substring(1)
}
console.log('Reading image from path:', filePath)
// 检查文件是否存在
if (!fs.existsSync(filePath)) {
console.error('File does not exist:', filePath)
clipboard.writeText('文件不存在: ' + filePath)
return
}
img = nativeImage.createFromPath(filePath)
console.log('Image loaded, size:', img.getSize())
}
if (img && !img.isEmpty()) {
// 清空剪贴板
clipboard.clear()
// 写入图片到剪贴板
clipboard.writeImage(img)
console.log('Successfully copied image to clipboard')
console.log('Image size:', img.getSize())
console.log('Image aspect ratio:', img.getAspectRatio())
} else {
console.log('Image is empty, copying error message')
clipboard.writeText('图片为空,无法复制')
}
} catch (err) {
console.error('Error copying image:', err)
clipboard.writeText('复制图片时出错: ' + err.message)
}
})
ipcMain.on('loaded-state', (event, filePath) => {
addToRecent(filePath)
})
ipcMain.on('record-window-size', (event, w, h) => {
width = mainWin.getSize()[0]
height = mainWin.getSize()[1]
})
ipcMain.on('move-electron-window', (event, x, y, initPos) => {
//var display = screen.getDisplayNearestPoint({x: x, y: y})
//var dpiRespected = screen.dipToScreenPoint({x: x, y: y})
//mainWin.setPosition(x,y)
mainWin.setBounds({
width: width,
height: height,
x: x - initPos.x,
y: y - initPos.y
});
//win.setSize(width, height)
//mainWin.setPosition(Math.round(x / 1.25) - Math.round(initPos.x / 1.25), Math.round(y / 1.25) - Math.round(initPos.y / 1.25))
})
let loopToLoad = function loopToLoad(){
if(windowIsReady){
console.log("loadMostRecent")
//setTimeout(loadMostRecent, 700)
//loadMostRecent()
}
else{
console.log("not ready")
setTimeout(loopToLoad, 100)
}
};
loopToLoad();
})
let windowIsReady = false;
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('will-quit', () => {
globalShortcut.unregisterAll()
})
function validateUrl(value) {
return /^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:[/?#]\S*)?$/i.test(value);
}