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

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

82
.gitignore vendored Normal file
View File

@@ -0,0 +1,82 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# FuseBox cache
.fusebox/
.vscode/launch.json
# electron forge output
out/
*.purgif

5
.npmrc Normal file
View File

@@ -0,0 +1,5 @@
node-linker=hoisted
# Electron 中国镜像源配置
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
ELECTRON_CUSTOM_DIR={{ version }}
ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 小煜
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

148
help.html Normal file
View File

@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>快捷键帮助 - AnimRef</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: #2a2a2a;
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
padding: 30px;
overflow-y: auto;
}
h2 {
color: #259dc7;
font-size: 28px;
text-align: center;
font-weight: normal;
margin-bottom: 25px;
}
h3 {
margin: 20px 0 12px 0;
color: #88c0d0;
font-size: 18px;
font-weight: normal;
border-bottom: 1px solid #444;
padding-bottom: 8px;
}
.help-section {
margin-bottom: 20px;
}
.help-item {
display: flex;
align-items: center;
padding: 8px 0;
gap: 15px;
}
.help-item kbd {
background-color: #3a3a3a;
border: 1px solid #555;
border-radius: 4px;
padding: 4px 10px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
color: #e0e0e0;
min-width: 140px;
text-align: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.help-item span {
color: #b0b0b0;
font-size: 14px;
}
.help-footer {
margin-top: 25px;
text-align: center;
color: #888;
font-size: 13px;
font-style: italic;
}
body::-webkit-scrollbar {
width: 8px;
}
body::-webkit-scrollbar-track {
background: #1a1a1a;
border-radius: 4px;
}
body::-webkit-scrollbar-thumb {
background: #555;
border-radius: 4px;
}
body::-webkit-scrollbar-thumb:hover {
background: #666;
}
</style>
</head>
<body>
<h2>快捷键帮助</h2>
<div class="help-section">
<h3>文件操作</h3>
<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+L</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+W</kbd><span>关闭窗口</span></div>
</div>
<div class="help-section">
<h3>窗口操作</h3>
<div class="help-item"><kbd>Ctrl+F</kbd><span>最大化/恢复窗口</span></div>
<div class="help-item"><kbd>Ctrl+M</kbd><span>最小化窗口</span></div>
</div>
<div class="help-section">
<h3>视图操作</h3>
<div class="help-item"><kbd>空格+拖动</kbd><span>平移画布</span></div>
<div class="help-item"><kbd>Ctrl+空格+拖动</kbd><span>缩放画布</span></div>
<div class="help-item"><kbd>鼠标滚轮</kbd><span>缩放画布</span></div>
<div class="help-item"><kbd>中键拖动</kbd><span>平移画布</span></div>
</div>
<div class="help-section">
<h3>元素操作</h3>
<div class="help-item"><kbd>左键拖动</kbd><span>移动元素</span></div>
<div class="help-item"><kbd>左键框选</kbd><span>选择多个元素</span></div>
<div class="help-item"><kbd>拖动边缘</kbd><span>调整大小</span></div>
<div class="help-item"><kbd>Delete</kbd><span>删除选中元素</span></div>
<div class="help-item"><kbd>Esc</kbd><span>取消选择框</span></div>
</div>
<div class="help-section">
<h3>其他</h3>
<div class="help-item"><kbd>右键</kbd><span>显示菜单</span></div>
<div class="help-item"><kbd>Ctrl+H</kbd><span>显示/隐藏帮助</span></div>
</div>
<p class="help-footer">按 Esc 或关闭窗口退出帮助</p>
<script>
// 按 Esc 关闭窗口
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
window.close()
}
})
</script>
</body>
</html>

65
images/play.svg Normal file
View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 21.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="Layer_1"
x="0px"
y="0px"
viewBox="0 0 71.412065 50"
xml:space="preserve"
inkscape:version="0.91 r13725"
sodipodi:docname="YouTube_dark_icon (2017).svg"
width="71.412064"
height="50"><metadata
id="metadata33"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
id="defs31" /><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1366"
inkscape:window-height="715"
id="namedview29"
showgrid="false"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:zoom="5.4355702"
inkscape:cx="30.091613"
inkscape:cy="15.29613"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_1" /><style
type="text/css"
id="style3">
.st0{fill:#FF0000;}
.st1{fill:#FFFFFF;}
.st2{fill:#282828;}
</style><g
id="g5"
transform="scale(0.58823529,0.58823529)"><path
class="st0"
d="M 118.9,13.3 C 117.5,8.1 113.4,4 108.2,2.6 98.7,0 60.7,0 60.7,0 60.7,0 22.7,0 13.2,2.5 8.1,3.9 3.9,8.1 2.5,13.3 0,22.8 0,42.5 0,42.5 0,42.5 0,62.3 2.5,71.7 3.9,76.9 8,81 13.2,82.4 22.8,85 60.7,85 60.7,85 c 0,0 38,0 47.5,-2.5 5.2,-1.4 9.3,-5.5 10.7,-10.7 2.5,-9.5 2.5,-29.2 2.5,-29.2 0,0 0.1,-19.8 -2.5,-29.3 z"
id="path7"
inkscape:connector-curvature="0"
style="fill:#282828;fill-opacity:1" /><polygon
class="st1"
points="80.2,42.5 48.6,24.3 48.6,60.7 "
id="polygon9"
style="fill:#ffffff" /></g></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
images/tray-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

271
index.css Normal file
View File

@@ -0,0 +1,271 @@
:root {
--scale: 1;
}
html {
border: #0c0c0c solid 1px;
transform-origin: 0 0;
transform: translate3d(0, 0, 0);
}
html,
body {
background: #202020;
background-color: transparent;
height: calc(100% - 2px);
margin: 0px;
padding: 0px;
}
body {
transition: -webkit-transform 0.1s ease 0s;
transform-origin: 0px 0px;
transform: translate(0px, 0px) scale(1);
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
width: 0px;
height: 0px;
}
#welcome {
font-family: sans-serif;
color: #7a7a7a;
text-align: center;
-webkit-user-select: none;
user-select: none;
width: 360px;
display: flex;
flex-direction: column;
height: 107px;
justify-content: flex-end;
position: absolute;
left: calc(50vw - 360px / 2);
top: calc(50vh - 140px / 2);
}
h1,
p {
margin: 0;
}
#welcome h1 {
line-height: 1;
font-weight: normal;
font-size: x-large;
color: #888888;
}
#welcome p {
margin-top: 12px;
}
#workspaceBox {
position: absolute;
background-color: #2a2a2a;
z-index: -1;
}
#itemHolder {
height: 100%;
width: 100%;
}
.selectedItem {
outline: calc(0.15rem / var(--scale)) solid rgb(37 157 199);
}
.selectedItem.resizeItem {
}
#itemHolder > * {
position: absolute;
}
.textElement {
-webkit-user-select: none;
user-select: none;
text-shadow: rgb(255, 255, 255) -1px -1px 0px, rgb(255, 255, 255) 1px -1px 0px,
rgb(255, 255, 255) -1px 1px 0px, rgb(255, 255, 255) 1px 1px 0px;
text-align: center;
display:block;
white-space: nowrap;
}
.hiddenTextTester{
opacity: 0;
position: absolute;
font-size: 1ch;
-webkit-user-select: none;
user-select: none;
pointer-events: none;
}
.iframeDiv {
min-height: 0;
min-width: 0;
-webkit-user-select: none;
user-select: none;
height: 100%;
pointer-events: none;
width: 100%;
}
@keyframes rock {
0% {
transform: rotate(-9deg);
filter: blur(0.5px);
}
50% {
filter: blur(2px);
}
100% {
transform: rotate(9deg);
filter: blur(0.5px);
}
}
@keyframes gradientLoad {
from {
background-size: 150% 100%;
filter: drop-shadow(2px 4px 6px black);
}
to {
background-size: 200% 200%;
filter: drop-shadow(2px 4px 2px black);
}
}
.playerNeedsSetup::before {
background-image: url("images/play.svg");
background-repeat: no-repeat;
content: "";
position: absolute;
width: 71px;
height: 50px;
transform: rotate(0deg);
top: calc(50% - 50px / 2);
right: 0px;
bottom: 0px;
left: calc(50% - 71px / 2);
opacity: 0.5;
animation-name: rock;
animation-duration: 0.5s;
animation-direction: alternate;
animation-iteration-count: infinite;
}
.playerNeedsSetup {
background-image: radial-gradient(
circle,
rgba(0, 0, 0, 0.1) 0%,
rgba(0, 0, 0, 0.6) 100%
);
background-size: 200% 200%;
filter: drop-shadow(2px 4px 6px black);
animation-name: gradientLoad;
animation-duration: 1s;
animation-direction: alternate-reverse;
animation-iteration-count: infinite;
background-position: center;
}
.editVideo {
width: 100vw !important;
height: calc(100vh - 20px) !important;
transform: unset !important;
background-color: black;
}
.disableBorder {
border: unset !important;
}
.timeline {
position: absolute;
width: 100vw;
height: 20px;
background: #403f3f;
bottom: 0;
z-index: 9999;
left: 0;
right: 0;
top: calc(100vh - 20px);
}
#progressbar {
background-color: #ececec;
box-shadow: inset 0 0.5em 0.5em rgb(0 0 0 / 5%);
bottom: 0;
left: 0;
position: absolute;
right: 0;
top: 0;
transition: all 400ms ease-out;
}
#slider {
background-color: rgba(78, 47, 102, 1);
}
.hide {
display: none !important;
}
#slider:hover .noUi-connect,
#slider:hover .noUi-handle {
filter: brightness(1.5);
transition: filter 300ms ease;
}
#slider .noUi-handle {
border-left: 1em solid transparent;
border-right: 1em solid transparent;
border-bottom: 1em solid #a74040;
outline: none;
border-radius: unset;
background: unset;
cursor: default;
box-shadow: unset;
border-top: unset;
opacity: 0.95;
}
#slider .noUi-handle:before,
#slider .noUi-handle:after {
content: unset;
}
#slider .noUi-connect {
background: #b128284a; /*linear-gradient(90deg, rgba(78,47,102,1) 0%, rgba(91,52,122,1) 47%, rgba(113,80,136,1) 47%, rgba(121,76,157,1) 100%);*/
opacity: 0.9;
box-shadow: inset 3px 2px 15px 0px #000000;
transition: all 400ms ease-out;
}
#slider .noUi-target {
background: #bfbfbf;
border-radius: 7px;
border: unset;
box-shadow: inset 5px 6px 7px #d3d1d6, 8px 2px 8px -5px #4b285d;
}
/* 选择框样式 */
#selectionBox {
position: absolute;
border: 2px solid rgba(37, 157, 199, 0.8);
background-color: rgba(37, 157, 199, 0.2);
display: none;
pointer-events: none;
z-index: 9999;
left: 0;
top: 0;
}
/* 锁定状态样式 */
body.locked {
pointer-events: none;
}
body.locked #lockIndicator {
display: block !important;
visibility: visible !important;
}
#lockIndicator {
display: none;
visibility: hidden;
position: fixed !important;
bottom: 20px !important;
right: 20px !important;
font-size: 48px;
opacity: 0.8;
pointer-events: none !important;
z-index: 2147483647 !important;
text-shadow:
0 0 10px rgba(0, 0, 0, 1),
0 0 20px rgba(0, 0, 0, 0.8),
2px 2px 4px rgba(0, 0, 0, 0.9);
filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.9));
background: rgba(0, 0, 0, 0.3);
padding: 10px;
border-radius: 10px;
backdrop-filter: blur(5px);
}

38
index.html Normal file
View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html id="root">
<head>
<meta charset="UTF-8" />
<title>RefVroid Support</title>
<meta
http-equiv="Content-Security-Policy"
content="script-src 'self' 'unsafe-inline' https://www.youtube.com;"
/>
<link rel="stylesheet" href="index.css" />
<link href="node_modules/nouislider/dist/nouislider.css" rel="stylesheet" />
<script src="node_modules/nouislider/dist/nouislider.js"></script>
<script src="https://www.youtube.com/iframe_api" defer></script>
<script src="renderer.js" defer></script>
</head>
<body>
<div id="hiddenTextTester"></div>
<div id="editVideoTools" class="hide">
<div class="timeline">
<div id="slider"><div id="progressbar"></div></div>
</div>
</div>
<div id="eventTrigger"></div>
<div id="welcome">
<h1>
这里什么都没有!<br />
拖放一些图片开始使用。
</h1>
<p>(按 Ctrl+H 获取更多帮助)</p>
</div>
<div id="itemHolder"></div>
<div id="workspaceBox"></div>
<div id="selectionBox"></div>
<div id="lockIndicator">🔒</div>
</body>
</html>

3
interact.min.js vendored Normal file

File diff suppressed because one or more lines are too long

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);
}

12265
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

83
package.json Normal file
View File

@@ -0,0 +1,83 @@
{
"name": "refvroid",
"version": "1.0.4",
"description": "Pureref clone that has support for gifs/videos.",
"main": "main.js",
"scripts": {
"start": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make"
},
"keywords": [],
"author": "小煜",
"license": "ISC",
"devDependencies": {
"@electron-forge/cli": "^7.4.0",
"@electron-forge/maker-deb": "^7.4.0",
"@electron-forge/maker-rpm": "^7.4.0",
"@electron-forge/maker-squirrel": "^7.4.0",
"@electron-forge/maker-zip": "^7.4.0",
"@electron-forge/maker-flatpak": "^7.4.0",
"@electron-forge/maker-dmg": "^7.4.0",
"electron": "30.0.1",
"@electron/rebuild": "^3.6.0"
},
"dependencies": {
"dombo": "^3.2.0",
"electron-squirrel-startup": "^1.0.0",
"electron-store": "^8.0.1",
"nouislider": "^15.5.0",
"try-require": "^1.2.1"
},
"config": {
"forge": {
"packagerConfig": {},
"make_targets": {
"win32": [
"squirrel"
],
"darwin": [
"dmg"
],
"linux": [
"deb",
"rpm",
"flatpak"
]
},
"makers": [
{
"name": "@electron-forge/maker-squirrel",
"config": {
"name": "refvroid"
}
},
{
"name": "@electron-forge/maker-zip"
},
{
"name": "@electron-forge/maker-flatpak",
"config": {
"options": {
"categories": [
"Graphics"
]
}
}
},
{
"name": "@electron-forge/maker-deb",
"config": {}
},
{
"name": "@electron-forge/maker-rpm",
"config": {}
},
{
"name": "@electron-forge/maker-dmg",
"config": {}
}
]
}
}
}

1183
preload.js Normal file

File diff suppressed because it is too large Load Diff

186
renderer.js Normal file
View File

@@ -0,0 +1,186 @@
const sliderElement = document.querySelector('#slider')
noUiSlider.create(sliderElement, {
start: [0, 100],
connect: true,
behaviour: 'unconstrained-tap',
range: {
'min': 0,
'max': 100
}
});
slider.noUiSlider.on('start', function () {
});
let scalingWithTouch = false
let lastDelta = 0
let initTouches = []
//ontouchstart
document.addEventListener('touchstart', function (e) {
if (e.touches.length > 1) {
scalingWithTouch = true
initTouches = e.touches
}
}, true);
document.addEventListener('touchend', function (e) {
if (e.touches.length < 2) {
scalingWithTouch = false
}
}, true);
document.addEventListener('touchmove', function (e) {
if (scalingWithTouch) {
//zoom
// delta between e.touches[1], e.touches[0] and initTouches[1], initTouches[0]
let delta = Math.sqrt(Math.pow(e.touches[0].clientX - e.touches[1].clientX, 2) + Math.pow(e.touches[0].clientY - e.touches[1].clientY, 2)) - Math.sqrt(Math.pow(initTouches[0].clientX - initTouches[1].clientX, 2) + Math.pow(initTouches[0].clientY - initTouches[1].clientY, 2))
let deltaDiff = delta - lastDelta
deltaDiff = deltaDiff / 100
currentScale = parseFloat(document.body.dataset.currentScale) || 1
const nextScale = Math.max(currentScale + deltaDiff * (currentScale / 2), 0.01)
console.log("touchmove", deltaDiff, currentScale, nextScale)
e.clientX = (initTouches[0].clientX + initTouches[0].clientX) / 2
e.clientY = (initTouches[0].clientY + initTouches[0].clientY) / 2
//zoom(nextScale, e) // this is not working
lastDelta = delta
}
}, true);
const factor = 0.1
document.documentElement.addEventListener("wheel", (e) => {
let delta = e.wheelDelta / 120
if (e.ctrlKey) { // is pinch zoom on touchpad(idk why it's ctrlKey but it is)
delta = e.deltaY * factor
}
if (document.querySelector('.editVideo')) return;
currentScale = parseFloat(document.body.dataset.currentScale) || 1
const nextScale = Math.max(currentScale + delta * (currentScale / 2), 0.01)
console.log("wheel", delta, currentScale, nextScale)
zoom(nextScale, e)
})
const zoom = (nextScale, event) => {
currentScale = parseFloat(document.body.dataset.currentScale) || 1
const ratio = 1 - nextScale / currentScale
const {
clientX,
clientY
} = event
let translateX = (parseFloat(document.body.dataset.translateX) || 0);
let translateY = (parseFloat(document.body.dataset.translateY) || 0);
translateX += (clientX - translateX) * ratio
translateY += (clientY - translateY) * ratio
console.log('zoom')
//console.log(translateX, translateY, nextScale, ratio, currentScale)
currentScale = nextScale
myAPI.updateScaleAndTranslate(currentScale, { translateX, translateY })
}
/**
* Youtube Embed Section
*/
var player;
function onYouTubeIframeAPIReady() { } // idk why it breaks without this
function onPlayerReady(event) {
event.target.setVolume(0);
event.target.mute();
// lifts youtube video to last element at the root of body
event.target.getIframe().contentDocument.body.appendChild(player.getIframe().contentDocument.querySelector('video'))
event.target.playVideo();
}
var done = false;
function onPlayerStateChange(event) {
if (event.data == YT.PlayerState.PLAYING && !done) {
//setTimeout(stopVideo, 6000);
let videoElement = event.target.getIframe().contentDocument.querySelector('video')
videoElement.loop = true
/*
videoElement.addEventListener('timeupdate', function(){
//if im the editvideo
if(document.querySelector(`.editVideo[data-idcode="${this.dataset.idcode}"]`)){
onPlayerProgress.apply(this, arguments)
}
})*/
let videoWidth = videoElement.videoWidth
let videoHeight = videoElement.videoHeight
//console.log('inside onPlayerStateChange', videoWidth, videoHeight)
let idcode = event.target.getIframe().parentElement.dataset.idcode
videoElement.dataset.idcode = idcode
//console.log('after onPlayerStateChange', videoElement.dataset.idcode, videoWidth, videoHeight)
myAPI.updateYoutubeOriginalSize(idcode, videoWidth, videoHeight)
//event.target.getIframe().parentElement.style.width = videoWidth + 'px'
//event.target.getIframe().parentElement.style.height = videoHeight + 'px'
done = true;
}
}
function stopVideo() {
player.stopVideo();
}
const targetNode = document.getElementById('eventTrigger');
const config = { attributes: true };
const callback = function (mutationsList, observer) {
// Use traditional 'for loops' for IE 11
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
console.log('A child node has been added or removed.');
}
else if (mutation.type === 'attributes') {
console.log('The ' + mutation.attributeName + ' attribute was modified.');
if (mutation.attributeName == 'data-youtubetrigger') {
embedYoutubeVideo()
} else if (mutation.attributeName == 'data-changesliders') {
//console.log(mutation)
//
let changesliders = JSON.parse(mutation.target.dataset.changesliders)
slider.noUiSlider.set(changesliders);
}
}
}
};
const observer = new MutationObserver(callback);
// Start observing the target node for configured mutations
observer.observe(targetNode, config);
function embedYoutubeVideo() {
var playerNeedsSetup = document.querySelector('.playerNeedsSetup');
if (!playerNeedsSetup) {
console.error('embedYoutubeVideo called without player div to setup')
};
playerNeedsSetup.classList.remove('playerNeedsSetup')
var iframediv = playerNeedsSetup.querySelector('.iframeDiv')
var code = playerNeedsSetup.dataset.idcode
//document.querySelector()
player = new YT.Player(iframediv, {
videoId: code,
playerVars: {
//'playsinline': 1
'controls': 0,
'disablekb': 1,
'enablejsapi': 1,
'fs': 0,
'loop': 1
},
events: {
'onReady': onPlayerReady,
'onStateChange': onPlayerStateChange
}
});
}