feat: 初始化项目基础架构和核心功能
添加项目基础文件结构,包括Tauri配置、前端框架和核心功能模块 实现WebGPU渲染器基础、纹理管理和着色器 添加UI组件包括标题栏、确认对话框、帮助窗口等 设置状态管理存储和工具函数 配置构建工具和开发环境
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
7
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"Vue.volar",
|
||||||
|
"tauri-apps.tauri-vscode",
|
||||||
|
"rust-lang.rust-analyzer"
|
||||||
|
]
|
||||||
|
}
|
||||||
7
README.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Tauri + Vue 3
|
||||||
|
|
||||||
|
This template should help get you started developing with Tauri + Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||||
49
index.html
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>RefVroid</title>
|
||||||
|
<style>
|
||||||
|
/* Prevent white flash during resize */
|
||||||
|
html, body, #app {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #191919;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar styles */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #555;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-corner {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body oncontextmenu="return false;">
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "refvroid",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"vue": "^3.5.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2",
|
||||||
|
"@types/node": "^25.0.3",
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"@webgpu/types": "^0.1.68",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^6.0.3",
|
||||||
|
"vue-tsc": "^3.1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
1041
pnpm-lock.yaml
generated
Normal file
6
public/tauri.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||||
|
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
1
public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
7
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
|
||||||
|
# Generated by Tauri
|
||||||
|
# will have schema files for capabilities auto-completion
|
||||||
|
/gen/schemas
|
||||||
6250
src-tauri/Cargo.lock
generated
Normal file
41
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
[package]
|
||||||
|
name = "refvroid"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "RefVroid - Animation Reference Desktop App"
|
||||||
|
authors = ["you"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
# The `_lib` suffix may seem redundant but it is necessary
|
||||||
|
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||||
|
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||||
|
name = "refvroid_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = ["tray-icon"] }
|
||||||
|
tauri-plugin-opener = "2"
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
tauri-plugin-clipboard-manager = "2"
|
||||||
|
tauri-plugin-fs = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
base64 = "0.22"
|
||||||
|
image = "0.25"
|
||||||
|
arboard = "3"
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
windows = { version = "0.58", features = [
|
||||||
|
"Win32_UI_WindowsAndMessaging",
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_System_DataExchange",
|
||||||
|
"Win32_System_Ole",
|
||||||
|
"Win32_UI_Shell",
|
||||||
|
"Win32_System_Memory"
|
||||||
|
] }
|
||||||
|
|
||||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
40
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Capability for the main window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"core:window:default",
|
||||||
|
"core:window:allow-set-always-on-top",
|
||||||
|
"core:window:allow-is-always-on-top",
|
||||||
|
"core:window:allow-set-decorations",
|
||||||
|
"core:window:allow-set-ignore-cursor-events",
|
||||||
|
"core:window:allow-maximize",
|
||||||
|
"core:window:allow-unmaximize",
|
||||||
|
"core:window:allow-is-maximized",
|
||||||
|
"core:window:allow-minimize",
|
||||||
|
"core:window:allow-is-minimized",
|
||||||
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-show",
|
||||||
|
"core:window:allow-hide",
|
||||||
|
"core:window:allow-is-visible",
|
||||||
|
"core:window:allow-set-focus",
|
||||||
|
"core:window:allow-start-dragging",
|
||||||
|
"core:event:default",
|
||||||
|
"core:event:allow-emit",
|
||||||
|
"core:event:allow-listen",
|
||||||
|
"opener:default",
|
||||||
|
"dialog:default",
|
||||||
|
"dialog:allow-open",
|
||||||
|
"dialog:allow-save",
|
||||||
|
"fs:default",
|
||||||
|
"fs:read-all",
|
||||||
|
"fs:write-all",
|
||||||
|
"clipboard-manager:default",
|
||||||
|
"clipboard-manager:allow-read-text",
|
||||||
|
"clipboard-manager:allow-read-image",
|
||||||
|
"clipboard-manager:allow-write-text",
|
||||||
|
"clipboard-manager:allow-write-image"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
504
src-tauri/src/commands/clipboard.rs
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
// Clipboard operations commands for RefVroid
|
||||||
|
// Handles reading/writing images, text, and files to system clipboard
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use image::ImageEncoder;
|
||||||
|
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use std::ffi::OsString;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use std::os::windows::ffi::OsStringExt;
|
||||||
|
|
||||||
|
/// Clipboard content types
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", content = "data")]
|
||||||
|
pub enum ClipboardContent {
|
||||||
|
#[serde(rename = "image")]
|
||||||
|
Image(String), // base64 encoded image with data URL prefix
|
||||||
|
#[serde(rename = "text")]
|
||||||
|
Text(String),
|
||||||
|
#[serde(rename = "files")]
|
||||||
|
Files(Vec<String>), // file paths
|
||||||
|
#[serde(rename = "empty")]
|
||||||
|
Empty,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read file list from Windows clipboard (CF_HDROP format)
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn read_clipboard_files_windows() -> Option<Vec<String>> {
|
||||||
|
use windows::Win32::Foundation::{HWND, HGLOBAL};
|
||||||
|
use windows::Win32::System::DataExchange::{OpenClipboard, CloseClipboard, GetClipboardData};
|
||||||
|
use windows::Win32::System::Ole::CF_HDROP;
|
||||||
|
use windows::Win32::UI::Shell::{DragQueryFileW, HDROP};
|
||||||
|
use windows::Win32::System::Memory::{GlobalLock, GlobalUnlock};
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
if OpenClipboard(HWND::default()).is_err() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = (|| {
|
||||||
|
let handle = GetClipboardData(CF_HDROP.0 as u32).ok()?;
|
||||||
|
let hglobal = HGLOBAL(handle.0);
|
||||||
|
let ptr = GlobalLock(hglobal);
|
||||||
|
|
||||||
|
if ptr.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hdrop = HDROP(ptr as _);
|
||||||
|
|
||||||
|
// Get file count
|
||||||
|
let count = DragQueryFileW(hdrop, 0xFFFFFFFF, None);
|
||||||
|
if count == 0 {
|
||||||
|
let _ = GlobalUnlock(hglobal);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut files = Vec::new();
|
||||||
|
for i in 0..count {
|
||||||
|
// Get required buffer size
|
||||||
|
let len = DragQueryFileW(hdrop, i, None) as usize;
|
||||||
|
if len == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate buffer and get file path
|
||||||
|
let mut buffer: Vec<u16> = vec![0; len + 1];
|
||||||
|
DragQueryFileW(hdrop, i, Some(&mut buffer));
|
||||||
|
|
||||||
|
// Convert to String
|
||||||
|
let path = OsString::from_wide(&buffer[..len]);
|
||||||
|
if let Some(path_str) = path.to_str() {
|
||||||
|
files.push(path_str.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = GlobalUnlock(hglobal);
|
||||||
|
|
||||||
|
if files.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(files)
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
let _ = CloseClipboard();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
fn read_clipboard_files_windows() -> Option<Vec<String>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read clipboard contents - supports images, text, and file paths
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn read_clipboard() -> Result<ClipboardContent, String> {
|
||||||
|
use arboard::Clipboard;
|
||||||
|
|
||||||
|
// First, try to get file list from Windows clipboard (CF_HDROP)
|
||||||
|
// This preserves GIF animation by reading from original file
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
if let Some(files) = read_clipboard_files_windows() {
|
||||||
|
return Ok(ClipboardContent::Files(files));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut clipboard = Clipboard::new()
|
||||||
|
.map_err(|e| format!("Failed to access clipboard: {}", e))?;
|
||||||
|
|
||||||
|
// Try to get text and check if it contains file paths
|
||||||
|
if let Ok(text) = clipboard.get_text() {
|
||||||
|
if !text.is_empty() {
|
||||||
|
// Check if text is a file path or list of file paths
|
||||||
|
let paths: Vec<&str> = text.lines()
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Check if all lines are valid file paths
|
||||||
|
let all_files = !paths.is_empty() && paths.iter().all(|p| {
|
||||||
|
// Handle file:// URLs
|
||||||
|
let clean_path = if p.starts_with("file://") {
|
||||||
|
p.trim_start_matches("file://")
|
||||||
|
} else {
|
||||||
|
p
|
||||||
|
};
|
||||||
|
let path = Path::new(clean_path);
|
||||||
|
path.exists() && path.is_file()
|
||||||
|
});
|
||||||
|
|
||||||
|
if all_files {
|
||||||
|
let file_paths: Vec<String> = paths.iter()
|
||||||
|
.map(|p| {
|
||||||
|
if p.starts_with("file://") {
|
||||||
|
p.trim_start_matches("file://").to_string()
|
||||||
|
} else {
|
||||||
|
p.to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
return Ok(ClipboardContent::Files(file_paths));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get image (for screenshots, copied images from browsers, etc.)
|
||||||
|
if let Ok(img) = clipboard.get_image() {
|
||||||
|
// Convert to PNG and base64 encode
|
||||||
|
let mut png_data = Vec::new();
|
||||||
|
let encoder = image::codecs::png::PngEncoder::new(&mut png_data);
|
||||||
|
encoder.write_image(
|
||||||
|
&img.bytes,
|
||||||
|
img.width as u32,
|
||||||
|
img.height as u32,
|
||||||
|
image::ExtendedColorType::Rgba8,
|
||||||
|
).map_err(|e| format!("Failed to encode image: {}", e))?;
|
||||||
|
|
||||||
|
let base64_data = BASE64.encode(&png_data);
|
||||||
|
return Ok(ClipboardContent::Image(format!("data:image/png;base64,{}", base64_data)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get text (for regular text content)
|
||||||
|
if let Ok(text) = clipboard.get_text() {
|
||||||
|
if !text.is_empty() {
|
||||||
|
return Ok(ClipboardContent::Text(text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ClipboardContent::Empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write image data to clipboard (from raw bytes)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn write_clipboard_image(data: Vec<u8>) -> Result<(), String> {
|
||||||
|
use arboard::Clipboard;
|
||||||
|
|
||||||
|
let img = image::load_from_memory(&data)
|
||||||
|
.map_err(|e| format!("Failed to decode image: {}", e))?;
|
||||||
|
let rgba = img.to_rgba8();
|
||||||
|
|
||||||
|
let mut clipboard = Clipboard::new()
|
||||||
|
.map_err(|e| format!("Failed to access clipboard: {}", e))?;
|
||||||
|
|
||||||
|
let img_data = arboard::ImageData {
|
||||||
|
width: rgba.width() as usize,
|
||||||
|
height: rgba.height() as usize,
|
||||||
|
bytes: rgba.into_raw().into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
clipboard.set_image(img_data)
|
||||||
|
.map_err(|e| format!("Failed to set clipboard image: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write image from base64 string to clipboard
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn write_clipboard_image_base64(base64_data: String) -> Result<(), String> {
|
||||||
|
// Remove data URL prefix if present
|
||||||
|
let base64_str = if base64_data.contains(",") {
|
||||||
|
base64_data.split(',').nth(1).unwrap_or(&base64_data)
|
||||||
|
} else {
|
||||||
|
&base64_data
|
||||||
|
};
|
||||||
|
|
||||||
|
let data = BASE64.decode(base64_str)
|
||||||
|
.map_err(|e| format!("Failed to decode base64: {}", e))?;
|
||||||
|
|
||||||
|
write_clipboard_image(data).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write files to clipboard - reads file and copies as image if it's an image file
|
||||||
|
/// For GIF files, preserves the original GIF data to maintain animation
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn write_clipboard_files(paths: Vec<String>) -> Result<(), String> {
|
||||||
|
use arboard::Clipboard;
|
||||||
|
|
||||||
|
if paths.is_empty() {
|
||||||
|
return Err("No files provided".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// For single image file, copy as image to clipboard
|
||||||
|
if paths.len() == 1 {
|
||||||
|
let path = &paths[0];
|
||||||
|
let extension = Path::new(path)
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|s| s.to_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Check if it's an image file
|
||||||
|
if matches!(extension.as_str(), "png" | "jpg" | "jpeg" | "bmp" | "webp") {
|
||||||
|
let data = fs::read(path)
|
||||||
|
.map_err(|e| format!("Failed to read file: {}", e))?;
|
||||||
|
return write_clipboard_image(data).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For GIF files, we need special handling to preserve animation
|
||||||
|
if extension == "gif" {
|
||||||
|
let data = fs::read(path)
|
||||||
|
.map_err(|e| format!("Failed to read GIF file: {}", e))?;
|
||||||
|
|
||||||
|
// Decode GIF and get first frame for clipboard (clipboard doesn't support animated GIF)
|
||||||
|
let img = image::load_from_memory(&data)
|
||||||
|
.map_err(|e| format!("Failed to decode GIF: {}", e))?;
|
||||||
|
let rgba = img.to_rgba8();
|
||||||
|
|
||||||
|
let mut clipboard = Clipboard::new()
|
||||||
|
.map_err(|e| format!("Failed to access clipboard: {}", e))?;
|
||||||
|
|
||||||
|
let img_data = arboard::ImageData {
|
||||||
|
width: rgba.width() as usize,
|
||||||
|
height: rgba.height() as usize,
|
||||||
|
bytes: rgba.into_raw().into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
clipboard.set_image(img_data)
|
||||||
|
.map_err(|e| format!("Failed to set clipboard image: {}", e))?;
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For multiple files or non-image files, copy paths as text
|
||||||
|
let mut clipboard = Clipboard::new()
|
||||||
|
.map_err(|e| format!("Failed to access clipboard: {}", e))?;
|
||||||
|
|
||||||
|
let text = paths.join("\n");
|
||||||
|
clipboard.set_text(text)
|
||||||
|
.map_err(|e| format!("Failed to set clipboard text: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write text to clipboard
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn write_clipboard_text(text: String) -> Result<(), String> {
|
||||||
|
use arboard::Clipboard;
|
||||||
|
|
||||||
|
let mut clipboard = Clipboard::new()
|
||||||
|
.map_err(|e| format!("Failed to access clipboard: {}", e))?;
|
||||||
|
|
||||||
|
clipboard.set_text(text)
|
||||||
|
.map_err(|e| format!("Failed to set clipboard text: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write multiple images to clipboard as temporary files
|
||||||
|
/// This preserves GIF animation and allows copying multiple images
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn write_clipboard_images_as_files(images: Vec<(String, String)>) -> Result<(), String> {
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
if images.is_empty() {
|
||||||
|
return Err("No images provided".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let temp_dir = env::temp_dir().join("refvroid_clipboard");
|
||||||
|
fs::create_dir_all(&temp_dir)
|
||||||
|
.map_err(|e| format!("Failed to create temp directory: {}", e))?;
|
||||||
|
|
||||||
|
let mut file_paths = Vec::new();
|
||||||
|
|
||||||
|
for (index, (base64_data, file_type)) in images.iter().enumerate() {
|
||||||
|
// Remove data URL prefix if present
|
||||||
|
let (mime_type, base64_str) = if base64_data.contains(",") {
|
||||||
|
let parts: Vec<&str> = base64_data.splitn(2, ',').collect();
|
||||||
|
let mime = parts[0]
|
||||||
|
.trim_start_matches("data:")
|
||||||
|
.trim_end_matches(";base64");
|
||||||
|
(mime.to_string(), parts.get(1).unwrap_or(&"").to_string())
|
||||||
|
} else {
|
||||||
|
(file_type.clone(), base64_data.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
let data = BASE64.decode(&base64_str)
|
||||||
|
.map_err(|e| format!("Failed to decode base64: {}", e))?;
|
||||||
|
|
||||||
|
// Determine file extension from MIME type or detect from content
|
||||||
|
let ext = if let Some(detected) = detect_image_mime_type(&data) {
|
||||||
|
match detected {
|
||||||
|
"image/gif" => "gif",
|
||||||
|
"image/png" => "png",
|
||||||
|
"image/jpeg" => "jpg",
|
||||||
|
"image/bmp" => "bmp",
|
||||||
|
"image/webp" => "webp",
|
||||||
|
_ => "png",
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match mime_type.as_str() {
|
||||||
|
"image/gif" => "gif",
|
||||||
|
"image/png" => "png",
|
||||||
|
"image/jpeg" => "jpg",
|
||||||
|
"image/bmp" => "bmp",
|
||||||
|
"image/webp" => "webp",
|
||||||
|
_ => "png",
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let file_name = format!("clipboard_{}_{}.{}", std::process::id(), index, ext);
|
||||||
|
let file_path = temp_dir.join(&file_name);
|
||||||
|
|
||||||
|
fs::write(&file_path, &data)
|
||||||
|
.map_err(|e| format!("Failed to write temp file: {}", e))?;
|
||||||
|
|
||||||
|
file_paths.push(file_path.to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write file paths to clipboard
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
write_files_to_clipboard_windows(&file_paths)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
// Fallback: write paths as text
|
||||||
|
use arboard::Clipboard;
|
||||||
|
let mut clipboard = Clipboard::new()
|
||||||
|
.map_err(|e| format!("Failed to access clipboard: {}", e))?;
|
||||||
|
clipboard.set_text(file_paths.join("\n"))
|
||||||
|
.map_err(|e| format!("Failed to set clipboard text: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write file paths to Windows clipboard in CF_HDROP format
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn write_files_to_clipboard_windows(paths: &[String]) -> Result<(), String> {
|
||||||
|
use windows::Win32::Foundation::HWND;
|
||||||
|
use windows::Win32::System::DataExchange::{OpenClipboard, CloseClipboard, EmptyClipboard, SetClipboardData};
|
||||||
|
use windows::Win32::System::Ole::CF_HDROP;
|
||||||
|
use windows::Win32::System::Memory::{GlobalAlloc, GlobalLock, GlobalUnlock, GMEM_MOVEABLE, GLOBAL_ALLOC_FLAGS};
|
||||||
|
use std::ptr;
|
||||||
|
|
||||||
|
// Calculate required buffer size
|
||||||
|
// DROPFILES structure + null-terminated wide strings + final null
|
||||||
|
let dropfiles_size = std::mem::size_of::<DROPFILES>();
|
||||||
|
let mut total_size = dropfiles_size;
|
||||||
|
|
||||||
|
for path in paths {
|
||||||
|
// Each path as wide string + null terminator
|
||||||
|
total_size += (path.len() + 1) * 2;
|
||||||
|
}
|
||||||
|
total_size += 2; // Final null terminator
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
if OpenClipboard(HWND::default()).is_err() {
|
||||||
|
return Err("Failed to open clipboard".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = (|| -> Result<(), String> {
|
||||||
|
if EmptyClipboard().is_err() {
|
||||||
|
return Err("Failed to empty clipboard".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let hglobal = GlobalAlloc(GMEM_MOVEABLE, total_size)
|
||||||
|
.map_err(|_| "Failed to allocate global memory".to_string())?;
|
||||||
|
|
||||||
|
let ptr = GlobalLock(hglobal);
|
||||||
|
if ptr.is_null() {
|
||||||
|
return Err("Failed to lock global memory".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill DROPFILES structure
|
||||||
|
let dropfiles = ptr as *mut DROPFILES;
|
||||||
|
(*dropfiles).pFiles = dropfiles_size as u32;
|
||||||
|
(*dropfiles).pt.x = 0;
|
||||||
|
(*dropfiles).pt.y = 0;
|
||||||
|
(*dropfiles).fNC = false.into();
|
||||||
|
(*dropfiles).fWide = true.into(); // Unicode
|
||||||
|
|
||||||
|
// Write file paths
|
||||||
|
let mut offset = dropfiles_size;
|
||||||
|
for path in paths {
|
||||||
|
let wide: Vec<u16> = path.encode_utf16().chain(std::iter::once(0)).collect();
|
||||||
|
let dest = (ptr as *mut u8).add(offset) as *mut u16;
|
||||||
|
ptr::copy_nonoverlapping(wide.as_ptr(), dest, wide.len());
|
||||||
|
offset += wide.len() * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final null terminator
|
||||||
|
let dest = (ptr as *mut u8).add(offset) as *mut u16;
|
||||||
|
*dest = 0;
|
||||||
|
|
||||||
|
let _ = GlobalUnlock(hglobal);
|
||||||
|
|
||||||
|
if SetClipboardData(CF_HDROP.0 as u32, windows::Win32::Foundation::HANDLE(hglobal.0)).is_err() {
|
||||||
|
return Err("Failed to set clipboard data".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
|
||||||
|
let _ = CloseClipboard();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[repr(C)]
|
||||||
|
struct DROPFILES {
|
||||||
|
pFiles: u32,
|
||||||
|
pt: windows::Win32::Foundation::POINT,
|
||||||
|
fNC: windows::Win32::Foundation::BOOL,
|
||||||
|
fWide: windows::Win32::Foundation::BOOL,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect image MIME type from file content (magic bytes)
|
||||||
|
fn detect_image_mime_type(data: &[u8]) -> Option<&'static str> {
|
||||||
|
if data.len() < 8 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check magic bytes
|
||||||
|
if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
|
||||||
|
return Some("image/gif");
|
||||||
|
}
|
||||||
|
if data.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
|
||||||
|
return Some("image/png");
|
||||||
|
}
|
||||||
|
if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||||||
|
return Some("image/jpeg");
|
||||||
|
}
|
||||||
|
if data.starts_with(b"BM") {
|
||||||
|
return Some("image/bmp");
|
||||||
|
}
|
||||||
|
if data.starts_with(b"RIFF") && data.len() >= 12 && &data[8..12] == b"WEBP" {
|
||||||
|
return Some("image/webp");
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read image from file and return as base64 data URL
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn read_image_as_base64(path: String) -> Result<String, String> {
|
||||||
|
let data = fs::read(&path)
|
||||||
|
.map_err(|e| format!("Failed to read file: {}", e))?;
|
||||||
|
|
||||||
|
// First try to detect MIME type from file content (magic bytes)
|
||||||
|
let mime_type = if let Some(detected) = detect_image_mime_type(&data) {
|
||||||
|
detected
|
||||||
|
} else {
|
||||||
|
// Fallback to extension-based detection
|
||||||
|
let extension = Path::new(&path)
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|s| s.to_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
match extension.as_str() {
|
||||||
|
"png" => "image/png",
|
||||||
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
|
"gif" => "image/gif",
|
||||||
|
"bmp" => "image/bmp",
|
||||||
|
"webp" => "image/webp",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let base64_data = BASE64.encode(&data);
|
||||||
|
Ok(format!("data:{};base64,{}", mime_type, base64_data))
|
||||||
|
}
|
||||||
181
src-tauri/src/commands/file.rs
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
// File operations commands for RefVroid
|
||||||
|
// Handles scene save/load and file reading operations
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri_plugin_dialog::{DialogExt, FilePath};
|
||||||
|
use tauri::AppHandle;
|
||||||
|
|
||||||
|
/// Result of a file dialog operation
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct FileDialogResult {
|
||||||
|
pub path: Option<String>,
|
||||||
|
pub cancelled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save scene data to a file
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_scene(path: String, data: String) -> Result<(), String> {
|
||||||
|
fs::write(&path, data).map_err(|e| format!("Failed to save scene: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load scene data from a file
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn load_scene(path: String) -> Result<String, String> {
|
||||||
|
fs::read_to_string(&path).map_err(|e| format!("Failed to load scene: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a file and return its contents as base64
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn read_file_as_base64(path: String) -> Result<String, String> {
|
||||||
|
let file_path = Path::new(&path);
|
||||||
|
let data = fs::read(file_path).map_err(|e| format!("Failed to read file: {}", e))?;
|
||||||
|
Ok(BASE64.encode(&data))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show save file dialog for .purgif files
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn show_save_dialog(app: AppHandle, default_name: Option<String>) -> Result<FileDialogResult, String> {
|
||||||
|
let file_name = default_name.unwrap_or_else(|| "scene.purgif".to_string());
|
||||||
|
|
||||||
|
let file_path = app.dialog()
|
||||||
|
.file()
|
||||||
|
.set_file_name(&file_name)
|
||||||
|
.add_filter("RefVroid Scene", &["purgif"])
|
||||||
|
.add_filter("All Files", &["*"])
|
||||||
|
.blocking_save_file();
|
||||||
|
|
||||||
|
match file_path {
|
||||||
|
Some(path) => {
|
||||||
|
let path_str = match path {
|
||||||
|
FilePath::Path(p) => p.to_string_lossy().to_string(),
|
||||||
|
FilePath::Url(u) => u.path().to_string(),
|
||||||
|
};
|
||||||
|
Ok(FileDialogResult {
|
||||||
|
path: Some(path_str),
|
||||||
|
cancelled: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
None => Ok(FileDialogResult {
|
||||||
|
path: None,
|
||||||
|
cancelled: true,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show open file dialog for .purgif files
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn show_open_dialog(app: AppHandle) -> Result<FileDialogResult, String> {
|
||||||
|
let file_path = app.dialog()
|
||||||
|
.file()
|
||||||
|
.add_filter("RefVroid Scene", &["purgif"])
|
||||||
|
.add_filter("All Files", &["*"])
|
||||||
|
.blocking_pick_file();
|
||||||
|
|
||||||
|
match file_path {
|
||||||
|
Some(path) => {
|
||||||
|
let path_str = match path {
|
||||||
|
FilePath::Path(p) => p.to_string_lossy().to_string(),
|
||||||
|
FilePath::Url(u) => u.path().to_string(),
|
||||||
|
};
|
||||||
|
Ok(FileDialogResult {
|
||||||
|
path: Some(path_str),
|
||||||
|
cancelled: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
None => Ok(FileDialogResult {
|
||||||
|
path: None,
|
||||||
|
cancelled: true,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show open file dialog for media files (images, videos)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn show_open_media_dialog(app: AppHandle) -> Result<FileDialogResult, String> {
|
||||||
|
let file_path = app.dialog()
|
||||||
|
.file()
|
||||||
|
.add_filter("Images", &["png", "jpg", "jpeg", "gif", "bmp", "webp"])
|
||||||
|
.add_filter("Videos", &["mp4", "webm", "mov"])
|
||||||
|
.add_filter("All Files", &["*"])
|
||||||
|
.blocking_pick_file();
|
||||||
|
|
||||||
|
match file_path {
|
||||||
|
Some(path) => {
|
||||||
|
let path_str = match path {
|
||||||
|
FilePath::Path(p) => p.to_string_lossy().to_string(),
|
||||||
|
FilePath::Url(u) => u.path().to_string(),
|
||||||
|
};
|
||||||
|
Ok(FileDialogResult {
|
||||||
|
path: Some(path_str),
|
||||||
|
cancelled: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
None => Ok(FileDialogResult {
|
||||||
|
path: None,
|
||||||
|
cancelled: true,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a multi-file dialog operation
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct MultiFileDialogResult {
|
||||||
|
pub paths: Option<Vec<String>>,
|
||||||
|
pub cancelled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show open file dialog for images (multiple selection)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn show_open_images_dialog(app: AppHandle) -> Result<MultiFileDialogResult, String> {
|
||||||
|
let file_paths = app.dialog()
|
||||||
|
.file()
|
||||||
|
.add_filter("Images", &["png", "jpg", "jpeg", "gif", "bmp", "webp"])
|
||||||
|
.add_filter("All Files", &["*"])
|
||||||
|
.blocking_pick_files();
|
||||||
|
|
||||||
|
match file_paths {
|
||||||
|
Some(paths) => {
|
||||||
|
let path_strs: Vec<String> = paths.iter().map(|path| {
|
||||||
|
match path {
|
||||||
|
FilePath::Path(p) => p.to_string_lossy().to_string(),
|
||||||
|
FilePath::Url(u) => u.path().to_string(),
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
Ok(MultiFileDialogResult {
|
||||||
|
paths: Some(path_strs),
|
||||||
|
cancelled: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
None => Ok(MultiFileDialogResult {
|
||||||
|
paths: None,
|
||||||
|
cancelled: true,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get file extension from path
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_file_extension(path: String) -> Option<String> {
|
||||||
|
Path::new(&path)
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.map(|s| s.to_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if file exists
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn file_exists(path: String) -> bool {
|
||||||
|
Path::new(&path).exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get file name from path
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_file_name(path: String) -> Option<String> {
|
||||||
|
Path::new(&path)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
}
|
||||||
26
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Tauri Commands Module
|
||||||
|
// This module organizes all Tauri commands for the RefVroid application
|
||||||
|
|
||||||
|
pub mod file;
|
||||||
|
pub mod clipboard;
|
||||||
|
pub mod window;
|
||||||
|
|
||||||
|
// Re-export all commands for easy access
|
||||||
|
pub use file::{
|
||||||
|
save_scene, load_scene, read_file_as_base64,
|
||||||
|
show_save_dialog, show_open_dialog, show_open_media_dialog,
|
||||||
|
show_open_images_dialog,
|
||||||
|
get_file_extension, file_exists, get_file_name,
|
||||||
|
};
|
||||||
|
pub use clipboard::{
|
||||||
|
read_clipboard, write_clipboard_image, write_clipboard_image_base64,
|
||||||
|
write_clipboard_files, write_clipboard_text, write_clipboard_images_as_files,
|
||||||
|
read_image_as_base64,
|
||||||
|
};
|
||||||
|
pub use window::{
|
||||||
|
set_always_on_top, get_always_on_top, set_window_opacity,
|
||||||
|
lock_window, unlock_window, is_window_locked, is_maximized,
|
||||||
|
toggle_maximize, minimize_window, close_window,
|
||||||
|
show_window, hide_window, get_window_state, start_window_drag,
|
||||||
|
move_window_by,
|
||||||
|
};
|
||||||
312
src-tauri/src/commands/window.rs
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
// Window management commands for RefVroid
|
||||||
|
// Handles window state, opacity, and lock/unlock functionality
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||||
|
|
||||||
|
// Global state for window lock status
|
||||||
|
static IS_WINDOW_LOCKED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
// Global state for window opacity (stored as 0-255)
|
||||||
|
static WINDOW_OPACITY: AtomicU8 = AtomicU8::new(255);
|
||||||
|
|
||||||
|
// Flag to cancel ongoing opacity animation
|
||||||
|
static CANCEL_OPACITY_ANIMATION: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// Helper function to set window locked state (used by tray menu)
|
||||||
|
pub fn set_window_locked_state(locked: bool) {
|
||||||
|
IS_WINDOW_LOCKED.store(locked, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to get window locked state (used by tray menu)
|
||||||
|
pub fn is_window_locked_state() -> bool {
|
||||||
|
IS_WINDOW_LOCKED.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Window state information
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct WindowState {
|
||||||
|
pub is_always_on_top: bool,
|
||||||
|
pub is_locked: bool,
|
||||||
|
pub opacity: f64,
|
||||||
|
pub is_maximized: bool,
|
||||||
|
pub is_minimized: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set window always on top state
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_always_on_top(app: AppHandle, value: bool) -> Result<(), String> {
|
||||||
|
let window = app.get_webview_window("main")
|
||||||
|
.ok_or("Main window not found")?;
|
||||||
|
window.set_always_on_top(value)
|
||||||
|
.map_err(|e| format!("Failed to set always on top: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get window always on top state
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_always_on_top(app: AppHandle) -> Result<bool, String> {
|
||||||
|
let window = app.get_webview_window("main")
|
||||||
|
.ok_or("Main window not found")?;
|
||||||
|
window.is_always_on_top()
|
||||||
|
.map_err(|e| format!("Failed to get always on top state: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set window opacity (0.1 - 1.0)
|
||||||
|
/// Uses Windows API SetLayeredWindowAttributes for true window transparency
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_window_opacity(app: AppHandle, opacity: f64) -> Result<(), String> {
|
||||||
|
let window = app.get_webview_window("main")
|
||||||
|
.ok_or("Main window not found")?;
|
||||||
|
|
||||||
|
// Cancel any ongoing opacity animation
|
||||||
|
CANCEL_OPACITY_ANIMATION.store(true, Ordering::SeqCst);
|
||||||
|
|
||||||
|
// Clamp opacity between 0.1 and 1.0
|
||||||
|
let clamped_opacity = opacity.max(0.1).min(1.0);
|
||||||
|
let alpha = (clamped_opacity * 255.0) as u8;
|
||||||
|
|
||||||
|
// Store the opacity value for later use (e.g., after maximize/restore)
|
||||||
|
WINDOW_OPACITY.store(alpha, Ordering::SeqCst);
|
||||||
|
|
||||||
|
apply_window_opacity_internal(&window, alpha)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal function to apply opacity without storing
|
||||||
|
fn apply_window_opacity_internal(window: &tauri::WebviewWindow, alpha: u8) -> Result<(), String> {
|
||||||
|
// Use Windows API for true window transparency
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
|
SetLayeredWindowAttributes, SetWindowLongPtrW, GetWindowLongPtrW,
|
||||||
|
GWL_EXSTYLE, WS_EX_LAYERED, LWA_ALPHA,
|
||||||
|
};
|
||||||
|
use windows::Win32::Foundation::HWND;
|
||||||
|
|
||||||
|
let hwnd = window.hwnd()
|
||||||
|
.map_err(|e| format!("Failed to get window handle: {}", e))?;
|
||||||
|
let hwnd = HWND(hwnd.0 as *mut std::ffi::c_void);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
// Add WS_EX_LAYERED style if not already present
|
||||||
|
let ex_style = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
|
||||||
|
if ex_style & WS_EX_LAYERED.0 as isize == 0 {
|
||||||
|
SetWindowLongPtrW(hwnd, GWL_EXSTYLE, ex_style | WS_EX_LAYERED.0 as isize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set window alpha (0-255)
|
||||||
|
SetLayeredWindowAttributes(hwnd, None, alpha, LWA_ALPHA)
|
||||||
|
.map_err(|e| format!("Failed to set window opacity: {}", e))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
// Fallback for other platforms - use CSS opacity
|
||||||
|
let clamped_opacity = alpha as f64 / 255.0;
|
||||||
|
let js = format!(
|
||||||
|
"document.body.style.opacity = '{}';",
|
||||||
|
clamped_opacity
|
||||||
|
);
|
||||||
|
window.eval(&js)
|
||||||
|
.map_err(|e| format!("Failed to set window opacity: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lock window - disable mouse interactions
|
||||||
|
/// Window will ignore all cursor events when locked
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn lock_window(app: AppHandle) -> Result<(), String> {
|
||||||
|
let window = app.get_webview_window("main")
|
||||||
|
.ok_or("Main window not found")?;
|
||||||
|
|
||||||
|
// Set window to ignore cursor events
|
||||||
|
window.set_ignore_cursor_events(true)
|
||||||
|
.map_err(|e| format!("Failed to lock window: {}", e))?;
|
||||||
|
|
||||||
|
IS_WINDOW_LOCKED.store(true, Ordering::SeqCst);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unlock window - enable mouse interactions
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn unlock_window(app: AppHandle) -> Result<(), String> {
|
||||||
|
let window = app.get_webview_window("main")
|
||||||
|
.ok_or("Main window not found")?;
|
||||||
|
|
||||||
|
window.set_ignore_cursor_events(false)
|
||||||
|
.map_err(|e| format!("Failed to unlock window: {}", e))?;
|
||||||
|
|
||||||
|
IS_WINDOW_LOCKED.store(false, Ordering::SeqCst);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if window is locked
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn is_window_locked() -> bool {
|
||||||
|
IS_WINDOW_LOCKED.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if window is maximized
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn is_maximized(app: AppHandle) -> Result<bool, String> {
|
||||||
|
let window = app.get_webview_window("main")
|
||||||
|
.ok_or("Main window not found")?;
|
||||||
|
|
||||||
|
window.is_maximized()
|
||||||
|
.map_err(|e| format!("Failed to get maximized state: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle window maximized state
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn toggle_maximize(app: AppHandle) -> Result<bool, String> {
|
||||||
|
let window = app.get_webview_window("main")
|
||||||
|
.ok_or("Main window not found")?;
|
||||||
|
|
||||||
|
let is_maximized = window.is_maximized()
|
||||||
|
.map_err(|e| format!("Failed to get maximized state: {}", e))?;
|
||||||
|
|
||||||
|
// Get current opacity before state change
|
||||||
|
let target_alpha = WINDOW_OPACITY.load(Ordering::SeqCst);
|
||||||
|
|
||||||
|
if is_maximized {
|
||||||
|
window.unmaximize()
|
||||||
|
.map_err(|e| format!("Failed to unmaximize window: {}", e))?;
|
||||||
|
} else {
|
||||||
|
window.maximize()
|
||||||
|
.map_err(|e| format!("Failed to maximize window: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-apply opacity with smooth transition if not fully opaque
|
||||||
|
if target_alpha < 255 {
|
||||||
|
let window_clone = window.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
// Reset cancel flag before starting animation
|
||||||
|
CANCEL_OPACITY_ANIMATION.store(false, Ordering::SeqCst);
|
||||||
|
|
||||||
|
// Wait for window state to settle
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
|
||||||
|
// Check if cancelled during wait
|
||||||
|
if CANCEL_OPACITY_ANIMATION.load(Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate from fully opaque to target opacity
|
||||||
|
// 180 steps * 16ms = ~3 seconds total duration
|
||||||
|
let steps = 30;
|
||||||
|
let step_delay = std::time::Duration::from_millis(16); // ~60fps
|
||||||
|
|
||||||
|
for i in 0..=steps {
|
||||||
|
// Check if animation should be cancelled
|
||||||
|
if CANCEL_OPACITY_ANIMATION.load(Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let progress = i as f64 / steps as f64;
|
||||||
|
// Linear: constant speed
|
||||||
|
let current = 255.0 - (255.0 - target_alpha as f64) * progress;
|
||||||
|
let _ = apply_window_opacity_internal(&window_clone, current as u8);
|
||||||
|
if i < steps {
|
||||||
|
std::thread::sleep(step_delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(!is_maximized)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimize window
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn minimize_window(app: AppHandle) -> Result<(), String> {
|
||||||
|
let window = app.get_webview_window("main")
|
||||||
|
.ok_or("Main window not found")?;
|
||||||
|
|
||||||
|
window.minimize()
|
||||||
|
.map_err(|e| format!("Failed to minimize window: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close window
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn close_window(app: AppHandle) -> Result<(), String> {
|
||||||
|
let window = app.get_webview_window("main")
|
||||||
|
.ok_or("Main window not found")?;
|
||||||
|
|
||||||
|
window.close()
|
||||||
|
.map_err(|e| format!("Failed to close window: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show window (bring to front)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn show_window(app: AppHandle) -> Result<(), String> {
|
||||||
|
let window = app.get_webview_window("main")
|
||||||
|
.ok_or("Main window not found")?;
|
||||||
|
|
||||||
|
window.show()
|
||||||
|
.map_err(|e| format!("Failed to show window: {}", e))?;
|
||||||
|
window.set_focus()
|
||||||
|
.map_err(|e| format!("Failed to focus window: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hide window
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn hide_window(app: AppHandle) -> Result<(), String> {
|
||||||
|
let window = app.get_webview_window("main")
|
||||||
|
.ok_or("Main window not found")?;
|
||||||
|
|
||||||
|
window.hide()
|
||||||
|
.map_err(|e| format!("Failed to hide window: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current window state
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_window_state(app: AppHandle) -> Result<WindowState, String> {
|
||||||
|
let window = app.get_webview_window("main")
|
||||||
|
.ok_or("Main window not found")?;
|
||||||
|
|
||||||
|
let is_always_on_top = window.is_always_on_top()
|
||||||
|
.map_err(|e| format!("Failed to get always on top state: {}", e))?;
|
||||||
|
let is_maximized = window.is_maximized()
|
||||||
|
.map_err(|e| format!("Failed to get maximized state: {}", e))?;
|
||||||
|
let is_minimized = window.is_minimized()
|
||||||
|
.map_err(|e| format!("Failed to get minimized state: {}", e))?;
|
||||||
|
|
||||||
|
Ok(WindowState {
|
||||||
|
is_always_on_top,
|
||||||
|
is_locked: IS_WINDOW_LOCKED.load(Ordering::SeqCst),
|
||||||
|
opacity: 1.0, // Default, actual value tracked in frontend
|
||||||
|
is_maximized,
|
||||||
|
is_minimized,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start window drag (for frameless window)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn start_window_drag(app: AppHandle) -> Result<(), String> {
|
||||||
|
let window = app.get_webview_window("main")
|
||||||
|
.ok_or("Main window not found")?;
|
||||||
|
|
||||||
|
window.start_dragging()
|
||||||
|
.map_err(|e| format!("Failed to start window drag: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move window by delta (for custom drag implementation)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn move_window_by(app: AppHandle, delta_x: i32, delta_y: i32) -> Result<(), String> {
|
||||||
|
let window = app.get_webview_window("main")
|
||||||
|
.ok_or("Main window not found")?;
|
||||||
|
|
||||||
|
let position = window.outer_position()
|
||||||
|
.map_err(|e| format!("Failed to get window position: {}", e))?;
|
||||||
|
|
||||||
|
let new_x = position.x + delta_x;
|
||||||
|
let new_y = position.y + delta_y;
|
||||||
|
|
||||||
|
window.set_position(tauri::Position::Physical(tauri::PhysicalPosition::new(new_x, new_y)))
|
||||||
|
.map_err(|e| format!("Failed to move window: {}", e))
|
||||||
|
}
|
||||||
166
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
// RefVroid - Animation Reference Desktop App
|
||||||
|
// Tauri backend implementation
|
||||||
|
|
||||||
|
mod commands;
|
||||||
|
|
||||||
|
use commands::{
|
||||||
|
// File operations
|
||||||
|
save_scene,
|
||||||
|
load_scene,
|
||||||
|
read_file_as_base64,
|
||||||
|
show_save_dialog,
|
||||||
|
show_open_dialog,
|
||||||
|
show_open_media_dialog,
|
||||||
|
show_open_images_dialog,
|
||||||
|
get_file_extension,
|
||||||
|
file_exists,
|
||||||
|
get_file_name,
|
||||||
|
// Clipboard operations
|
||||||
|
read_clipboard,
|
||||||
|
write_clipboard_image,
|
||||||
|
write_clipboard_image_base64,
|
||||||
|
write_clipboard_files,
|
||||||
|
write_clipboard_text,
|
||||||
|
write_clipboard_images_as_files,
|
||||||
|
read_image_as_base64,
|
||||||
|
// Window operations
|
||||||
|
set_always_on_top,
|
||||||
|
get_always_on_top,
|
||||||
|
set_window_opacity,
|
||||||
|
lock_window,
|
||||||
|
unlock_window,
|
||||||
|
is_window_locked,
|
||||||
|
is_maximized,
|
||||||
|
toggle_maximize,
|
||||||
|
minimize_window,
|
||||||
|
close_window,
|
||||||
|
show_window,
|
||||||
|
hide_window,
|
||||||
|
get_window_state,
|
||||||
|
start_window_drag,
|
||||||
|
move_window_by,
|
||||||
|
};
|
||||||
|
|
||||||
|
use tauri::{
|
||||||
|
Emitter, Manager,
|
||||||
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||||
|
menu::{Menu, MenuItem},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn setup_tray(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Create menu items
|
||||||
|
let show_item = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?;
|
||||||
|
let unlock_item = MenuItem::with_id(app, "unlock", "解锁窗口", true, None::<&str>)?;
|
||||||
|
let quit_item = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
|
||||||
|
|
||||||
|
// Create tray menu
|
||||||
|
let menu = Menu::with_items(app, &[&show_item, &unlock_item, &quit_item])?;
|
||||||
|
|
||||||
|
// Build tray icon
|
||||||
|
let _tray = TrayIconBuilder::new()
|
||||||
|
.icon(app.default_window_icon().unwrap().clone())
|
||||||
|
.menu(&menu)
|
||||||
|
.show_menu_on_left_click(false) // Don't show menu on left click
|
||||||
|
.tooltip("RefVroid - 动画参考工具")
|
||||||
|
.on_menu_event(|app, event| {
|
||||||
|
match event.id.as_ref() {
|
||||||
|
"show" => {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"unlock" => {
|
||||||
|
// Only unlock if currently locked
|
||||||
|
if commands::window::is_window_locked_state() {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.set_ignore_cursor_events(false);
|
||||||
|
// Update the global lock state
|
||||||
|
commands::window::set_window_locked_state(false);
|
||||||
|
// Emit event to frontend to update UI
|
||||||
|
let _ = window.emit("window-unlocked", ());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"quit" => {
|
||||||
|
app.exit(0);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_tray_icon_event(|tray, event| {
|
||||||
|
if let TrayIconEvent::Click {
|
||||||
|
button: MouseButton::Left,
|
||||||
|
button_state: MouseButtonState::Up,
|
||||||
|
..
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
// Toggle window visibility on left click
|
||||||
|
let app = tray.app_handle();
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
if window.is_visible().unwrap_or(false) {
|
||||||
|
let _ = window.hide();
|
||||||
|
} else {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(app)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_clipboard_manager::init())
|
||||||
|
.plugin(tauri_plugin_fs::init())
|
||||||
|
.setup(|app| {
|
||||||
|
// Setup system tray
|
||||||
|
setup_tray(app)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
// File operations
|
||||||
|
save_scene,
|
||||||
|
load_scene,
|
||||||
|
read_file_as_base64,
|
||||||
|
show_save_dialog,
|
||||||
|
show_open_dialog,
|
||||||
|
show_open_media_dialog,
|
||||||
|
show_open_images_dialog,
|
||||||
|
get_file_extension,
|
||||||
|
file_exists,
|
||||||
|
get_file_name,
|
||||||
|
// Clipboard operations
|
||||||
|
read_clipboard,
|
||||||
|
write_clipboard_image,
|
||||||
|
write_clipboard_image_base64,
|
||||||
|
write_clipboard_files,
|
||||||
|
write_clipboard_text,
|
||||||
|
write_clipboard_images_as_files,
|
||||||
|
read_image_as_base64,
|
||||||
|
// Window operations
|
||||||
|
set_always_on_top,
|
||||||
|
get_always_on_top,
|
||||||
|
set_window_opacity,
|
||||||
|
lock_window,
|
||||||
|
unlock_window,
|
||||||
|
is_window_locked,
|
||||||
|
is_maximized,
|
||||||
|
toggle_maximize,
|
||||||
|
minimize_window,
|
||||||
|
close_window,
|
||||||
|
show_window,
|
||||||
|
hide_window,
|
||||||
|
get_window_state,
|
||||||
|
start_window_drag,
|
||||||
|
move_window_by,
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
refvroid_lib::run()
|
||||||
|
}
|
||||||
42
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "RefVroid",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.refvroid.app",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"beforeBuildCommand": "pnpm build",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "RefVroid",
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"resizable": true,
|
||||||
|
"decorations": false,
|
||||||
|
"transparent": false,
|
||||||
|
"alwaysOnTop": false,
|
||||||
|
"dragDropEnabled": true,
|
||||||
|
"backgroundColor": [25, 25, 25, 255]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": ["msi"],
|
||||||
|
"icon": [
|
||||||
|
"icons/icon.png",
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
1250
src/App.vue
Normal file
1
src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
1513
src/components/CanvasView.vue
Normal file
110
src/components/ConfirmDialog.vue
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* ConfirmDialog Component
|
||||||
|
* Generic confirmation dialog
|
||||||
|
*/
|
||||||
|
defineProps<{
|
||||||
|
visible: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'confirm'): void;
|
||||||
|
(e: 'cancel'): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible" class="dialog-overlay" @click.self="emit('cancel')">
|
||||||
|
<div class="dialog">
|
||||||
|
<h2 class="dialog__title">{{ title }}</h2>
|
||||||
|
<p class="dialog__message">{{ message }}</p>
|
||||||
|
|
||||||
|
<div class="dialog__buttons">
|
||||||
|
<button class="dialog__btn dialog__btn--confirm" @click="emit('confirm')">
|
||||||
|
{{ confirmText || '确认' }}
|
||||||
|
</button>
|
||||||
|
<button class="dialog__btn dialog__btn--cancel" @click="emit('cancel')">
|
||||||
|
{{ cancelText || '取消' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
min-width: 360px;
|
||||||
|
max-width: 450px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog__title {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog__message {
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog__buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog__btn {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog__btn--confirm {
|
||||||
|
background: #2a9fd6;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog__btn--confirm:hover {
|
||||||
|
background: #3ab0e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog__btn--cancel {
|
||||||
|
background: #444;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog__btn--cancel:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
368
src/components/ContextMenu.vue
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* ContextMenu Component
|
||||||
|
* Right-click context menu with submenu support
|
||||||
|
*/
|
||||||
|
import { ref, computed, watch, onUnmounted } from 'vue';
|
||||||
|
import type { MenuItem } from '../types';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Props & Emits
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
items: MenuItem[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
(e: 'action', action: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// State
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const menuRef = ref<HTMLDivElement | null>(null);
|
||||||
|
const submenuRef = ref<HTMLDivElement | null>(null);
|
||||||
|
const activeSubmenu = ref<string | null>(null);
|
||||||
|
const submenuStyle = ref<Record<string, string>>({});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Computed
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Adjusted position after boundary check
|
||||||
|
const adjustedPosition = ref({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
const menuStyle = computed(() => {
|
||||||
|
if (!props.visible) return {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: `${adjustedPosition.value.x}px`,
|
||||||
|
top: `${adjustedPosition.value.y}px`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adjust menu position to stay within viewport
|
||||||
|
function adjustMenuPosition() {
|
||||||
|
if (!menuRef.value) return;
|
||||||
|
|
||||||
|
const menuRect = menuRef.value.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
let x = props.x;
|
||||||
|
let y = props.y;
|
||||||
|
|
||||||
|
// Adjust horizontal position
|
||||||
|
if (x + menuRect.width > viewportWidth) {
|
||||||
|
x = viewportWidth - menuRect.width - 8;
|
||||||
|
}
|
||||||
|
if (x < 8) x = 8;
|
||||||
|
|
||||||
|
// Adjust vertical position
|
||||||
|
if (y + menuRect.height > viewportHeight) {
|
||||||
|
y = viewportHeight - menuRect.height - 8;
|
||||||
|
}
|
||||||
|
if (y < 8) y = 8;
|
||||||
|
|
||||||
|
adjustedPosition.value = { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Methods
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
function handleItemClick(item: MenuItem) {
|
||||||
|
if (item.separator || item.disabled) return;
|
||||||
|
|
||||||
|
if (item.submenu && item.submenu.length > 0) {
|
||||||
|
// Toggle submenu
|
||||||
|
if (activeSubmenu.value === item.label) {
|
||||||
|
activeSubmenu.value = null;
|
||||||
|
} else {
|
||||||
|
activeSubmenu.value = item.label;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute action
|
||||||
|
item.action();
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmenuItemClick(item: MenuItem) {
|
||||||
|
if (item.separator || item.disabled) return;
|
||||||
|
|
||||||
|
item.action();
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseEnter(item: MenuItem, event: MouseEvent) {
|
||||||
|
if (item.submenu && item.submenu.length > 0) {
|
||||||
|
activeSubmenu.value = item.label;
|
||||||
|
// Calculate submenu position based on the hovered item
|
||||||
|
const target = event.currentTarget as HTMLElement;
|
||||||
|
if (target && menuRef.value) {
|
||||||
|
const itemRect = target.getBoundingClientRect();
|
||||||
|
const menuRect = menuRef.value.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
// Estimate submenu width based on content (with shortcuts it can be wider)
|
||||||
|
const submenuWidth = 200;
|
||||||
|
const submenuEstimatedHeight = (item.submenu?.length || 1) * 32 + 8;
|
||||||
|
|
||||||
|
const style: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Horizontal positioning - check if submenu fits on the right
|
||||||
|
if (menuRect.right + submenuWidth + 8 > viewportWidth) {
|
||||||
|
// Show on left side
|
||||||
|
style.right = '100%';
|
||||||
|
style.left = 'auto';
|
||||||
|
style.marginRight = '4px';
|
||||||
|
style.marginLeft = '0';
|
||||||
|
} else {
|
||||||
|
// Show on right side (default)
|
||||||
|
style.left = '100%';
|
||||||
|
style.right = 'auto';
|
||||||
|
style.marginLeft = '4px';
|
||||||
|
style.marginRight = '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertical positioning - submenu is positioned relative to the menu item
|
||||||
|
// So we use top: 0 to align with the item, but adjust if it would overflow
|
||||||
|
if (itemRect.top + submenuEstimatedHeight > viewportHeight) {
|
||||||
|
// Would overflow bottom, align to bottom of item
|
||||||
|
style.bottom = '0';
|
||||||
|
style.top = 'auto';
|
||||||
|
} else {
|
||||||
|
// Align to top of item (default)
|
||||||
|
style.top = '0';
|
||||||
|
style.bottom = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
submenuStyle.value = style;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
activeSubmenu.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (menuRef.value && !menuRef.value.contains(event.target as Node)) {
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWindowResize() {
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Lifecycle
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
watch(() => props.visible, (visible) => {
|
||||||
|
if (visible) {
|
||||||
|
// Set initial position
|
||||||
|
adjustedPosition.value = { x: props.x, y: props.y };
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
window.addEventListener('resize', handleWindowResize);
|
||||||
|
// Adjust position after DOM update
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
adjustMenuPosition();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
window.removeEventListener('resize', handleWindowResize);
|
||||||
|
activeSubmenu.value = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
window.removeEventListener('resize', handleWindowResize);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
ref="menuRef"
|
||||||
|
class="context-menu"
|
||||||
|
:style="menuStyle"
|
||||||
|
>
|
||||||
|
<div class="context-menu__list">
|
||||||
|
<template v-for="(item, index) in items" :key="index">
|
||||||
|
<!-- Separator -->
|
||||||
|
<div v-if="item.separator" class="context-menu__separator" />
|
||||||
|
|
||||||
|
<!-- Menu Item -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="context-menu__item"
|
||||||
|
:class="{
|
||||||
|
'context-menu__item--checked': item.checked,
|
||||||
|
'context-menu__item--has-submenu': item.submenu && item.submenu.length > 0,
|
||||||
|
'context-menu__item--active': activeSubmenu === item.label,
|
||||||
|
'context-menu__item--disabled': item.disabled,
|
||||||
|
}"
|
||||||
|
@click="handleItemClick(item)"
|
||||||
|
@mouseenter="handleMouseEnter(item, $event)"
|
||||||
|
>
|
||||||
|
<!-- Check mark -->
|
||||||
|
<span class="context-menu__check">
|
||||||
|
{{ item.checked ? '✓' : '' }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Label -->
|
||||||
|
<span class="context-menu__label">{{ item.label }}</span>
|
||||||
|
|
||||||
|
<!-- Shortcut -->
|
||||||
|
<span v-if="item.shortcut && !item.submenu" class="context-menu__shortcut">
|
||||||
|
{{ item.shortcut }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Submenu arrow -->
|
||||||
|
<span v-if="item.submenu && item.submenu.length > 0" class="context-menu__arrow">
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Submenu -->
|
||||||
|
<div
|
||||||
|
v-if="item.submenu && item.submenu.length > 0 && activeSubmenu === item.label"
|
||||||
|
class="context-menu__submenu"
|
||||||
|
:style="submenuStyle"
|
||||||
|
ref="submenuRef"
|
||||||
|
>
|
||||||
|
<template v-for="(subItem, subIndex) in item.submenu" :key="subIndex">
|
||||||
|
<div v-if="subItem.separator" class="context-menu__separator" />
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="context-menu__item"
|
||||||
|
:class="{
|
||||||
|
'context-menu__item--checked': subItem.checked,
|
||||||
|
'context-menu__item--disabled': subItem.disabled,
|
||||||
|
}"
|
||||||
|
@click.stop="handleSubmenuItemClick(subItem)"
|
||||||
|
>
|
||||||
|
<span class="context-menu__check">
|
||||||
|
{{ subItem.checked ? '✓' : '' }}
|
||||||
|
</span>
|
||||||
|
<span class="context-menu__label">{{ subItem.label }}</span>
|
||||||
|
<span v-if="subItem.shortcut" class="context-menu__shortcut">
|
||||||
|
{{ subItem.shortcut }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.context-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10000;
|
||||||
|
min-width: 160px;
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: background-color 0.1s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__item:hover,
|
||||||
|
.context-menu__item--active {
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__item--disabled {
|
||||||
|
color: #666;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__item--disabled:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__item--disabled .context-menu__shortcut {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__check {
|
||||||
|
width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #00c8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__label {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__shortcut {
|
||||||
|
margin-left: 24px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__arrow {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__separator {
|
||||||
|
height: 1px;
|
||||||
|
background: #444;
|
||||||
|
margin: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu__submenu {
|
||||||
|
position: absolute;
|
||||||
|
min-width: 180px;
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
padding: 4px 0;
|
||||||
|
margin-left: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
311
src/components/HelpDialog.vue
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* HelpDialog Component
|
||||||
|
* Displays keyboard shortcuts and help information
|
||||||
|
*/
|
||||||
|
import { computed, watch, onUnmounted } from 'vue';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Props & Emits
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Shortcut Categories
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface ShortcutCategory {
|
||||||
|
name: string;
|
||||||
|
shortcuts: Array<{
|
||||||
|
keys: string;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortcutCategories = computed<ShortcutCategory[]>(() => {
|
||||||
|
const categories: ShortcutCategory[] = [
|
||||||
|
{
|
||||||
|
name: '文件操作',
|
||||||
|
shortcuts: [
|
||||||
|
{ keys: 'Ctrl+S', description: '保存场景' },
|
||||||
|
{ keys: 'Ctrl+L', description: '加载场景' },
|
||||||
|
{ keys: 'Ctrl+N', description: '新建场景' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '编辑操作',
|
||||||
|
shortcuts: [
|
||||||
|
{ keys: 'Ctrl+Z', description: '撤销' },
|
||||||
|
{ keys: 'Ctrl+Y / Ctrl+Shift+Z', description: '重做' },
|
||||||
|
{ keys: 'Ctrl+C', description: '复制' },
|
||||||
|
{ keys: 'Ctrl+V', description: '粘贴' },
|
||||||
|
{ keys: 'Ctrl+X', description: '剪切' },
|
||||||
|
{ keys: 'Ctrl+A', description: '全选' },
|
||||||
|
{ keys: 'Delete / Backspace', description: '删除选中元素' },
|
||||||
|
{ keys: 'Escape', description: '取消选择' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '视图操作',
|
||||||
|
shortcuts: [
|
||||||
|
{ keys: '鼠标滚轮', description: '缩放画布' },
|
||||||
|
{ keys: '空格 + 拖动', description: '平移画布' },
|
||||||
|
{ keys: 'Ctrl+空格 + 拖动', description: '拖动缩放' },
|
||||||
|
{ keys: '鼠标中键拖动', description: '平移画布' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '窗口管理',
|
||||||
|
shortcuts: [
|
||||||
|
{ keys: 'Ctrl+F', description: '切换最大化' },
|
||||||
|
{ keys: 'Ctrl+M', description: '最小化窗口' },
|
||||||
|
{ keys: 'Ctrl+W', description: '关闭窗口' },
|
||||||
|
{ keys: 'Ctrl+H', description: '显示帮助' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '元素操作',
|
||||||
|
shortcuts: [
|
||||||
|
{ keys: '左键点击', description: '选择元素' },
|
||||||
|
{ keys: 'Shift + 左键点击', description: '多选元素' },
|
||||||
|
{ keys: '左键拖动', description: '移动元素' },
|
||||||
|
{ keys: '拖动边缘', description: '调整大小' },
|
||||||
|
{ keys: '右键点击', description: '打开菜单' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return categories;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Event Handlers
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdropClick(event: MouseEvent) {
|
||||||
|
if ((event.target as HTMLElement).classList.contains('help-dialog__backdrop')) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Lifecycle
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
watch(() => props.visible, (visible) => {
|
||||||
|
if (visible) {
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="fade">
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="help-dialog__backdrop"
|
||||||
|
@click="handleBackdropClick"
|
||||||
|
>
|
||||||
|
<div class="help-dialog">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="help-dialog__header">
|
||||||
|
<h2 class="help-dialog__title">快捷键帮助</h2>
|
||||||
|
<button class="help-dialog__close" @click="handleClose">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="help-dialog__content">
|
||||||
|
<div
|
||||||
|
v-for="category in shortcutCategories"
|
||||||
|
:key="category.name"
|
||||||
|
class="help-dialog__category"
|
||||||
|
>
|
||||||
|
<h3 class="help-dialog__category-title">{{ category.name }}</h3>
|
||||||
|
<div class="help-dialog__shortcuts">
|
||||||
|
<div
|
||||||
|
v-for="shortcut in category.shortcuts"
|
||||||
|
:key="shortcut.keys"
|
||||||
|
class="help-dialog__shortcut"
|
||||||
|
>
|
||||||
|
<span class="help-dialog__keys">{{ shortcut.keys }}</span>
|
||||||
|
<span class="help-dialog__description">{{ shortcut.description }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="help-dialog__footer">
|
||||||
|
<span class="help-dialog__version">RefVroid WebGPU v1.0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.help-dialog__backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-dialog {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 80vh;
|
||||||
|
width: 90%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-dialog__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-dialog__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-dialog__close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-dialog__close:hover {
|
||||||
|
background: #3a3a3a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-dialog__content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-dialog__category {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-dialog__category:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-dialog__category-title {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #00c8ff;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-dialog__shortcuts {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-dialog__shortcut {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-dialog__keys {
|
||||||
|
min-width: 180px;
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-dialog__description {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-dialog__footer {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-top: 1px solid #444;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-dialog__version {
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-active .help-dialog,
|
||||||
|
.fade-leave-active .help-dialog {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from .help-dialog,
|
||||||
|
.fade-leave-to .help-dialog {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
84
src/components/LockIndicator.vue
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* LockIndicator Component
|
||||||
|
* Displays lock status indicator when window is locked
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Props
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="visible" class="lock-indicator">
|
||||||
|
<div class="lock-indicator__icon">
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="lock-indicator__text">窗口已锁定</div>
|
||||||
|
<div class="lock-indicator__hint">通过系统托盘解锁</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lock-indicator {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
border: 1px solid #ff6b6b;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 10001;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-indicator__icon {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-indicator__text {
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-indicator__hint {
|
||||||
|
color: #888;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
66
src/components/SelectionBox.vue
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* SelectionBox Component
|
||||||
|
* Renders the selection rectangle during drag selection
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Props
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
endX: number;
|
||||||
|
endY: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Computed Styles
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const boxStyle = computed(() => {
|
||||||
|
if (!props.visible) return {};
|
||||||
|
|
||||||
|
// Calculate normalized rectangle (handle negative dimensions)
|
||||||
|
const x = Math.min(props.startX, props.endX);
|
||||||
|
const y = Math.min(props.startY, props.endY);
|
||||||
|
const width = Math.abs(props.endX - props.startX);
|
||||||
|
const height = Math.abs(props.endY - props.startY);
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: `${x}px`,
|
||||||
|
top: `${y}px`,
|
||||||
|
width: `${width}px`,
|
||||||
|
height: `${height}px`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="selection-box"
|
||||||
|
:style="boxStyle"
|
||||||
|
>
|
||||||
|
<div class="selection-box__border"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.selection-box {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-box__border {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 1px dashed #00c8ff;
|
||||||
|
background-color: rgba(0, 200, 255, 0.1);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
256
src/components/TitleBar.vue
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* TitleBar Component
|
||||||
|
* Shows when mouse hovers near the top of the window
|
||||||
|
*/
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
title?: string;
|
||||||
|
alwaysOnTop?: boolean;
|
||||||
|
isDirty?: boolean;
|
||||||
|
hasContent?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'minimize'): void;
|
||||||
|
(e: 'maximize'): void;
|
||||||
|
(e: 'close'): void;
|
||||||
|
(e: 'toggle-pin'): void;
|
||||||
|
(e: 'start-drag'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isVisible = ref(false);
|
||||||
|
const isDragging = ref(false);
|
||||||
|
const isMouseInTitleBar = ref(false);
|
||||||
|
|
||||||
|
// Show title bar when mouse is near top of window
|
||||||
|
const TRIGGER_ZONE = 30; // pixels from top to trigger
|
||||||
|
const TITLE_BAR_HEIGHT = 32; // height of title bar
|
||||||
|
const HIDE_DELAY = 300; // ms delay before hiding
|
||||||
|
|
||||||
|
let hideTimeout: number | null = null;
|
||||||
|
|
||||||
|
function handleMouseMove(e: MouseEvent) {
|
||||||
|
// Show if mouse is in trigger zone
|
||||||
|
if (e.clientY <= TRIGGER_ZONE) {
|
||||||
|
showTitleBar();
|
||||||
|
} else if (isVisible.value && !isMouseInTitleBar.value && !isDragging.value) {
|
||||||
|
// Hide if mouse moved away from both trigger zone and title bar
|
||||||
|
if (e.clientY > TITLE_BAR_HEIGHT) {
|
||||||
|
scheduleHide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTitleBar() {
|
||||||
|
cancelHide();
|
||||||
|
isVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleHide() {
|
||||||
|
// Don't hide while dragging
|
||||||
|
if (isDragging.value) return;
|
||||||
|
|
||||||
|
if (hideTimeout === null) {
|
||||||
|
hideTimeout = window.setTimeout(() => {
|
||||||
|
if (!isMouseInTitleBar.value && !isDragging.value) {
|
||||||
|
isVisible.value = false;
|
||||||
|
}
|
||||||
|
hideTimeout = null;
|
||||||
|
}, HIDE_DELAY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelHide() {
|
||||||
|
if (hideTimeout !== null) {
|
||||||
|
clearTimeout(hideTimeout);
|
||||||
|
hideTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTitleBarMouseEnter() {
|
||||||
|
isMouseInTitleBar.value = true;
|
||||||
|
cancelHide();
|
||||||
|
isVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTitleBarMouseLeave() {
|
||||||
|
isMouseInTitleBar.value = false;
|
||||||
|
// Don't hide while dragging
|
||||||
|
if (!isDragging.value) {
|
||||||
|
scheduleHide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDoubleClick() {
|
||||||
|
emit('maximize');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDragStart(e: MouseEvent) {
|
||||||
|
if (e.button === 0) {
|
||||||
|
isDragging.value = true;
|
||||||
|
emit('start-drag');
|
||||||
|
|
||||||
|
// Listen for mouseup to end dragging
|
||||||
|
const handleMouseUp = (upEvent: MouseEvent) => {
|
||||||
|
isDragging.value = false;
|
||||||
|
window.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
// Check if mouse is outside title bar area after drag ends
|
||||||
|
if (upEvent.clientY > TITLE_BAR_HEIGHT) {
|
||||||
|
isMouseInTitleBar.value = false;
|
||||||
|
scheduleHide();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('mouseup', handleMouseUp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('mousemove', handleMouseMove);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
if (hideTimeout) {
|
||||||
|
clearTimeout(hideTimeout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="titleBarRef"
|
||||||
|
class="title-bar"
|
||||||
|
:class="{ 'title-bar--visible': isVisible }"
|
||||||
|
@mouseenter="handleTitleBarMouseEnter"
|
||||||
|
@mouseleave="handleTitleBarMouseLeave"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="title-bar__drag-area"
|
||||||
|
@dblclick="handleDoubleClick"
|
||||||
|
@mousedown="handleDragStart"
|
||||||
|
>
|
||||||
|
<span class="title-bar__title">
|
||||||
|
<template v-if="hasContent">{{ isDirty ? '*' : '' }}{{ title || 'Untitled' }}</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="title-bar__buttons">
|
||||||
|
<!-- Pin icon: mdi:pin -->
|
||||||
|
<button
|
||||||
|
class="title-bar__btn title-bar__btn--pin"
|
||||||
|
:class="{ 'title-bar__btn--active': alwaysOnTop }"
|
||||||
|
@click="emit('toggle-pin')"
|
||||||
|
title="置顶"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor" d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<!-- Minimize icon: mdi:minus -->
|
||||||
|
<button
|
||||||
|
class="title-bar__btn title-bar__btn--minimize"
|
||||||
|
@click="emit('minimize')"
|
||||||
|
title="最小化"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor" d="M19 13H5v-2h14z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<!-- Maximize icon: mdi:checkbox-blank-outline -->
|
||||||
|
<button
|
||||||
|
class="title-bar__btn title-bar__btn--maximize"
|
||||||
|
@click="emit('maximize')"
|
||||||
|
title="最大化"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor" d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2m0 16H5V5h14z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<!-- Close icon: mdi:close -->
|
||||||
|
<button
|
||||||
|
class="title-bar__btn title-bar__btn--close"
|
||||||
|
@click="emit('close')"
|
||||||
|
title="关闭"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor" d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.title-bar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 32px;
|
||||||
|
background: rgba(30, 30, 30, 0.95);
|
||||||
|
border-bottom: 1px solid #000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
z-index: 9999;
|
||||||
|
transform: translateY(-100%);
|
||||||
|
transition: transform 0.2s ease-out;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar--visible {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar__drag-area {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-left: 12px;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar__title {
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 13px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar__buttons {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar__btn {
|
||||||
|
width: 46px;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar__btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar__btn--pin.title-bar__btn--active {
|
||||||
|
color: #00c8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar__btn--close:hover {
|
||||||
|
background: #e81123;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
194
src/components/UnsavedDialog.vue
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* UnsavedDialog Component
|
||||||
|
* Shows warning when user tries to close with unsaved changes
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'save'): void;
|
||||||
|
(e: 'discard'): void;
|
||||||
|
(e: 'cancel'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const rememberChoice = ref(false);
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
if (rememberChoice.value) {
|
||||||
|
localStorage.setItem('refvroid_unsaved_action', 'save');
|
||||||
|
}
|
||||||
|
emit('save');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDiscard() {
|
||||||
|
if (rememberChoice.value) {
|
||||||
|
localStorage.setItem('refvroid_unsaved_action', 'discard');
|
||||||
|
}
|
||||||
|
emit('discard');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
emit('cancel');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible" class="dialog-overlay" @click.self="handleCancel">
|
||||||
|
<div class="dialog">
|
||||||
|
<button class="dialog__close" @click="handleCancel">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor" d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h2 class="dialog__title">未保存的更改</h2>
|
||||||
|
|
||||||
|
<p class="dialog__message">
|
||||||
|
当前场景有未保存的更改。是否在退出前保存?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="dialog__remember">
|
||||||
|
<input type="checkbox" v-model="rememberChoice" />
|
||||||
|
<span>记住我的选择</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="dialog__buttons">
|
||||||
|
<button class="dialog__btn dialog__btn--save" @click="handleSave">
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
<button class="dialog__btn dialog__btn--discard" @click="handleDiscard">
|
||||||
|
不保存
|
||||||
|
</button>
|
||||||
|
<button class="dialog__btn dialog__btn--cancel" @click="handleCancel">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
min-width: 400px;
|
||||||
|
max-width: 500px;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog__close {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog__close:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog__title {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog__message {
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog__remember {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog__remember input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog__buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog__btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog__btn:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog__btn--save {
|
||||||
|
background: #2a9fd6;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog__btn--save:hover {
|
||||||
|
background: #3ab0e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog__btn--discard {
|
||||||
|
background: #d64541;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog__btn--discard:hover {
|
||||||
|
background: #e65550;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog__btn--cancel {
|
||||||
|
background: #444;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog__btn--cancel:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
105
src/components/WelcomeOverlay.vue
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* WelcomeOverlay Component
|
||||||
|
* Shows welcome screen when scene is empty
|
||||||
|
*/
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'browse'): void;
|
||||||
|
(e: 'help'): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="welcome-overlay">
|
||||||
|
<div class="welcome-content">
|
||||||
|
<!-- Drop zone icon -->
|
||||||
|
<div class="welcome-icon">
|
||||||
|
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Dashed border -->
|
||||||
|
<rect x="20" y="20" width="80" height="80" rx="4" stroke="#555" stroke-width="2" stroke-dasharray="8 4" fill="none"/>
|
||||||
|
<!-- Image icon -->
|
||||||
|
<circle cx="45" cy="45" r="8" fill="#666"/>
|
||||||
|
<path d="M30 75 L50 55 L65 70 L75 60 L90 75 L90 85 L30 85 Z" fill="#666"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="welcome-text">拖放图片到这里</p>
|
||||||
|
<p class="welcome-or">或</p>
|
||||||
|
|
||||||
|
<button class="welcome-browse" @click="emit('browse')">
|
||||||
|
浏览文件
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a class="welcome-help" @click.prevent="emit('help')">
|
||||||
|
帮助
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.welcome-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-text {
|
||||||
|
color: #888;
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-or {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-browse {
|
||||||
|
background: #2a9fd6;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-browse:hover {
|
||||||
|
background: #3ab0e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-help {
|
||||||
|
color: #888;
|
||||||
|
font-size: 13px;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-help:hover {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
404
src/core/history/index.ts
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
/**
|
||||||
|
* History Manager
|
||||||
|
* Manages undo/redo functionality with state snapshots
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, computed, type Ref, type ComputedRef } from 'vue';
|
||||||
|
import type { MediaElement as IMediaElement, ViewTransform } from '../../types';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Constants
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const DEFAULT_MAX_HISTORY_SIZE = 50;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// History State Interface
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a snapshot of the scene state for history tracking
|
||||||
|
*/
|
||||||
|
export interface HistorySnapshot {
|
||||||
|
/** Serialized elements at this point in time */
|
||||||
|
elements: IMediaElement[];
|
||||||
|
/** View transform state */
|
||||||
|
viewTransform: ViewTransform;
|
||||||
|
/** Timestamp when snapshot was created */
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// History Manager Class
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export class HistoryManager {
|
||||||
|
/** Stack of states for undo operations */
|
||||||
|
private _undoStack: HistorySnapshot[] = [];
|
||||||
|
|
||||||
|
/** Stack of states for redo operations */
|
||||||
|
private _redoStack: HistorySnapshot[] = [];
|
||||||
|
|
||||||
|
/** Maximum number of states to keep in history */
|
||||||
|
private _maxSize: number = DEFAULT_MAX_HISTORY_SIZE;
|
||||||
|
|
||||||
|
/** Reactive version counter for Vue reactivity */
|
||||||
|
private _version: Ref<number> = ref(0);
|
||||||
|
|
||||||
|
/** Flag to prevent recording during undo/redo operations */
|
||||||
|
private _isRestoring: boolean = false;
|
||||||
|
|
||||||
|
/** Computed property indicating if undo is available */
|
||||||
|
readonly canUndo: ComputedRef<boolean>;
|
||||||
|
|
||||||
|
/** Computed property indicating if redo is available */
|
||||||
|
readonly canRedo: ComputedRef<boolean>;
|
||||||
|
|
||||||
|
/** Computed property for undo stack size */
|
||||||
|
readonly undoStackSize: ComputedRef<number>;
|
||||||
|
|
||||||
|
/** Computed property for redo stack size */
|
||||||
|
readonly redoStackSize: ComputedRef<number>;
|
||||||
|
|
||||||
|
constructor(maxSize: number = DEFAULT_MAX_HISTORY_SIZE) {
|
||||||
|
this._maxSize = maxSize;
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
this.canUndo = computed(() => {
|
||||||
|
self._version.value; // Trigger reactivity
|
||||||
|
return self._undoStack.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.canRedo = computed(() => {
|
||||||
|
self._version.value; // Trigger reactivity
|
||||||
|
return self._redoStack.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.undoStackSize = computed(() => {
|
||||||
|
self._version.value;
|
||||||
|
return self._undoStack.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.redoStackSize = computed(() => {
|
||||||
|
self._version.value;
|
||||||
|
return self._redoStack.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify Vue reactivity system of changes
|
||||||
|
*/
|
||||||
|
private notify(): void {
|
||||||
|
this._version.value++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// State Snapshot Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a deep clone of the state for snapshot storage
|
||||||
|
* This ensures the snapshot is independent of the original state
|
||||||
|
*/
|
||||||
|
private cloneState(state: HistorySnapshot): HistorySnapshot {
|
||||||
|
return {
|
||||||
|
elements: state.elements.map(el => this.cloneElement(el)),
|
||||||
|
viewTransform: { ...state.viewTransform },
|
||||||
|
timestamp: state.timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep clone a single element
|
||||||
|
*/
|
||||||
|
private cloneElement(element: IMediaElement): IMediaElement {
|
||||||
|
return {
|
||||||
|
...element,
|
||||||
|
data: this.cloneElementData(element.data),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep clone element data
|
||||||
|
* Note: ImageBitmap objects in GifData.frames cannot be cloned,
|
||||||
|
* so we keep references to them (they are immutable anyway)
|
||||||
|
*/
|
||||||
|
private cloneElementData(data: IMediaElement['data']): IMediaElement['data'] {
|
||||||
|
if ('frames' in data) {
|
||||||
|
// GifData - keep frame references as they are immutable
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
frameDelays: [...data.frameDelays],
|
||||||
|
frames: data.frames, // Keep reference to ImageBitmap array
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...data };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a snapshot from the current scene state
|
||||||
|
*/
|
||||||
|
createSnapshot(
|
||||||
|
elements: IMediaElement[],
|
||||||
|
viewTransform: ViewTransform
|
||||||
|
): HistorySnapshot {
|
||||||
|
return {
|
||||||
|
elements: elements.map(el => this.cloneElement(el)),
|
||||||
|
viewTransform: { ...viewTransform },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// History Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push a new state onto the undo stack
|
||||||
|
* This should be called before making changes to the scene
|
||||||
|
* Requirement 5.4: Clears redo stack when new operation is performed
|
||||||
|
*/
|
||||||
|
push(state: HistorySnapshot): void {
|
||||||
|
// Don't record state during undo/redo operations
|
||||||
|
if (this._isRestoring) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone the state to ensure independence
|
||||||
|
const snapshot = this.cloneState(state);
|
||||||
|
|
||||||
|
// Add to undo stack
|
||||||
|
this._undoStack.push(snapshot);
|
||||||
|
|
||||||
|
// Enforce max size limit (Requirement 5.3)
|
||||||
|
while (this._undoStack.length > this._maxSize) {
|
||||||
|
this._undoStack.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear redo stack (Requirement 5.4)
|
||||||
|
this._redoStack = [];
|
||||||
|
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record the current state before an operation
|
||||||
|
* Convenience method that creates and pushes a snapshot
|
||||||
|
*/
|
||||||
|
record(elements: IMediaElement[], viewTransform: ViewTransform): void {
|
||||||
|
const snapshot = this.createSnapshot(elements, viewTransform);
|
||||||
|
this.push(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo the last operation
|
||||||
|
* Requirement 5.1: Restores to previous state
|
||||||
|
* @param currentState The current state to save for redo
|
||||||
|
* @returns The previous state to restore, or null if nothing to undo
|
||||||
|
*/
|
||||||
|
undo(currentState: HistorySnapshot): HistorySnapshot | null {
|
||||||
|
if (this._undoStack.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._isRestoring = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Save current state to redo stack
|
||||||
|
this._redoStack.push(this.cloneState(currentState));
|
||||||
|
|
||||||
|
// Pop and return the previous state
|
||||||
|
const previousState = this._undoStack.pop()!;
|
||||||
|
this.notify();
|
||||||
|
|
||||||
|
return this.cloneState(previousState);
|
||||||
|
} finally {
|
||||||
|
this._isRestoring = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redo the last undone operation
|
||||||
|
* Requirement 5.2: Restores the most recently undone state
|
||||||
|
* @param currentState The current state to save for undo
|
||||||
|
* @returns The state to restore, or null if nothing to redo
|
||||||
|
*/
|
||||||
|
redo(currentState: HistorySnapshot): HistorySnapshot | null {
|
||||||
|
if (this._redoStack.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._isRestoring = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Save current state to undo stack
|
||||||
|
this._undoStack.push(this.cloneState(currentState));
|
||||||
|
|
||||||
|
// Pop and return the redo state
|
||||||
|
const redoState = this._redoStack.pop()!;
|
||||||
|
this.notify();
|
||||||
|
|
||||||
|
return this.cloneState(redoState);
|
||||||
|
} finally {
|
||||||
|
this._isRestoring = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Configuration
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the maximum history size
|
||||||
|
* Requirement 5.3: Maintains max 50 states
|
||||||
|
*/
|
||||||
|
setMaxSize(size: number): void {
|
||||||
|
this._maxSize = Math.max(1, size);
|
||||||
|
|
||||||
|
// Trim undo stack if necessary
|
||||||
|
while (this._undoStack.length > this._maxSize) {
|
||||||
|
this._undoStack.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current maximum history size
|
||||||
|
*/
|
||||||
|
getMaxSize(): number {
|
||||||
|
return this._maxSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// State Queries
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if undo is available
|
||||||
|
*/
|
||||||
|
hasUndo(): boolean {
|
||||||
|
return this._undoStack.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if redo is available
|
||||||
|
*/
|
||||||
|
hasRedo(): boolean {
|
||||||
|
return this._redoStack.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of states in the undo stack
|
||||||
|
*/
|
||||||
|
getUndoCount(): number {
|
||||||
|
return this._undoStack.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of states in the redo stack
|
||||||
|
*/
|
||||||
|
getRedoCount(): number {
|
||||||
|
return this._redoStack.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Clear Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all history (both undo and redo stacks)
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this._undoStack = [];
|
||||||
|
this._redoStack = [];
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear only the undo stack
|
||||||
|
*/
|
||||||
|
clearUndo(): void {
|
||||||
|
this._undoStack = [];
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear only the redo stack
|
||||||
|
*/
|
||||||
|
clearRedo(): void {
|
||||||
|
this._redoStack = [];
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Batch Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Begin a batch operation
|
||||||
|
* Multiple changes can be grouped into a single undo step
|
||||||
|
* Call endBatch() when done
|
||||||
|
*/
|
||||||
|
private _batchState: HistorySnapshot | null = null;
|
||||||
|
|
||||||
|
beginBatch(currentState: HistorySnapshot): void {
|
||||||
|
if (this._batchState === null) {
|
||||||
|
this._batchState = this.cloneState(currentState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End a batch operation and record the initial state
|
||||||
|
*/
|
||||||
|
endBatch(): void {
|
||||||
|
if (this._batchState !== null) {
|
||||||
|
this.push(this._batchState);
|
||||||
|
this._batchState = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel a batch operation without recording
|
||||||
|
*/
|
||||||
|
cancelBatch(): void {
|
||||||
|
this._batchState = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if currently in a batch operation
|
||||||
|
*/
|
||||||
|
isInBatch(): boolean {
|
||||||
|
return this._batchState !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Singleton Instance
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
let historyManagerInstance: HistoryManager | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the singleton HistoryManager instance
|
||||||
|
*/
|
||||||
|
export function getHistoryManager(): HistoryManager {
|
||||||
|
if (!historyManagerInstance) {
|
||||||
|
historyManagerInstance = new HistoryManager(DEFAULT_MAX_HISTORY_SIZE);
|
||||||
|
}
|
||||||
|
return historyManagerInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the singleton instance (useful for testing)
|
||||||
|
*/
|
||||||
|
export function resetHistoryManager(): void {
|
||||||
|
historyManagerInstance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new HistoryManager instance (for non-singleton use)
|
||||||
|
*/
|
||||||
|
export function createHistoryManager(maxSize?: number): HistoryManager {
|
||||||
|
return new HistoryManager(maxSize);
|
||||||
|
}
|
||||||
577
src/core/input/index.ts
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
/**
|
||||||
|
* Input Handler - Main Entry Point
|
||||||
|
* Integrates keyboard and mouse input handling with input mode state machine
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { InputMode, MousePosition } from '../../types';
|
||||||
|
import {
|
||||||
|
KeyboardHandler,
|
||||||
|
getKeyboardHandler,
|
||||||
|
resetKeyboardHandler,
|
||||||
|
type KeyboardShortcut,
|
||||||
|
} from './keyboard';
|
||||||
|
import {
|
||||||
|
MouseHandler,
|
||||||
|
getMouseHandler,
|
||||||
|
resetMouseHandler,
|
||||||
|
type MouseEventData,
|
||||||
|
type DragState,
|
||||||
|
} from './mouse';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface InputState {
|
||||||
|
mode: InputMode;
|
||||||
|
mousePosition: MousePosition;
|
||||||
|
isDragging: boolean;
|
||||||
|
isSpacePressed: boolean;
|
||||||
|
isCtrlPressed: boolean;
|
||||||
|
isShiftPressed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectionRect {
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
endX: number;
|
||||||
|
endY: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InputActionCallback = (action: string, data?: unknown) => void;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Input Mode State Machine
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the appropriate input mode based on current state
|
||||||
|
*/
|
||||||
|
function determineMode(
|
||||||
|
keyboard: KeyboardHandler,
|
||||||
|
mouse: MouseHandler,
|
||||||
|
currentMode: InputMode
|
||||||
|
): InputMode {
|
||||||
|
const isSpacePressed = keyboard.isSpacePressed();
|
||||||
|
const isCtrlPressed = keyboard.isCtrlPressed();
|
||||||
|
const isDragging = mouse.isDragging();
|
||||||
|
const dragState = mouse.getDragState();
|
||||||
|
|
||||||
|
// If dragging, maintain current drag-related mode
|
||||||
|
if (isDragging) {
|
||||||
|
// Space + drag = panning
|
||||||
|
if (isSpacePressed && !isCtrlPressed) {
|
||||||
|
return 'panning';
|
||||||
|
}
|
||||||
|
// Ctrl + Space + drag = zooming
|
||||||
|
if (isSpacePressed && isCtrlPressed) {
|
||||||
|
return 'zooming';
|
||||||
|
}
|
||||||
|
// Middle button drag = panning
|
||||||
|
if (dragState.button === 1) {
|
||||||
|
return 'panning';
|
||||||
|
}
|
||||||
|
// Keep current mode if already in a drag mode
|
||||||
|
if (
|
||||||
|
currentMode === 'dragging' ||
|
||||||
|
currentMode === 'resizing' ||
|
||||||
|
currentMode === 'selecting'
|
||||||
|
) {
|
||||||
|
return currentMode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not dragging - check for potential mode based on keys
|
||||||
|
if (isSpacePressed && isCtrlPressed) {
|
||||||
|
return 'zooming';
|
||||||
|
}
|
||||||
|
if (isSpacePressed) {
|
||||||
|
return 'panning';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Input Handler Class
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export class InputHandler {
|
||||||
|
private keyboard: KeyboardHandler;
|
||||||
|
private mouse: MouseHandler;
|
||||||
|
private mode: InputMode = 'default';
|
||||||
|
private element: HTMLElement | null = null;
|
||||||
|
private actionCallbacks: Set<InputActionCallback> = new Set();
|
||||||
|
private selectionRect: SelectionRect | null = null;
|
||||||
|
|
||||||
|
// Cleanup functions
|
||||||
|
private cleanupFns: (() => void)[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.keyboard = getKeyboardHandler();
|
||||||
|
this.mouse = getMouseHandler();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Lifecycle
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach input handlers to an element
|
||||||
|
*/
|
||||||
|
attach(element: HTMLElement): void {
|
||||||
|
if (this.element) {
|
||||||
|
this.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.element = element;
|
||||||
|
this.keyboard.attach(window);
|
||||||
|
this.mouse.attach(element);
|
||||||
|
|
||||||
|
// Set up keyboard action handling
|
||||||
|
const keyboardCleanup = this.keyboard.onAction((action, event) => {
|
||||||
|
this.handleKeyboardAction(action, event);
|
||||||
|
});
|
||||||
|
this.cleanupFns.push(keyboardCleanup);
|
||||||
|
|
||||||
|
// Set up mouse event handling
|
||||||
|
this.setupMouseHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detach input handlers
|
||||||
|
*/
|
||||||
|
detach(): void {
|
||||||
|
for (const cleanup of this.cleanupFns) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
this.cleanupFns = [];
|
||||||
|
|
||||||
|
this.keyboard.detach();
|
||||||
|
this.mouse.detach();
|
||||||
|
this.element = null;
|
||||||
|
this.mode = 'default';
|
||||||
|
this.selectionRect = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Mouse Event Setup
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private setupMouseHandlers(): void {
|
||||||
|
// Click handling
|
||||||
|
const clickCleanup = this.mouse.on('click', (data) => {
|
||||||
|
this.handleClick(data);
|
||||||
|
});
|
||||||
|
this.cleanupFns.push(clickCleanup);
|
||||||
|
|
||||||
|
// Double click handling
|
||||||
|
const dblClickCleanup = this.mouse.on('doubleClick', (data) => {
|
||||||
|
this.handleDoubleClick(data);
|
||||||
|
});
|
||||||
|
this.cleanupFns.push(dblClickCleanup);
|
||||||
|
|
||||||
|
// Drag start
|
||||||
|
const dragStartCleanup = this.mouse.on('dragStart', (data) => {
|
||||||
|
this.handleDragStart(data);
|
||||||
|
});
|
||||||
|
this.cleanupFns.push(dragStartCleanup);
|
||||||
|
|
||||||
|
// Drag
|
||||||
|
const dragCleanup = this.mouse.on('drag', (data) => {
|
||||||
|
this.handleDrag(data);
|
||||||
|
});
|
||||||
|
this.cleanupFns.push(dragCleanup);
|
||||||
|
|
||||||
|
// Drag end
|
||||||
|
const dragEndCleanup = this.mouse.on('dragEnd', (data) => {
|
||||||
|
this.handleDragEnd(data);
|
||||||
|
});
|
||||||
|
this.cleanupFns.push(dragEndCleanup);
|
||||||
|
|
||||||
|
// Wheel (zoom)
|
||||||
|
const wheelCleanup = this.mouse.on('wheel', (data) => {
|
||||||
|
this.handleWheel(data);
|
||||||
|
});
|
||||||
|
this.cleanupFns.push(wheelCleanup);
|
||||||
|
|
||||||
|
// Context menu
|
||||||
|
const contextMenuCleanup = this.mouse.on('contextMenu', (data) => {
|
||||||
|
this.handleContextMenu(data);
|
||||||
|
});
|
||||||
|
this.cleanupFns.push(contextMenuCleanup);
|
||||||
|
|
||||||
|
// Mouse move
|
||||||
|
const moveCleanup = this.mouse.on('move', (data) => {
|
||||||
|
this.handleMouseMove(data);
|
||||||
|
});
|
||||||
|
this.cleanupFns.push(moveCleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Keyboard Action Handling
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private handleKeyboardAction(action: string, _event: KeyboardEvent): void {
|
||||||
|
this.emitAction(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Mouse Event Handling
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private handleClick(data: MouseEventData): void {
|
||||||
|
if (data.button === 0) {
|
||||||
|
// Left click
|
||||||
|
this.emitAction('click', {
|
||||||
|
x: data.x,
|
||||||
|
y: data.y,
|
||||||
|
shift: this.keyboard.isShiftPressed(),
|
||||||
|
ctrl: this.keyboard.isCtrlPressed(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleDoubleClick(data: MouseEventData): void {
|
||||||
|
if (data.button === 0) {
|
||||||
|
this.emitAction('doubleClick', { x: data.x, y: data.y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleDragStart(data: MouseEventData): void {
|
||||||
|
const isSpace = this.keyboard.isSpacePressed();
|
||||||
|
const isCtrl = this.keyboard.isCtrlPressed();
|
||||||
|
|
||||||
|
// Determine drag mode
|
||||||
|
if (data.button === 2) {
|
||||||
|
// Right button drag = window drag (Requirement 7.5)
|
||||||
|
this.setMode('windowDragging' as InputMode);
|
||||||
|
this.emitAction('windowDragStart', { x: data.x, y: data.y });
|
||||||
|
} else if (data.button === 1 || (data.button === 0 && isSpace && !isCtrl)) {
|
||||||
|
// Middle button or Space+Left = panning
|
||||||
|
this.setMode('panning');
|
||||||
|
this.emitAction('panStart', { x: data.x, y: data.y });
|
||||||
|
// Apply initial delta from mousedown to dragStart immediately
|
||||||
|
if (data.deltaX !== undefined && data.deltaY !== undefined) {
|
||||||
|
this.emitAction('pan', {
|
||||||
|
deltaX: data.deltaX,
|
||||||
|
deltaY: data.deltaY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.button === 0 && isSpace && isCtrl) {
|
||||||
|
// Ctrl+Space+Left = zooming
|
||||||
|
this.setMode('zooming');
|
||||||
|
this.emitAction('zoomDragStart', { x: data.x, y: data.y });
|
||||||
|
} else if (data.button === 0) {
|
||||||
|
// Left button drag - could be element drag or selection
|
||||||
|
// The actual mode will be determined by the scene manager
|
||||||
|
this.emitAction('dragStart', {
|
||||||
|
x: data.x,
|
||||||
|
y: data.y,
|
||||||
|
shift: this.keyboard.isShiftPressed(),
|
||||||
|
deltaX: data.deltaX,
|
||||||
|
deltaY: data.deltaY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleDrag(data: MouseEventData): void {
|
||||||
|
const mode = this.mode;
|
||||||
|
|
||||||
|
if (mode === 'panning') {
|
||||||
|
this.emitAction('pan', {
|
||||||
|
deltaX: data.deltaX,
|
||||||
|
deltaY: data.deltaY,
|
||||||
|
});
|
||||||
|
} else if (mode === 'zooming') {
|
||||||
|
// Horizontal movement controls zoom
|
||||||
|
this.emitAction('zoomDrag', {
|
||||||
|
deltaX: data.deltaX,
|
||||||
|
x: data.x,
|
||||||
|
y: data.y,
|
||||||
|
});
|
||||||
|
} else if (mode === 'selecting') {
|
||||||
|
// Update selection rectangle
|
||||||
|
if (this.selectionRect) {
|
||||||
|
this.selectionRect.endX = data.x;
|
||||||
|
this.selectionRect.endY = data.y;
|
||||||
|
this.selectionRect.width = data.x - this.selectionRect.startX;
|
||||||
|
this.selectionRect.height = data.y - this.selectionRect.startY;
|
||||||
|
|
||||||
|
this.emitAction('selectionUpdate', { ...this.selectionRect });
|
||||||
|
}
|
||||||
|
} else if (mode === 'dragging') {
|
||||||
|
this.emitAction('elementDrag', {
|
||||||
|
deltaX: data.deltaX,
|
||||||
|
deltaY: data.deltaY,
|
||||||
|
});
|
||||||
|
} else if (mode === 'resizing') {
|
||||||
|
this.emitAction('elementResize', {
|
||||||
|
deltaX: data.deltaX,
|
||||||
|
deltaY: data.deltaY,
|
||||||
|
x: data.x,
|
||||||
|
y: data.y,
|
||||||
|
});
|
||||||
|
} else if (mode === ('windowDragging' as InputMode)) {
|
||||||
|
this.emitAction('windowDrag', {
|
||||||
|
deltaX: data.deltaX,
|
||||||
|
deltaY: data.deltaY,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Default mode drag - emit generic drag
|
||||||
|
this.emitAction('drag', {
|
||||||
|
x: data.x,
|
||||||
|
y: data.y,
|
||||||
|
deltaX: data.deltaX,
|
||||||
|
deltaY: data.deltaY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleDragEnd(data: MouseEventData): void {
|
||||||
|
const mode = this.mode;
|
||||||
|
|
||||||
|
if (mode === 'panning') {
|
||||||
|
this.emitAction('panEnd', { x: data.x, y: data.y });
|
||||||
|
} else if (mode === 'zooming') {
|
||||||
|
this.emitAction('zoomDragEnd', { x: data.x, y: data.y });
|
||||||
|
} else if (mode === 'selecting') {
|
||||||
|
if (this.selectionRect) {
|
||||||
|
// Update selection rect with final mouse position before emitting
|
||||||
|
this.selectionRect.endX = data.x;
|
||||||
|
this.selectionRect.endY = data.y;
|
||||||
|
this.selectionRect.width = data.x - this.selectionRect.startX;
|
||||||
|
this.selectionRect.height = data.y - this.selectionRect.startY;
|
||||||
|
this.emitAction('selectionEnd', { ...this.selectionRect });
|
||||||
|
this.selectionRect = null;
|
||||||
|
}
|
||||||
|
} else if (mode === 'dragging') {
|
||||||
|
this.emitAction('elementDragEnd', { x: data.x, y: data.y });
|
||||||
|
} else if (mode === 'resizing') {
|
||||||
|
this.emitAction('elementResizeEnd', { x: data.x, y: data.y });
|
||||||
|
} else {
|
||||||
|
this.emitAction('dragEnd', {
|
||||||
|
x: data.x,
|
||||||
|
y: data.y,
|
||||||
|
deltaX: data.deltaX,
|
||||||
|
deltaY: data.deltaY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset to default mode after drag ends
|
||||||
|
this.setMode('default');
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleWheel(data: MouseEventData): void {
|
||||||
|
// Wheel zoom (Requirement 2.1)
|
||||||
|
this.emitAction('zoom', {
|
||||||
|
delta: data.wheelDelta,
|
||||||
|
x: data.x,
|
||||||
|
y: data.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleContextMenu(data: MouseEventData): void {
|
||||||
|
this.emitAction('contextMenu', {
|
||||||
|
x: data.x,
|
||||||
|
y: data.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMouseMove(data: MouseEventData): void {
|
||||||
|
// Update mode based on current key state
|
||||||
|
const newMode = determineMode(this.keyboard, this.mouse, this.mode);
|
||||||
|
if (newMode !== this.mode && !this.mouse.isDragging()) {
|
||||||
|
this.setMode(newMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitAction('mouseMove', { x: data.x, y: data.y });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Mode Management
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the current input mode
|
||||||
|
*/
|
||||||
|
setMode(mode: InputMode): void {
|
||||||
|
if (this.mode !== mode) {
|
||||||
|
const oldMode = this.mode;
|
||||||
|
this.mode = mode;
|
||||||
|
this.emitAction('modeChange', { oldMode, newMode: mode });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current input mode
|
||||||
|
*/
|
||||||
|
getMode(): InputMode {
|
||||||
|
return this.mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start selection mode with initial rectangle
|
||||||
|
*/
|
||||||
|
startSelection(x: number, y: number): void {
|
||||||
|
this.setMode('selecting');
|
||||||
|
this.selectionRect = {
|
||||||
|
startX: x,
|
||||||
|
startY: y,
|
||||||
|
endX: x,
|
||||||
|
endY: y,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
};
|
||||||
|
this.emitAction('selectionStart', { x, y });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start element dragging mode
|
||||||
|
*/
|
||||||
|
startDragging(): void {
|
||||||
|
this.setMode('dragging');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start element resizing mode
|
||||||
|
*/
|
||||||
|
startResizing(): void {
|
||||||
|
this.setMode('resizing');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel current operation and return to default mode
|
||||||
|
*/
|
||||||
|
cancel(): void {
|
||||||
|
if (this.mode === 'selecting') {
|
||||||
|
this.selectionRect = null;
|
||||||
|
this.emitAction('selectionCancel');
|
||||||
|
}
|
||||||
|
this.setMode('default');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Action Callbacks
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a callback for input actions
|
||||||
|
*/
|
||||||
|
onAction(callback: InputActionCallback): () => void {
|
||||||
|
this.actionCallbacks.add(callback);
|
||||||
|
return () => this.actionCallbacks.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit an action to all registered callbacks
|
||||||
|
*/
|
||||||
|
private emitAction(action: string, data?: unknown): void {
|
||||||
|
for (const callback of this.actionCallbacks) {
|
||||||
|
callback(action, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// State Queries
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current input state
|
||||||
|
*/
|
||||||
|
getState(): InputState {
|
||||||
|
return {
|
||||||
|
mode: this.mode,
|
||||||
|
mousePosition: this.mouse.getPosition(),
|
||||||
|
isDragging: this.mouse.isDragging(),
|
||||||
|
isSpacePressed: this.keyboard.isSpacePressed(),
|
||||||
|
isCtrlPressed: this.keyboard.isCtrlPressed(),
|
||||||
|
isShiftPressed: this.keyboard.isShiftPressed(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current mouse position
|
||||||
|
*/
|
||||||
|
getMousePosition(): MousePosition {
|
||||||
|
return this.mouse.getPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a key is pressed
|
||||||
|
*/
|
||||||
|
isKeyPressed(key: string): boolean {
|
||||||
|
return this.keyboard.isKeyPressed(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current selection rectangle (if in selecting mode)
|
||||||
|
*/
|
||||||
|
getSelectionRect(): SelectionRect | null {
|
||||||
|
return this.selectionRect ? { ...this.selectionRect } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get drag state
|
||||||
|
*/
|
||||||
|
getDragState(): DragState {
|
||||||
|
return this.mouse.getDragState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all keyboard shortcuts
|
||||||
|
*/
|
||||||
|
getShortcuts(): KeyboardShortcut[] {
|
||||||
|
return this.keyboard.getShortcuts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Singleton Instance
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
let inputHandlerInstance: InputHandler | null = null;
|
||||||
|
|
||||||
|
export function getInputHandler(): InputHandler {
|
||||||
|
if (!inputHandlerInstance) {
|
||||||
|
inputHandlerInstance = new InputHandler();
|
||||||
|
}
|
||||||
|
return inputHandlerInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetInputHandler(): void {
|
||||||
|
if (inputHandlerInstance) {
|
||||||
|
inputHandlerInstance.detach();
|
||||||
|
inputHandlerInstance = null;
|
||||||
|
}
|
||||||
|
resetKeyboardHandler();
|
||||||
|
resetMouseHandler();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Re-exports
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export {
|
||||||
|
KeyboardHandler,
|
||||||
|
getKeyboardHandler,
|
||||||
|
resetKeyboardHandler,
|
||||||
|
KEYBOARD_SHORTCUTS,
|
||||||
|
type KeyboardShortcut,
|
||||||
|
type KeyboardState,
|
||||||
|
type KeyboardEventCallback,
|
||||||
|
} from './keyboard';
|
||||||
|
|
||||||
|
export {
|
||||||
|
MouseHandler,
|
||||||
|
getMouseHandler,
|
||||||
|
resetMouseHandler,
|
||||||
|
type MouseEventData,
|
||||||
|
type MouseEventType,
|
||||||
|
type MouseEventCallback,
|
||||||
|
type DragState,
|
||||||
|
type MouseState,
|
||||||
|
} from './mouse';
|
||||||
330
src/core/input/keyboard.ts
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
/**
|
||||||
|
* Keyboard Input Handler
|
||||||
|
* Implements keyboard event listening, shortcut mapping, and combo key detection
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface KeyboardShortcut {
|
||||||
|
key: string;
|
||||||
|
ctrl?: boolean;
|
||||||
|
shift?: boolean;
|
||||||
|
alt?: boolean;
|
||||||
|
action: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyboardState {
|
||||||
|
pressedKeys: Set<string>;
|
||||||
|
ctrlPressed: boolean;
|
||||||
|
shiftPressed: boolean;
|
||||||
|
altPressed: boolean;
|
||||||
|
spacePressed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KeyboardEventCallback = (action: string, event: KeyboardEvent) => void;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Shortcut Definitions
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
|
||||||
|
// Undo/Redo (Requirement 5.1, 5.2)
|
||||||
|
{ key: 'z', ctrl: true, action: 'undo', description: '撤销' },
|
||||||
|
{ key: 'y', ctrl: true, action: 'redo', description: '重做' },
|
||||||
|
{ key: 'z', ctrl: true, shift: true, action: 'redo', description: '重做' },
|
||||||
|
|
||||||
|
// File Operations (Requirement 6.1, 6.2, 6.3)
|
||||||
|
{ key: 's', ctrl: true, action: 'save', description: '保存场景' },
|
||||||
|
{ key: 'l', ctrl: true, action: 'load', description: '加载场景' },
|
||||||
|
{ key: 'n', ctrl: true, action: 'new', description: '新建场景' },
|
||||||
|
|
||||||
|
// Window Management (Requirement 7.4, 7.5, 7.6)
|
||||||
|
{ key: 'f', ctrl: true, action: 'toggleFullscreen', description: '切换最大化' },
|
||||||
|
{ key: 'm', ctrl: true, action: 'minimize', description: '最小化窗口' },
|
||||||
|
{ key: 'w', ctrl: true, action: 'close', description: '关闭窗口' },
|
||||||
|
|
||||||
|
// Help (Requirement 9.1)
|
||||||
|
{ key: 'h', ctrl: true, action: 'help', description: '显示帮助' },
|
||||||
|
|
||||||
|
// Copy/Paste (Requirement 4.1, 4.2)
|
||||||
|
{ key: 'c', ctrl: true, action: 'copy', description: '复制' },
|
||||||
|
{ key: 'v', ctrl: true, action: 'paste', description: '粘贴' },
|
||||||
|
{ key: 'x', ctrl: true, action: 'cut', description: '剪切' },
|
||||||
|
|
||||||
|
// Selection
|
||||||
|
{ key: 'a', ctrl: true, action: 'selectAll', description: '全选' },
|
||||||
|
{ key: 'Escape', action: 'deselect', description: '取消选择' },
|
||||||
|
{ key: 'Delete', action: 'delete', description: '删除选中元素' },
|
||||||
|
{ key: 'Backspace', action: 'delete', description: '删除选中元素' },
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Keyboard Handler Class
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export class KeyboardHandler {
|
||||||
|
private state: KeyboardState = {
|
||||||
|
pressedKeys: new Set(),
|
||||||
|
ctrlPressed: false,
|
||||||
|
shiftPressed: false,
|
||||||
|
altPressed: false,
|
||||||
|
spacePressed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
private callbacks: Set<KeyboardEventCallback> = new Set();
|
||||||
|
private element: HTMLElement | Window | null = null;
|
||||||
|
private boundKeyDown: (e: KeyboardEvent) => void;
|
||||||
|
private boundKeyUp: (e: KeyboardEvent) => void;
|
||||||
|
private boundBlur: () => void;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.boundKeyDown = this.handleKeyDown.bind(this);
|
||||||
|
this.boundKeyUp = this.handleKeyUp.bind(this);
|
||||||
|
this.boundBlur = this.handleBlur.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Lifecycle
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach keyboard event listeners to an element
|
||||||
|
*/
|
||||||
|
attach(element: HTMLElement | Window = window): void {
|
||||||
|
if (this.element) {
|
||||||
|
this.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.element = element;
|
||||||
|
element.addEventListener('keydown', this.boundKeyDown as EventListener);
|
||||||
|
element.addEventListener('keyup', this.boundKeyUp as EventListener);
|
||||||
|
window.addEventListener('blur', this.boundBlur);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detach keyboard event listeners
|
||||||
|
*/
|
||||||
|
detach(): void {
|
||||||
|
if (!this.element) return;
|
||||||
|
|
||||||
|
this.element.removeEventListener('keydown', this.boundKeyDown as EventListener);
|
||||||
|
this.element.removeEventListener('keyup', this.boundKeyUp as EventListener);
|
||||||
|
window.removeEventListener('blur', this.boundBlur);
|
||||||
|
this.element = null;
|
||||||
|
this.resetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset keyboard state (called on blur)
|
||||||
|
*/
|
||||||
|
private resetState(): void {
|
||||||
|
this.state.pressedKeys.clear();
|
||||||
|
this.state.ctrlPressed = false;
|
||||||
|
this.state.shiftPressed = false;
|
||||||
|
this.state.altPressed = false;
|
||||||
|
this.state.spacePressed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Event Handlers
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private handleKeyDown(event: KeyboardEvent): void {
|
||||||
|
const key = event.key;
|
||||||
|
|
||||||
|
// Update modifier state
|
||||||
|
this.state.ctrlPressed = event.ctrlKey || event.metaKey;
|
||||||
|
this.state.shiftPressed = event.shiftKey;
|
||||||
|
this.state.altPressed = event.altKey;
|
||||||
|
|
||||||
|
// Track space key separately for panning
|
||||||
|
if (key === ' ') {
|
||||||
|
this.state.spacePressed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to pressed keys
|
||||||
|
this.state.pressedKeys.add(key.toLowerCase());
|
||||||
|
|
||||||
|
// Check for matching shortcut
|
||||||
|
const action = this.matchShortcut(event);
|
||||||
|
if (action) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.notifyCallbacks(action, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleKeyUp(event: KeyboardEvent): void {
|
||||||
|
const key = event.key;
|
||||||
|
|
||||||
|
// Update modifier state
|
||||||
|
this.state.ctrlPressed = event.ctrlKey || event.metaKey;
|
||||||
|
this.state.shiftPressed = event.shiftKey;
|
||||||
|
this.state.altPressed = event.altKey;
|
||||||
|
|
||||||
|
// Track space key
|
||||||
|
if (key === ' ') {
|
||||||
|
this.state.spacePressed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from pressed keys
|
||||||
|
this.state.pressedKeys.delete(key.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleBlur(): void {
|
||||||
|
this.resetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Shortcut Matching
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match keyboard event against defined shortcuts
|
||||||
|
*/
|
||||||
|
private matchShortcut(event: KeyboardEvent): string | null {
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
const ctrl = event.ctrlKey || event.metaKey;
|
||||||
|
const shift = event.shiftKey;
|
||||||
|
const alt = event.altKey;
|
||||||
|
|
||||||
|
for (const shortcut of KEYBOARD_SHORTCUTS) {
|
||||||
|
const shortcutKey = shortcut.key.toLowerCase();
|
||||||
|
const requiresCtrl = shortcut.ctrl ?? false;
|
||||||
|
const requiresShift = shortcut.shift ?? false;
|
||||||
|
const requiresAlt = shortcut.alt ?? false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
shortcutKey === key &&
|
||||||
|
requiresCtrl === ctrl &&
|
||||||
|
requiresShift === shift &&
|
||||||
|
requiresAlt === alt
|
||||||
|
) {
|
||||||
|
return shortcut.action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Callback Management
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a callback for keyboard actions
|
||||||
|
*/
|
||||||
|
onAction(callback: KeyboardEventCallback): () => void {
|
||||||
|
this.callbacks.add(callback);
|
||||||
|
return () => this.callbacks.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify all registered callbacks
|
||||||
|
*/
|
||||||
|
private notifyCallbacks(action: string, event: KeyboardEvent): void {
|
||||||
|
for (const callback of this.callbacks) {
|
||||||
|
callback(action, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// State Queries
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a specific key is currently pressed
|
||||||
|
*/
|
||||||
|
isKeyPressed(key: string): boolean {
|
||||||
|
return this.state.pressedKeys.has(key.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Ctrl (or Cmd on Mac) is pressed
|
||||||
|
*/
|
||||||
|
isCtrlPressed(): boolean {
|
||||||
|
return this.state.ctrlPressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Shift is pressed
|
||||||
|
*/
|
||||||
|
isShiftPressed(): boolean {
|
||||||
|
return this.state.shiftPressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Alt is pressed
|
||||||
|
*/
|
||||||
|
isAltPressed(): boolean {
|
||||||
|
return this.state.altPressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Space is pressed (for panning mode)
|
||||||
|
*/
|
||||||
|
isSpacePressed(): boolean {
|
||||||
|
return this.state.spacePressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current keyboard state
|
||||||
|
*/
|
||||||
|
getState(): Readonly<KeyboardState> {
|
||||||
|
return {
|
||||||
|
...this.state,
|
||||||
|
pressedKeys: new Set(this.state.pressedKeys),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all defined shortcuts
|
||||||
|
*/
|
||||||
|
getShortcuts(): KeyboardShortcut[] {
|
||||||
|
return [...KEYBOARD_SHORTCUTS];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format shortcut for display
|
||||||
|
*/
|
||||||
|
static formatShortcut(shortcut: KeyboardShortcut): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (shortcut.ctrl) parts.push('Ctrl');
|
||||||
|
if (shortcut.shift) parts.push('Shift');
|
||||||
|
if (shortcut.alt) parts.push('Alt');
|
||||||
|
|
||||||
|
// Format special keys
|
||||||
|
let keyDisplay = shortcut.key;
|
||||||
|
if (keyDisplay === ' ') keyDisplay = 'Space';
|
||||||
|
else if (keyDisplay === 'Escape') keyDisplay = 'Esc';
|
||||||
|
else if (keyDisplay === 'Delete') keyDisplay = 'Del';
|
||||||
|
else if (keyDisplay === 'Backspace') keyDisplay = 'Backspace';
|
||||||
|
else keyDisplay = keyDisplay.toUpperCase();
|
||||||
|
|
||||||
|
parts.push(keyDisplay);
|
||||||
|
return parts.join('+');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Singleton Instance
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
let keyboardHandlerInstance: KeyboardHandler | null = null;
|
||||||
|
|
||||||
|
export function getKeyboardHandler(): KeyboardHandler {
|
||||||
|
if (!keyboardHandlerInstance) {
|
||||||
|
keyboardHandlerInstance = new KeyboardHandler();
|
||||||
|
}
|
||||||
|
return keyboardHandlerInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetKeyboardHandler(): void {
|
||||||
|
if (keyboardHandlerInstance) {
|
||||||
|
keyboardHandlerInstance.detach();
|
||||||
|
keyboardHandlerInstance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
509
src/core/input/mouse.ts
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
/**
|
||||||
|
* Mouse Input Handler
|
||||||
|
* Implements mouse event listening, drag detection, wheel zoom, and hit testing
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MousePosition } from '../../types';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface DragState {
|
||||||
|
isDragging: boolean;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
currentX: number;
|
||||||
|
currentY: number;
|
||||||
|
deltaX: number;
|
||||||
|
deltaY: number;
|
||||||
|
button: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MouseState {
|
||||||
|
position: MousePosition;
|
||||||
|
isLeftButtonDown: boolean;
|
||||||
|
isMiddleButtonDown: boolean;
|
||||||
|
isRightButtonDown: boolean;
|
||||||
|
drag: DragState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MouseEventType =
|
||||||
|
| 'click'
|
||||||
|
| 'doubleClick'
|
||||||
|
| 'dragStart'
|
||||||
|
| 'drag'
|
||||||
|
| 'dragEnd'
|
||||||
|
| 'wheel'
|
||||||
|
| 'contextMenu'
|
||||||
|
| 'move';
|
||||||
|
|
||||||
|
export interface MouseEventData {
|
||||||
|
type: MouseEventType;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
button: number;
|
||||||
|
deltaX?: number;
|
||||||
|
deltaY?: number;
|
||||||
|
wheelDelta?: number;
|
||||||
|
originalEvent: MouseEvent | WheelEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MouseEventCallback = (data: MouseEventData) => void;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Constants
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const DRAG_THRESHOLD = 5; // Minimum pixels to start drag
|
||||||
|
const DOUBLE_CLICK_DELAY = 300; // ms
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Mouse Handler Class
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export class MouseHandler {
|
||||||
|
private state: MouseState = {
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
isLeftButtonDown: false,
|
||||||
|
isMiddleButtonDown: false,
|
||||||
|
isRightButtonDown: false,
|
||||||
|
drag: {
|
||||||
|
isDragging: false,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
currentX: 0,
|
||||||
|
currentY: 0,
|
||||||
|
deltaX: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
button: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
private callbacks: Map<MouseEventType, Set<MouseEventCallback>> = new Map();
|
||||||
|
private element: HTMLElement | null = null;
|
||||||
|
private lastClickTime = 0;
|
||||||
|
private lastClickX = 0;
|
||||||
|
private lastClickY = 0;
|
||||||
|
|
||||||
|
// Bound event handlers
|
||||||
|
private boundMouseDown: (e: MouseEvent) => void;
|
||||||
|
private boundMouseMove: (e: MouseEvent) => void;
|
||||||
|
private boundMouseUp: (e: MouseEvent) => void;
|
||||||
|
private boundWheel: (e: WheelEvent) => void;
|
||||||
|
private boundContextMenu: (e: MouseEvent) => void;
|
||||||
|
private boundMouseLeave: (e: MouseEvent) => void;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.boundMouseDown = this.handleMouseDown.bind(this);
|
||||||
|
this.boundMouseMove = this.handleMouseMove.bind(this);
|
||||||
|
this.boundMouseUp = this.handleMouseUp.bind(this);
|
||||||
|
this.boundWheel = this.handleWheel.bind(this);
|
||||||
|
this.boundContextMenu = this.handleContextMenu.bind(this);
|
||||||
|
this.boundMouseLeave = this.handleMouseLeave.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Lifecycle
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach mouse event listeners to an element
|
||||||
|
*/
|
||||||
|
attach(element: HTMLElement): void {
|
||||||
|
if (this.element) {
|
||||||
|
this.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.element = element;
|
||||||
|
element.addEventListener('mousedown', this.boundMouseDown);
|
||||||
|
element.addEventListener('mousemove', this.boundMouseMove);
|
||||||
|
element.addEventListener('mouseup', this.boundMouseUp);
|
||||||
|
element.addEventListener('wheel', this.boundWheel, { passive: false });
|
||||||
|
element.addEventListener('contextmenu', this.boundContextMenu);
|
||||||
|
element.addEventListener('mouseleave', this.boundMouseLeave);
|
||||||
|
|
||||||
|
// Also listen on window for mouseup to catch releases outside element
|
||||||
|
window.addEventListener('mouseup', this.boundMouseUp);
|
||||||
|
window.addEventListener('mousemove', this.boundMouseMove);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detach mouse event listeners
|
||||||
|
*/
|
||||||
|
detach(): void {
|
||||||
|
if (!this.element) return;
|
||||||
|
|
||||||
|
this.element.removeEventListener('mousedown', this.boundMouseDown);
|
||||||
|
this.element.removeEventListener('mousemove', this.boundMouseMove);
|
||||||
|
this.element.removeEventListener('mouseup', this.boundMouseUp);
|
||||||
|
this.element.removeEventListener('wheel', this.boundWheel);
|
||||||
|
this.element.removeEventListener('contextmenu', this.boundContextMenu);
|
||||||
|
this.element.removeEventListener('mouseleave', this.boundMouseLeave);
|
||||||
|
|
||||||
|
window.removeEventListener('mouseup', this.boundMouseUp);
|
||||||
|
window.removeEventListener('mousemove', this.boundMouseMove);
|
||||||
|
|
||||||
|
this.element = null;
|
||||||
|
this.resetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset mouse state
|
||||||
|
*/
|
||||||
|
private resetState(): void {
|
||||||
|
this.state.isLeftButtonDown = false;
|
||||||
|
this.state.isMiddleButtonDown = false;
|
||||||
|
this.state.isRightButtonDown = false;
|
||||||
|
this.state.drag.isDragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Event Handlers
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private handleMouseDown(event: MouseEvent): void {
|
||||||
|
const { clientX, clientY, button } = event;
|
||||||
|
const rect = this.element?.getBoundingClientRect();
|
||||||
|
const x = rect ? clientX - rect.left : clientX;
|
||||||
|
const y = rect ? clientY - rect.top : clientY;
|
||||||
|
|
||||||
|
// Update button state
|
||||||
|
if (button === 0) this.state.isLeftButtonDown = true;
|
||||||
|
if (button === 1) this.state.isMiddleButtonDown = true;
|
||||||
|
if (button === 2) this.state.isRightButtonDown = true;
|
||||||
|
|
||||||
|
// Initialize drag tracking
|
||||||
|
this.state.drag.startX = x;
|
||||||
|
this.state.drag.startY = y;
|
||||||
|
this.state.drag.currentX = x;
|
||||||
|
this.state.drag.currentY = y;
|
||||||
|
this.state.drag.deltaX = 0;
|
||||||
|
this.state.drag.deltaY = 0;
|
||||||
|
this.state.drag.button = button;
|
||||||
|
|
||||||
|
this.state.position = { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMouseMove(event: MouseEvent): void {
|
||||||
|
const { clientX, clientY, screenX, screenY } = event;
|
||||||
|
const rect = this.element?.getBoundingClientRect();
|
||||||
|
const x = rect ? clientX - rect.left : clientX;
|
||||||
|
const y = rect ? clientY - rect.top : clientY;
|
||||||
|
|
||||||
|
const prevX = this.state.position.x;
|
||||||
|
const prevY = this.state.position.y;
|
||||||
|
this.state.position = { x, y };
|
||||||
|
|
||||||
|
// Check if any button is down for drag detection
|
||||||
|
const anyButtonDown =
|
||||||
|
this.state.isLeftButtonDown ||
|
||||||
|
this.state.isMiddleButtonDown ||
|
||||||
|
this.state.isRightButtonDown;
|
||||||
|
|
||||||
|
if (anyButtonDown) {
|
||||||
|
const dx = x - this.state.drag.startX;
|
||||||
|
const dy = y - this.state.drag.startY;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (!this.state.drag.isDragging && distance >= DRAG_THRESHOLD) {
|
||||||
|
// Start dragging
|
||||||
|
this.state.drag.isDragging = true;
|
||||||
|
// Store screen position for window dragging
|
||||||
|
(this.state.drag as any).lastScreenX = screenX;
|
||||||
|
(this.state.drag as any).lastScreenY = screenY;
|
||||||
|
this.emit('dragStart', {
|
||||||
|
type: 'dragStart',
|
||||||
|
x: this.state.drag.startX,
|
||||||
|
y: this.state.drag.startY,
|
||||||
|
button: this.state.drag.button,
|
||||||
|
// Include initial delta from mousedown to dragStart
|
||||||
|
deltaX: x - this.state.drag.startX,
|
||||||
|
deltaY: y - this.state.drag.startY,
|
||||||
|
originalEvent: event,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.drag.isDragging) {
|
||||||
|
this.state.drag.currentX = x;
|
||||||
|
this.state.drag.currentY = y;
|
||||||
|
this.state.drag.deltaX = x - prevX;
|
||||||
|
this.state.drag.deltaY = y - prevY;
|
||||||
|
|
||||||
|
// For right button (window drag), use screen coordinates for stable delta
|
||||||
|
if (this.state.drag.button === 2) {
|
||||||
|
const lastScreenX = (this.state.drag as any).lastScreenX || screenX;
|
||||||
|
const lastScreenY = (this.state.drag as any).lastScreenY || screenY;
|
||||||
|
this.state.drag.deltaX = screenX - lastScreenX;
|
||||||
|
this.state.drag.deltaY = screenY - lastScreenY;
|
||||||
|
(this.state.drag as any).lastScreenX = screenX;
|
||||||
|
(this.state.drag as any).lastScreenY = screenY;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('drag', {
|
||||||
|
type: 'drag',
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
button: this.state.drag.button,
|
||||||
|
deltaX: this.state.drag.deltaX,
|
||||||
|
deltaY: this.state.drag.deltaY,
|
||||||
|
originalEvent: event,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Regular mouse move
|
||||||
|
this.emit('move', {
|
||||||
|
type: 'move',
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
button: -1,
|
||||||
|
originalEvent: event,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMouseUp(event: MouseEvent): void {
|
||||||
|
const { clientX, clientY, button } = event;
|
||||||
|
const rect = this.element?.getBoundingClientRect();
|
||||||
|
const x = rect ? clientX - rect.left : clientX;
|
||||||
|
const y = rect ? clientY - rect.top : clientY;
|
||||||
|
|
||||||
|
// Check if this button was actually pressed (avoid duplicate events from window listener)
|
||||||
|
const wasButtonDown =
|
||||||
|
(button === 0 && this.state.isLeftButtonDown) ||
|
||||||
|
(button === 1 && this.state.isMiddleButtonDown) ||
|
||||||
|
(button === 2 && this.state.isRightButtonDown);
|
||||||
|
|
||||||
|
if (!wasButtonDown) {
|
||||||
|
// This button wasn't tracked as pressed, ignore this event
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for drag end
|
||||||
|
if (this.state.drag.isDragging && this.state.drag.button === button) {
|
||||||
|
// Mark if this was a right-button drag (to suppress context menu)
|
||||||
|
if (button === 2) {
|
||||||
|
(this.state.drag as any).wasRightDragging = true;
|
||||||
|
}
|
||||||
|
this.emit('dragEnd', {
|
||||||
|
type: 'dragEnd',
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
button,
|
||||||
|
deltaX: x - this.state.drag.startX,
|
||||||
|
deltaY: y - this.state.drag.startY,
|
||||||
|
originalEvent: event,
|
||||||
|
});
|
||||||
|
this.state.drag.isDragging = false;
|
||||||
|
} else if (!this.state.drag.isDragging) {
|
||||||
|
// It was a click (no drag occurred)
|
||||||
|
const now = Date.now();
|
||||||
|
const timeDiff = now - this.lastClickTime;
|
||||||
|
const distX = Math.abs(x - this.lastClickX);
|
||||||
|
const distY = Math.abs(y - this.lastClickY);
|
||||||
|
|
||||||
|
if (timeDiff < DOUBLE_CLICK_DELAY && distX < 5 && distY < 5) {
|
||||||
|
// Double click
|
||||||
|
this.emit('doubleClick', {
|
||||||
|
type: 'doubleClick',
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
button,
|
||||||
|
originalEvent: event,
|
||||||
|
});
|
||||||
|
this.lastClickTime = 0; // Reset to prevent triple-click
|
||||||
|
} else {
|
||||||
|
// Single click
|
||||||
|
this.emit('click', {
|
||||||
|
type: 'click',
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
button,
|
||||||
|
originalEvent: event,
|
||||||
|
});
|
||||||
|
this.lastClickTime = now;
|
||||||
|
this.lastClickX = x;
|
||||||
|
this.lastClickY = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update button state
|
||||||
|
if (button === 0) this.state.isLeftButtonDown = false;
|
||||||
|
if (button === 1) this.state.isMiddleButtonDown = false;
|
||||||
|
if (button === 2) this.state.isRightButtonDown = false;
|
||||||
|
|
||||||
|
this.state.position = { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleWheel(event: WheelEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const { clientX, clientY, deltaY } = event;
|
||||||
|
const rect = this.element?.getBoundingClientRect();
|
||||||
|
const x = rect ? clientX - rect.left : clientX;
|
||||||
|
const y = rect ? clientY - rect.top : clientY;
|
||||||
|
|
||||||
|
// Normalize wheel delta
|
||||||
|
const wheelDelta = -deltaY * 0.001;
|
||||||
|
|
||||||
|
this.emit('wheel', {
|
||||||
|
type: 'wheel',
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
button: -1,
|
||||||
|
wheelDelta,
|
||||||
|
originalEvent: event,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleContextMenu(event: MouseEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// Don't show context menu if we just finished dragging
|
||||||
|
if (this.state.drag.isDragging || (this.state.drag as any).wasRightDragging) {
|
||||||
|
(this.state.drag as any).wasRightDragging = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { clientX, clientY } = event;
|
||||||
|
const rect = this.element?.getBoundingClientRect();
|
||||||
|
const x = rect ? clientX - rect.left : clientX;
|
||||||
|
const y = rect ? clientY - rect.top : clientY;
|
||||||
|
|
||||||
|
this.emit('contextMenu', {
|
||||||
|
type: 'contextMenu',
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
button: 2,
|
||||||
|
originalEvent: event,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMouseLeave(_event: MouseEvent): void {
|
||||||
|
// Don't reset drag state on leave - we track via window events
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Event Emission
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private emit(type: MouseEventType, data: MouseEventData): void {
|
||||||
|
const callbacks = this.callbacks.get(type);
|
||||||
|
if (callbacks) {
|
||||||
|
for (const callback of callbacks) {
|
||||||
|
callback(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Callback Management
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a callback for a specific mouse event type
|
||||||
|
*/
|
||||||
|
on(type: MouseEventType, callback: MouseEventCallback): () => void {
|
||||||
|
if (!this.callbacks.has(type)) {
|
||||||
|
this.callbacks.set(type, new Set());
|
||||||
|
}
|
||||||
|
this.callbacks.get(type)!.add(callback);
|
||||||
|
return () => this.off(type, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a callback for a specific mouse event type
|
||||||
|
*/
|
||||||
|
off(type: MouseEventType, callback: MouseEventCallback): void {
|
||||||
|
this.callbacks.get(type)?.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all callbacks for a specific event type
|
||||||
|
*/
|
||||||
|
offAll(type?: MouseEventType): void {
|
||||||
|
if (type) {
|
||||||
|
this.callbacks.delete(type);
|
||||||
|
} else {
|
||||||
|
this.callbacks.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// State Queries
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current mouse position relative to attached element
|
||||||
|
*/
|
||||||
|
getPosition(): MousePosition {
|
||||||
|
return { ...this.state.position };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if left mouse button is down
|
||||||
|
*/
|
||||||
|
isLeftButtonDown(): boolean {
|
||||||
|
return this.state.isLeftButtonDown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if middle mouse button is down
|
||||||
|
*/
|
||||||
|
isMiddleButtonDown(): boolean {
|
||||||
|
return this.state.isMiddleButtonDown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if right mouse button is down
|
||||||
|
*/
|
||||||
|
isRightButtonDown(): boolean {
|
||||||
|
return this.state.isRightButtonDown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if currently dragging
|
||||||
|
*/
|
||||||
|
isDragging(): boolean {
|
||||||
|
return this.state.drag.isDragging;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current drag state
|
||||||
|
*/
|
||||||
|
getDragState(): Readonly<DragState> {
|
||||||
|
return { ...this.state.drag };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full mouse state
|
||||||
|
*/
|
||||||
|
getState(): Readonly<MouseState> {
|
||||||
|
return {
|
||||||
|
...this.state,
|
||||||
|
position: { ...this.state.position },
|
||||||
|
drag: { ...this.state.drag },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Singleton Instance
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
let mouseHandlerInstance: MouseHandler | null = null;
|
||||||
|
|
||||||
|
export function getMouseHandler(): MouseHandler {
|
||||||
|
if (!mouseHandlerInstance) {
|
||||||
|
mouseHandlerInstance = new MouseHandler();
|
||||||
|
}
|
||||||
|
return mouseHandlerInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetMouseHandler(): void {
|
||||||
|
if (mouseHandlerInstance) {
|
||||||
|
mouseHandlerInstance.detach();
|
||||||
|
mouseHandlerInstance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
818
src/core/renderer/index.ts
Normal file
@@ -0,0 +1,818 @@
|
|||||||
|
// WebGPU Renderer - Main entry point
|
||||||
|
import type { ViewTransform, RenderElement } from '../../types';
|
||||||
|
import { ErrorType, type AppError } from '../../types';
|
||||||
|
import {
|
||||||
|
createRenderPipeline,
|
||||||
|
createUniformBuffer,
|
||||||
|
createBindGroup,
|
||||||
|
createOrthographicMatrix,
|
||||||
|
createModelMatrix,
|
||||||
|
createBorderPipeline,
|
||||||
|
createBorderColorBuffer,
|
||||||
|
createBorderBindGroup,
|
||||||
|
createFillPipeline,
|
||||||
|
createFillColorBuffer,
|
||||||
|
createFillBindGroup,
|
||||||
|
type RenderPipeline,
|
||||||
|
type BorderPipeline,
|
||||||
|
type FillPipeline,
|
||||||
|
} from './pipeline';
|
||||||
|
import {
|
||||||
|
vertexShaderCode,
|
||||||
|
fragmentShaderCode,
|
||||||
|
borderVertexShaderCode,
|
||||||
|
borderFragmentShaderCode,
|
||||||
|
fillVertexShaderCode,
|
||||||
|
fillFragmentShaderCode,
|
||||||
|
} from './shaders';
|
||||||
|
import { TextureManager } from './texture';
|
||||||
|
|
||||||
|
export interface RendererConfig {
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Renderer {
|
||||||
|
private canvas: HTMLCanvasElement | null = null;
|
||||||
|
private device: GPUDevice | null = null;
|
||||||
|
private context: GPUCanvasContext | null = null;
|
||||||
|
private format: GPUTextureFormat = 'bgra8unorm';
|
||||||
|
private pipeline: RenderPipeline | null = null;
|
||||||
|
private borderPipeline: BorderPipeline | null = null;
|
||||||
|
private fillPipeline: FillPipeline | null = null;
|
||||||
|
private textureManager: TextureManager | null = null;
|
||||||
|
|
||||||
|
// Vertex buffer for quad rendering
|
||||||
|
private quadVertexBuffer: GPUBuffer | null = null;
|
||||||
|
private quadIndexBuffer: GPUBuffer | null = null;
|
||||||
|
|
||||||
|
// Vertex buffer for border rendering (line list)
|
||||||
|
private borderVertexBuffer: GPUBuffer | null = null;
|
||||||
|
|
||||||
|
// Border color buffer
|
||||||
|
private borderColorBuffer: GPUBuffer | null = null;
|
||||||
|
|
||||||
|
// Fill color buffer for background
|
||||||
|
private fillColorBuffer: GPUBuffer | null = null;
|
||||||
|
|
||||||
|
// Background color for elements area (#1D1D1D)
|
||||||
|
private elementsBackgroundColor = new Float32Array([0.114, 0.114, 0.114, 1.0]);
|
||||||
|
|
||||||
|
// Uniform buffer pool for element rendering
|
||||||
|
private uniformBuffers: GPUBuffer[] = [];
|
||||||
|
private uniformBufferIndex = 0;
|
||||||
|
|
||||||
|
// Selection border color (cyan)
|
||||||
|
private selectionBorderColor = new Float32Array([0.0, 0.8, 1.0, 1.0]);
|
||||||
|
|
||||||
|
// View transform
|
||||||
|
private viewTransform: ViewTransform = {
|
||||||
|
scale: 1,
|
||||||
|
translateX: 0,
|
||||||
|
translateY: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render state
|
||||||
|
private elements: Map<string, RenderElement> = new Map();
|
||||||
|
private isInitialized = false;
|
||||||
|
private animationFrameId: number | null = null;
|
||||||
|
private needsRender = true;
|
||||||
|
|
||||||
|
// Pending resize state (to avoid flickering)
|
||||||
|
private pendingResize: { width: number; height: number } | null = null;
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
private onError: ((error: AppError) => void) | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if WebGPU is supported
|
||||||
|
*/
|
||||||
|
static async isSupported(): Promise<boolean> {
|
||||||
|
if (!navigator.gpu) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const adapter = await navigator.gpu.requestAdapter();
|
||||||
|
return adapter !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the WebGPU renderer
|
||||||
|
*/
|
||||||
|
async init(canvas: HTMLCanvasElement): Promise<void> {
|
||||||
|
this.canvas = canvas;
|
||||||
|
|
||||||
|
// Check WebGPU support
|
||||||
|
if (!navigator.gpu) {
|
||||||
|
this.emitError({
|
||||||
|
type: ErrorType.WEBGPU_NOT_SUPPORTED,
|
||||||
|
message: 'WebGPU is not supported. Please update your browser or graphics driver.',
|
||||||
|
});
|
||||||
|
throw new Error('WebGPU not supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request adapter
|
||||||
|
const adapter = await navigator.gpu.requestAdapter({
|
||||||
|
powerPreference: 'high-performance',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!adapter) {
|
||||||
|
this.emitError({
|
||||||
|
type: ErrorType.WEBGPU_NOT_SUPPORTED,
|
||||||
|
message: 'Failed to get WebGPU adapter. Please check your graphics driver.',
|
||||||
|
});
|
||||||
|
throw new Error('Failed to get WebGPU adapter');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request device
|
||||||
|
this.device = await adapter.requestDevice({
|
||||||
|
label: 'RefVroid Device',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle device errors
|
||||||
|
this.device.onuncapturederror = (event) => {
|
||||||
|
console.error('WebGPU uncaptured error:', event.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get canvas context
|
||||||
|
this.context = canvas.getContext('webgpu');
|
||||||
|
if (!this.context) {
|
||||||
|
throw new Error('Failed to get WebGPU context');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get preferred format
|
||||||
|
this.format = navigator.gpu.getPreferredCanvasFormat();
|
||||||
|
|
||||||
|
// Configure context
|
||||||
|
this.context.configure({
|
||||||
|
device: this.device,
|
||||||
|
format: this.format,
|
||||||
|
alphaMode: 'premultiplied',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create render pipeline
|
||||||
|
this.pipeline = createRenderPipeline(
|
||||||
|
{ device: this.device, format: this.format },
|
||||||
|
vertexShaderCode,
|
||||||
|
fragmentShaderCode
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create border pipeline for selection borders
|
||||||
|
this.borderPipeline = createBorderPipeline(
|
||||||
|
{ device: this.device, format: this.format },
|
||||||
|
borderVertexShaderCode,
|
||||||
|
borderFragmentShaderCode
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create fill pipeline for background rectangles
|
||||||
|
this.fillPipeline = createFillPipeline(
|
||||||
|
{ device: this.device, format: this.format },
|
||||||
|
fillVertexShaderCode,
|
||||||
|
fillFragmentShaderCode
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create texture manager
|
||||||
|
this.textureManager = new TextureManager(this.device);
|
||||||
|
|
||||||
|
// Create quad geometry buffers
|
||||||
|
this.createQuadBuffers();
|
||||||
|
|
||||||
|
// Create border geometry buffers
|
||||||
|
this.createBorderBuffers();
|
||||||
|
|
||||||
|
// Create fill color buffer
|
||||||
|
this.createFillBuffers();
|
||||||
|
|
||||||
|
this.isInitialized = true;
|
||||||
|
console.log('WebGPU Renderer initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create vertex and index buffers for quad rendering
|
||||||
|
*/
|
||||||
|
private createQuadBuffers(): void {
|
||||||
|
if (!this.device) return;
|
||||||
|
|
||||||
|
// Quad vertices: position (x, y) + texCoord (u, v)
|
||||||
|
// Centered at origin, size 1x1
|
||||||
|
// Note: texCoord Y is NOT flipped here because the orthographic matrix already flips Y
|
||||||
|
const vertices = new Float32Array([
|
||||||
|
// Position // TexCoord
|
||||||
|
-0.5, -0.5, 0.0, 0.0, // Bottom-left -> texture top-left
|
||||||
|
0.5, -0.5, 1.0, 0.0, // Bottom-right -> texture top-right
|
||||||
|
0.5, 0.5, 1.0, 1.0, // Top-right -> texture bottom-right
|
||||||
|
-0.5, 0.5, 0.0, 1.0, // Top-left -> texture bottom-left
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.quadVertexBuffer = this.device.createBuffer({
|
||||||
|
label: 'Quad Vertex Buffer',
|
||||||
|
size: vertices.byteLength,
|
||||||
|
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
||||||
|
});
|
||||||
|
this.device.queue.writeBuffer(this.quadVertexBuffer, 0, vertices);
|
||||||
|
|
||||||
|
// Quad indices (two triangles)
|
||||||
|
const indices = new Uint16Array([
|
||||||
|
0, 1, 2, // First triangle
|
||||||
|
0, 2, 3, // Second triangle
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.quadIndexBuffer = this.device.createBuffer({
|
||||||
|
label: 'Quad Index Buffer',
|
||||||
|
size: indices.byteLength,
|
||||||
|
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
|
||||||
|
});
|
||||||
|
this.device.queue.writeBuffer(this.quadIndexBuffer, 0, indices);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create vertex buffer for border rendering (line list)
|
||||||
|
*/
|
||||||
|
private createBorderBuffers(): void {
|
||||||
|
if (!this.device) return;
|
||||||
|
|
||||||
|
// Border vertices: 4 lines forming a rectangle (8 vertices for line-list)
|
||||||
|
// Centered at origin, size 1x1
|
||||||
|
const borderVertices = new Float32Array([
|
||||||
|
// Line 1: Bottom (left to right)
|
||||||
|
-0.5, -0.5,
|
||||||
|
0.5, -0.5,
|
||||||
|
// Line 2: Right (bottom to top)
|
||||||
|
0.5, -0.5,
|
||||||
|
0.5, 0.5,
|
||||||
|
// Line 3: Top (right to left)
|
||||||
|
0.5, 0.5,
|
||||||
|
-0.5, 0.5,
|
||||||
|
// Line 4: Left (top to bottom)
|
||||||
|
-0.5, 0.5,
|
||||||
|
-0.5, -0.5,
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.borderVertexBuffer = this.device.createBuffer({
|
||||||
|
label: 'Border Vertex Buffer',
|
||||||
|
size: borderVertices.byteLength,
|
||||||
|
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
||||||
|
});
|
||||||
|
this.device.queue.writeBuffer(this.borderVertexBuffer, 0, borderVertices);
|
||||||
|
|
||||||
|
// Create border color buffer
|
||||||
|
this.borderColorBuffer = createBorderColorBuffer(this.device);
|
||||||
|
this.device.queue.writeBuffer(this.borderColorBuffer, 0, this.selectionBorderColor.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create fill color buffer for background rendering
|
||||||
|
*/
|
||||||
|
private createFillBuffers(): void {
|
||||||
|
if (!this.device) return;
|
||||||
|
|
||||||
|
this.fillColorBuffer = createFillColorBuffer(this.device);
|
||||||
|
this.device.queue.writeBuffer(this.fillColorBuffer, 0, this.elementsBackgroundColor.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a uniform buffer from the pool
|
||||||
|
*/
|
||||||
|
private getUniformBuffer(): GPUBuffer {
|
||||||
|
if (!this.device) throw new Error('Device not initialized');
|
||||||
|
|
||||||
|
if (this.uniformBufferIndex >= this.uniformBuffers.length) {
|
||||||
|
const buffer = createUniformBuffer(this.device);
|
||||||
|
this.uniformBuffers.push(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.uniformBuffers[this.uniformBufferIndex++];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the render loop
|
||||||
|
*/
|
||||||
|
startRenderLoop(): void {
|
||||||
|
if (this.animationFrameId !== null) return;
|
||||||
|
|
||||||
|
const renderFrame = () => {
|
||||||
|
if (this.needsRender) {
|
||||||
|
this.render();
|
||||||
|
this.needsRender = false;
|
||||||
|
}
|
||||||
|
this.animationFrameId = requestAnimationFrame(renderFrame);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.animationFrameId = requestAnimationFrame(renderFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the render loop
|
||||||
|
*/
|
||||||
|
stopRenderLoop(): void {
|
||||||
|
if (this.animationFrameId !== null) {
|
||||||
|
cancelAnimationFrame(this.animationFrameId);
|
||||||
|
this.animationFrameId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request a render on the next frame
|
||||||
|
*/
|
||||||
|
requestRender(): void {
|
||||||
|
this.needsRender = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main render function
|
||||||
|
*/
|
||||||
|
render(): void {
|
||||||
|
if (!this.isInitialized || !this.device || !this.context || !this.pipeline) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle pending resize before rendering
|
||||||
|
if (this.pendingResize) {
|
||||||
|
const { width, height } = this.pendingResize;
|
||||||
|
this.pendingResize = null;
|
||||||
|
|
||||||
|
if (this.canvas && (this.canvas.width !== width || this.canvas.height !== height)) {
|
||||||
|
this.canvas.width = width;
|
||||||
|
this.canvas.height = height;
|
||||||
|
|
||||||
|
// Reconfigure context
|
||||||
|
this.context.configure({
|
||||||
|
device: this.device,
|
||||||
|
format: this.format,
|
||||||
|
alphaMode: 'premultiplied',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset uniform buffer pool index
|
||||||
|
this.uniformBufferIndex = 0;
|
||||||
|
|
||||||
|
// Get current texture to render to
|
||||||
|
const currentTexture = this.context.getCurrentTexture();
|
||||||
|
const textureView = currentTexture.createView();
|
||||||
|
|
||||||
|
// Create command encoder
|
||||||
|
const commandEncoder = this.device.createCommandEncoder({
|
||||||
|
label: 'Render Command Encoder',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Begin render pass
|
||||||
|
const renderPass = commandEncoder.beginRenderPass({
|
||||||
|
label: 'Main Render Pass',
|
||||||
|
colorAttachments: [
|
||||||
|
{
|
||||||
|
view: textureView,
|
||||||
|
clearValue: { r: 0.098, g: 0.098, b: 0.098, a: 1.0 }, // #191919
|
||||||
|
loadOp: 'clear',
|
||||||
|
storeOp: 'store',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set pipeline and vertex/index buffers
|
||||||
|
renderPass.setPipeline(this.pipeline.pipeline);
|
||||||
|
renderPass.setVertexBuffer(0, this.quadVertexBuffer!);
|
||||||
|
renderPass.setIndexBuffer(this.quadIndexBuffer!, 'uint16');
|
||||||
|
|
||||||
|
// Calculate view projection matrix
|
||||||
|
const viewProjection = createOrthographicMatrix(
|
||||||
|
this.canvas!.width,
|
||||||
|
this.canvas!.height,
|
||||||
|
this.viewTransform
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort elements by zIndex for proper layering
|
||||||
|
const sortedElements = Array.from(this.elements.values()).sort(
|
||||||
|
(a, b) => a.zIndex - b.zIndex
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render elements background area if there are elements
|
||||||
|
if (sortedElements.length > 0) {
|
||||||
|
this.renderElementsBackground(renderPass, sortedElements, viewProjection);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render each element
|
||||||
|
for (const element of sortedElements) {
|
||||||
|
this.renderElement(renderPass, element, viewProjection);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render selection borders for selected elements
|
||||||
|
this.renderSelectionBorders(renderPass, sortedElements, viewProjection);
|
||||||
|
|
||||||
|
// End render pass
|
||||||
|
renderPass.end();
|
||||||
|
|
||||||
|
// Submit commands
|
||||||
|
this.device.queue.submit([commandEncoder.finish()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render background area for all elements
|
||||||
|
*/
|
||||||
|
private renderElementsBackground(
|
||||||
|
renderPass: GPURenderPassEncoder,
|
||||||
|
elements: RenderElement[],
|
||||||
|
viewProjection: Float32Array
|
||||||
|
): void {
|
||||||
|
if (!this.device || !this.fillPipeline || !this.fillColorBuffer) return;
|
||||||
|
if (elements.length === 0) return;
|
||||||
|
|
||||||
|
// Calculate bounding box of all elements
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
|
||||||
|
for (const element of elements) {
|
||||||
|
minX = Math.min(minX, element.x);
|
||||||
|
minY = Math.min(minY, element.y);
|
||||||
|
maxX = Math.max(maxX, element.x + element.width);
|
||||||
|
maxY = Math.max(maxY, element.y + element.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
const padding = 10; // Add some padding around elements
|
||||||
|
const bgX = minX - padding;
|
||||||
|
const bgY = minY - padding;
|
||||||
|
const bgWidth = maxX - minX + padding * 2;
|
||||||
|
const bgHeight = maxY - minY + padding * 2;
|
||||||
|
|
||||||
|
// Switch to fill pipeline
|
||||||
|
renderPass.setPipeline(this.fillPipeline.pipeline);
|
||||||
|
renderPass.setVertexBuffer(0, this.quadVertexBuffer!);
|
||||||
|
renderPass.setIndexBuffer(this.quadIndexBuffer!, 'uint16');
|
||||||
|
|
||||||
|
// Get uniform buffer
|
||||||
|
const uniformBuffer = this.getUniformBuffer();
|
||||||
|
|
||||||
|
// Create model matrix for background
|
||||||
|
const modelMatrix = createModelMatrix(bgX, bgY, bgWidth, bgHeight, 0);
|
||||||
|
|
||||||
|
// Write matrices to uniform buffer
|
||||||
|
this.device.queue.writeBuffer(uniformBuffer, 0, viewProjection.buffer);
|
||||||
|
this.device.queue.writeBuffer(uniformBuffer, 64, modelMatrix.buffer);
|
||||||
|
|
||||||
|
// Create bind group for fill
|
||||||
|
const bindGroup = createFillBindGroup(
|
||||||
|
this.device,
|
||||||
|
this.fillPipeline.bindGroupLayout,
|
||||||
|
uniformBuffer,
|
||||||
|
this.fillColorBuffer
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draw background
|
||||||
|
renderPass.setBindGroup(0, bindGroup);
|
||||||
|
renderPass.drawIndexed(6);
|
||||||
|
|
||||||
|
// Switch back to main pipeline for element rendering
|
||||||
|
renderPass.setPipeline(this.pipeline!.pipeline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a single element
|
||||||
|
*/
|
||||||
|
private renderElement(
|
||||||
|
renderPass: GPURenderPassEncoder,
|
||||||
|
element: RenderElement,
|
||||||
|
viewProjection: Float32Array
|
||||||
|
): void {
|
||||||
|
if (!this.device || !this.pipeline) return;
|
||||||
|
|
||||||
|
// Get uniform buffer
|
||||||
|
const uniformBuffer = this.getUniformBuffer();
|
||||||
|
|
||||||
|
// Create model matrix
|
||||||
|
const modelMatrix = createModelMatrix(
|
||||||
|
element.x,
|
||||||
|
element.y,
|
||||||
|
element.width,
|
||||||
|
element.height,
|
||||||
|
element.rotation
|
||||||
|
);
|
||||||
|
|
||||||
|
// Write matrices to uniform buffer
|
||||||
|
this.device.queue.writeBuffer(uniformBuffer, 0, viewProjection.buffer);
|
||||||
|
this.device.queue.writeBuffer(uniformBuffer, 64, modelMatrix.buffer);
|
||||||
|
|
||||||
|
// Create bind group for this element
|
||||||
|
const bindGroup = createBindGroup(
|
||||||
|
this.device,
|
||||||
|
this.pipeline.bindGroupLayout,
|
||||||
|
uniformBuffer,
|
||||||
|
this.pipeline.sampler,
|
||||||
|
element.texture
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draw
|
||||||
|
renderPass.setBindGroup(0, bindGroup);
|
||||||
|
renderPass.drawIndexed(6); // 6 indices for 2 triangles
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render selection borders for selected elements
|
||||||
|
*/
|
||||||
|
private renderSelectionBorders(
|
||||||
|
renderPass: GPURenderPassEncoder,
|
||||||
|
elements: RenderElement[],
|
||||||
|
viewProjection: Float32Array
|
||||||
|
): void {
|
||||||
|
if (!this.device || !this.borderPipeline || !this.borderVertexBuffer || !this.borderColorBuffer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter selected elements
|
||||||
|
const selectedElements = elements.filter((e) => e.selected);
|
||||||
|
if (selectedElements.length === 0) return;
|
||||||
|
|
||||||
|
// Switch to border pipeline
|
||||||
|
renderPass.setPipeline(this.borderPipeline.pipeline);
|
||||||
|
renderPass.setVertexBuffer(0, this.borderVertexBuffer);
|
||||||
|
|
||||||
|
// Render border for each selected element
|
||||||
|
for (const element of selectedElements) {
|
||||||
|
this.renderElementBorder(renderPass, element, viewProjection);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render group bounding box if multiple elements selected
|
||||||
|
if (selectedElements.length > 1) {
|
||||||
|
this.renderGroupBorder(renderPass, selectedElements, viewProjection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render group bounding box for multiple selected elements
|
||||||
|
*/
|
||||||
|
private renderGroupBorder(
|
||||||
|
renderPass: GPURenderPassEncoder,
|
||||||
|
elements: RenderElement[],
|
||||||
|
viewProjection: Float32Array
|
||||||
|
): void {
|
||||||
|
if (!this.device || !this.borderPipeline || !this.borderColorBuffer) return;
|
||||||
|
|
||||||
|
// Calculate bounding box of all selected elements
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
|
||||||
|
for (const element of elements) {
|
||||||
|
minX = Math.min(minX, element.x);
|
||||||
|
minY = Math.min(minY, element.y);
|
||||||
|
maxX = Math.max(maxX, element.x + element.width);
|
||||||
|
maxY = Math.max(maxY, element.y + element.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupWidth = maxX - minX;
|
||||||
|
const groupHeight = maxY - minY;
|
||||||
|
|
||||||
|
// Get uniform buffer
|
||||||
|
const uniformBuffer = this.getUniformBuffer();
|
||||||
|
|
||||||
|
// Create model matrix for group border with padding
|
||||||
|
const borderPadding = 4; // Slightly larger padding for group border
|
||||||
|
const modelMatrix = createModelMatrix(
|
||||||
|
minX - borderPadding,
|
||||||
|
minY - borderPadding,
|
||||||
|
groupWidth + borderPadding * 2,
|
||||||
|
groupHeight + borderPadding * 2,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Write matrices to uniform buffer
|
||||||
|
this.device.queue.writeBuffer(uniformBuffer, 0, viewProjection.buffer);
|
||||||
|
this.device.queue.writeBuffer(uniformBuffer, 64, modelMatrix.buffer);
|
||||||
|
|
||||||
|
// Create bind group for border
|
||||||
|
const bindGroup = createBorderBindGroup(
|
||||||
|
this.device,
|
||||||
|
this.borderPipeline.bindGroupLayout,
|
||||||
|
uniformBuffer,
|
||||||
|
this.borderColorBuffer
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draw group border
|
||||||
|
renderPass.setBindGroup(0, bindGroup);
|
||||||
|
renderPass.draw(8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render border for a single element
|
||||||
|
*/
|
||||||
|
private renderElementBorder(
|
||||||
|
renderPass: GPURenderPassEncoder,
|
||||||
|
element: RenderElement,
|
||||||
|
viewProjection: Float32Array
|
||||||
|
): void {
|
||||||
|
if (!this.device || !this.borderPipeline || !this.borderColorBuffer) return;
|
||||||
|
|
||||||
|
// Get uniform buffer
|
||||||
|
const uniformBuffer = this.getUniformBuffer();
|
||||||
|
|
||||||
|
// Create model matrix with slight expansion for border visibility
|
||||||
|
const borderPadding = 2; // pixels
|
||||||
|
const modelMatrix = createModelMatrix(
|
||||||
|
element.x - borderPadding,
|
||||||
|
element.y - borderPadding,
|
||||||
|
element.width + borderPadding * 2,
|
||||||
|
element.height + borderPadding * 2,
|
||||||
|
element.rotation
|
||||||
|
);
|
||||||
|
|
||||||
|
// Write matrices to uniform buffer
|
||||||
|
this.device.queue.writeBuffer(uniformBuffer, 0, viewProjection.buffer);
|
||||||
|
this.device.queue.writeBuffer(uniformBuffer, 64, modelMatrix.buffer);
|
||||||
|
|
||||||
|
// Create bind group for border
|
||||||
|
const bindGroup = createBorderBindGroup(
|
||||||
|
this.device,
|
||||||
|
this.borderPipeline.bindGroupLayout,
|
||||||
|
uniformBuffer,
|
||||||
|
this.borderColorBuffer
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draw border (8 vertices = 4 lines)
|
||||||
|
renderPass.setBindGroup(0, bindGroup);
|
||||||
|
renderPass.draw(8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set selection border color
|
||||||
|
*/
|
||||||
|
setSelectionBorderColor(r: number, g: number, b: number, a: number = 1.0): void {
|
||||||
|
this.selectionBorderColor[0] = r;
|
||||||
|
this.selectionBorderColor[1] = g;
|
||||||
|
this.selectionBorderColor[2] = b;
|
||||||
|
this.selectionBorderColor[3] = a;
|
||||||
|
|
||||||
|
if (this.device && this.borderColorBuffer) {
|
||||||
|
this.device.queue.writeBuffer(this.borderColorBuffer, 0, this.selectionBorderColor.buffer);
|
||||||
|
}
|
||||||
|
this.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an element to render
|
||||||
|
*/
|
||||||
|
addElement(element: RenderElement): void {
|
||||||
|
this.elements.set(element.id, element);
|
||||||
|
this.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an element
|
||||||
|
*/
|
||||||
|
removeElement(id: string): void {
|
||||||
|
const element = this.elements.get(id);
|
||||||
|
if (element) {
|
||||||
|
// Texture cleanup is handled by TextureManager
|
||||||
|
this.elements.delete(id);
|
||||||
|
this.requestRender();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an element's properties
|
||||||
|
*/
|
||||||
|
updateElement(id: string, props: Partial<RenderElement>): void {
|
||||||
|
const element = this.elements.get(id);
|
||||||
|
if (element) {
|
||||||
|
Object.assign(element, props);
|
||||||
|
this.requestRender();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an element by ID
|
||||||
|
*/
|
||||||
|
getElement(id: string): RenderElement | undefined {
|
||||||
|
return this.elements.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all elements
|
||||||
|
*/
|
||||||
|
getAllElements(): RenderElement[] {
|
||||||
|
return Array.from(this.elements.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all elements
|
||||||
|
*/
|
||||||
|
clearElements(): void {
|
||||||
|
this.elements.clear();
|
||||||
|
this.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set view transform (scale and translation)
|
||||||
|
*/
|
||||||
|
setViewTransform(transform: Partial<ViewTransform>): void {
|
||||||
|
Object.assign(this.viewTransform, transform);
|
||||||
|
this.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current view transform
|
||||||
|
*/
|
||||||
|
getViewTransform(): ViewTransform {
|
||||||
|
return { ...this.viewTransform };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the texture manager
|
||||||
|
*/
|
||||||
|
getTextureManager(): TextureManager | null {
|
||||||
|
return this.textureManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the GPU device
|
||||||
|
*/
|
||||||
|
getDevice(): GPUDevice | null {
|
||||||
|
return this.device;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize the canvas
|
||||||
|
* Note: Actual resize is deferred to render() to avoid flickering
|
||||||
|
*/
|
||||||
|
resize(width: number, height: number): void {
|
||||||
|
if (!this.canvas || !this.context || !this.device) return;
|
||||||
|
|
||||||
|
// Only schedule resize if dimensions actually changed
|
||||||
|
if (this.canvas.width !== width || this.canvas.height !== height) {
|
||||||
|
this.pendingResize = { width, height };
|
||||||
|
this.requestRender();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set error callback
|
||||||
|
*/
|
||||||
|
setOnError(callback: (error: AppError) => void): void {
|
||||||
|
this.onError = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit an error
|
||||||
|
*/
|
||||||
|
private emitError(error: AppError): void {
|
||||||
|
console.error('Renderer error:', error);
|
||||||
|
if (this.onError) {
|
||||||
|
this.onError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if renderer is initialized
|
||||||
|
*/
|
||||||
|
isReady(): boolean {
|
||||||
|
return this.isInitialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy the renderer and release resources
|
||||||
|
*/
|
||||||
|
destroy(): void {
|
||||||
|
this.stopRenderLoop();
|
||||||
|
|
||||||
|
// Destroy texture manager
|
||||||
|
if (this.textureManager) {
|
||||||
|
this.textureManager.destroyAll();
|
||||||
|
this.textureManager = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destroy buffers
|
||||||
|
this.quadVertexBuffer?.destroy();
|
||||||
|
this.quadIndexBuffer?.destroy();
|
||||||
|
this.borderVertexBuffer?.destroy();
|
||||||
|
this.borderColorBuffer?.destroy();
|
||||||
|
this.fillColorBuffer?.destroy();
|
||||||
|
for (const buffer of this.uniformBuffers) {
|
||||||
|
buffer.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.quadVertexBuffer = null;
|
||||||
|
this.quadIndexBuffer = null;
|
||||||
|
this.borderVertexBuffer = null;
|
||||||
|
this.borderColorBuffer = null;
|
||||||
|
this.fillColorBuffer = null;
|
||||||
|
this.uniformBuffers = [];
|
||||||
|
|
||||||
|
// Clear elements
|
||||||
|
this.elements.clear();
|
||||||
|
|
||||||
|
// Clear references
|
||||||
|
this.device = null;
|
||||||
|
this.context = null;
|
||||||
|
this.pipeline = null;
|
||||||
|
this.borderPipeline = null;
|
||||||
|
this.fillPipeline = null;
|
||||||
|
this.canvas = null;
|
||||||
|
|
||||||
|
this.isInitialized = false;
|
||||||
|
console.log('WebGPU Renderer destroyed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance for convenience
|
||||||
|
export const renderer = new Renderer();
|
||||||
554
src/core/renderer/pipeline.ts
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
// WebGPU Render Pipeline configuration
|
||||||
|
import type { ViewTransform } from '../../types';
|
||||||
|
|
||||||
|
export interface RenderPipelineConfig {
|
||||||
|
device: GPUDevice;
|
||||||
|
format: GPUTextureFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderPipeline {
|
||||||
|
device: GPUDevice;
|
||||||
|
pipeline: GPURenderPipeline;
|
||||||
|
bindGroupLayout: GPUBindGroupLayout;
|
||||||
|
sampler: GPUSampler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the WebGPU render pipeline for rendering textured quads
|
||||||
|
*/
|
||||||
|
export function createRenderPipeline(
|
||||||
|
config: RenderPipelineConfig,
|
||||||
|
vertexShaderCode: string,
|
||||||
|
fragmentShaderCode: string
|
||||||
|
): RenderPipeline {
|
||||||
|
const { device, format } = config;
|
||||||
|
|
||||||
|
// Create shader modules
|
||||||
|
const vertexModule = device.createShaderModule({
|
||||||
|
label: 'Vertex Shader',
|
||||||
|
code: vertexShaderCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fragmentModule = device.createShaderModule({
|
||||||
|
label: 'Fragment Shader',
|
||||||
|
code: fragmentShaderCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create bind group layout for uniforms and textures
|
||||||
|
const bindGroupLayout = device.createBindGroupLayout({
|
||||||
|
label: 'Render Bind Group Layout',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
// Uniform buffer for view and model matrices
|
||||||
|
binding: 0,
|
||||||
|
visibility: GPUShaderStage.VERTEX,
|
||||||
|
buffer: { type: 'uniform' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Texture sampler
|
||||||
|
binding: 1,
|
||||||
|
visibility: GPUShaderStage.FRAGMENT,
|
||||||
|
sampler: { type: 'filtering' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Texture
|
||||||
|
binding: 2,
|
||||||
|
visibility: GPUShaderStage.FRAGMENT,
|
||||||
|
texture: { sampleType: 'float' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Create pipeline layout
|
||||||
|
const pipelineLayout = device.createPipelineLayout({
|
||||||
|
label: 'Render Pipeline Layout',
|
||||||
|
bindGroupLayouts: [bindGroupLayout],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create render pipeline
|
||||||
|
const pipeline = device.createRenderPipeline({
|
||||||
|
label: 'Main Render Pipeline',
|
||||||
|
layout: pipelineLayout,
|
||||||
|
vertex: {
|
||||||
|
module: vertexModule,
|
||||||
|
entryPoint: 'main',
|
||||||
|
buffers: [
|
||||||
|
{
|
||||||
|
// Vertex buffer layout: position (vec2) + texCoord (vec2)
|
||||||
|
arrayStride: 4 * 4, // 4 floats * 4 bytes
|
||||||
|
attributes: [
|
||||||
|
{
|
||||||
|
// Position
|
||||||
|
shaderLocation: 0,
|
||||||
|
offset: 0,
|
||||||
|
format: 'float32x2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Texture coordinates
|
||||||
|
shaderLocation: 1,
|
||||||
|
offset: 2 * 4, // 2 floats offset
|
||||||
|
format: 'float32x2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fragment: {
|
||||||
|
module: fragmentModule,
|
||||||
|
entryPoint: 'main',
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
format,
|
||||||
|
blend: {
|
||||||
|
// Enable alpha blending for transparency
|
||||||
|
color: {
|
||||||
|
srcFactor: 'src-alpha',
|
||||||
|
dstFactor: 'one-minus-src-alpha',
|
||||||
|
operation: 'add',
|
||||||
|
},
|
||||||
|
alpha: {
|
||||||
|
srcFactor: 'one',
|
||||||
|
dstFactor: 'one-minus-src-alpha',
|
||||||
|
operation: 'add',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
primitive: {
|
||||||
|
topology: 'triangle-list',
|
||||||
|
cullMode: 'none',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create texture sampler
|
||||||
|
const sampler = device.createSampler({
|
||||||
|
label: 'Texture Sampler',
|
||||||
|
magFilter: 'linear',
|
||||||
|
minFilter: 'linear',
|
||||||
|
addressModeU: 'clamp-to-edge',
|
||||||
|
addressModeV: 'clamp-to-edge',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
device,
|
||||||
|
pipeline,
|
||||||
|
bindGroupLayout,
|
||||||
|
sampler,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a uniform buffer for view and model matrices
|
||||||
|
* Layout: viewProjection (mat4x4) + model (mat4x4) = 128 bytes
|
||||||
|
*/
|
||||||
|
export function createUniformBuffer(device: GPUDevice): GPUBuffer {
|
||||||
|
return device.createBuffer({
|
||||||
|
label: 'Uniform Buffer',
|
||||||
|
size: 128, // 2 * mat4x4 (64 bytes each)
|
||||||
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a bind group for rendering an element
|
||||||
|
*/
|
||||||
|
export function createBindGroup(
|
||||||
|
device: GPUDevice,
|
||||||
|
layout: GPUBindGroupLayout,
|
||||||
|
uniformBuffer: GPUBuffer,
|
||||||
|
sampler: GPUSampler,
|
||||||
|
texture: GPUTexture
|
||||||
|
): GPUBindGroup {
|
||||||
|
return device.createBindGroup({
|
||||||
|
label: 'Element Bind Group',
|
||||||
|
layout,
|
||||||
|
entries: [
|
||||||
|
{ binding: 0, resource: { buffer: uniformBuffer } },
|
||||||
|
{ binding: 1, resource: sampler },
|
||||||
|
{ binding: 2, resource: texture.createView() },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an orthographic projection matrix for 2D rendering
|
||||||
|
*
|
||||||
|
* The transformation pipeline is:
|
||||||
|
* 1. World coordinates -> Screen coordinates: screenPos = worldPos * scale + translate
|
||||||
|
* 2. Screen coordinates -> Clip space: clipPos = (screenPos / canvasSize) * 2 - 1
|
||||||
|
*
|
||||||
|
* Combined: clipX = (worldX * scale + translateX) * (2/width) - 1
|
||||||
|
* clipY = -((worldY * scale + translateY) * (2/height) - 1) // Y flipped
|
||||||
|
*/
|
||||||
|
export function createOrthographicMatrix(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
transform: ViewTransform
|
||||||
|
): Float32Array {
|
||||||
|
const { scale, translateX, translateY } = transform;
|
||||||
|
|
||||||
|
const matrix = new Float32Array(16);
|
||||||
|
|
||||||
|
// Scale factors: world to clip space
|
||||||
|
const sx = (2 * scale) / width;
|
||||||
|
const sy = (-2 * scale) / height; // Flip Y axis (screen Y goes down, clip Y goes up)
|
||||||
|
|
||||||
|
// Translation: apply translate then convert to clip space
|
||||||
|
const tx = (2 * translateX) / width - 1;
|
||||||
|
const ty = 1 - (2 * translateY) / height; // Flipped Y
|
||||||
|
|
||||||
|
// Column-major order for WebGPU
|
||||||
|
matrix[0] = sx;
|
||||||
|
matrix[1] = 0;
|
||||||
|
matrix[2] = 0;
|
||||||
|
matrix[3] = 0;
|
||||||
|
|
||||||
|
matrix[4] = 0;
|
||||||
|
matrix[5] = sy;
|
||||||
|
matrix[6] = 0;
|
||||||
|
matrix[7] = 0;
|
||||||
|
|
||||||
|
matrix[8] = 0;
|
||||||
|
matrix[9] = 0;
|
||||||
|
matrix[10] = 1;
|
||||||
|
matrix[11] = 0;
|
||||||
|
|
||||||
|
matrix[12] = tx;
|
||||||
|
matrix[13] = ty;
|
||||||
|
matrix[14] = 0;
|
||||||
|
matrix[15] = 1;
|
||||||
|
|
||||||
|
return matrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a model matrix for element transformation
|
||||||
|
*/
|
||||||
|
export function createModelMatrix(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
rotation: number = 0
|
||||||
|
): Float32Array {
|
||||||
|
const matrix = new Float32Array(16);
|
||||||
|
|
||||||
|
const cos = Math.cos(rotation);
|
||||||
|
const sin = Math.sin(rotation);
|
||||||
|
|
||||||
|
// Translation to center, then scale, then rotate, then translate to position
|
||||||
|
// Combined transformation matrix (column-major)
|
||||||
|
const cx = x + width / 2;
|
||||||
|
const cy = y + height / 2;
|
||||||
|
|
||||||
|
matrix[0] = width * cos;
|
||||||
|
matrix[1] = width * sin;
|
||||||
|
matrix[2] = 0;
|
||||||
|
matrix[3] = 0;
|
||||||
|
|
||||||
|
matrix[4] = -height * sin;
|
||||||
|
matrix[5] = height * cos;
|
||||||
|
matrix[6] = 0;
|
||||||
|
matrix[7] = 0;
|
||||||
|
|
||||||
|
matrix[8] = 0;
|
||||||
|
matrix[9] = 0;
|
||||||
|
matrix[10] = 1;
|
||||||
|
matrix[11] = 0;
|
||||||
|
|
||||||
|
matrix[12] = cx;
|
||||||
|
matrix[13] = cy;
|
||||||
|
matrix[14] = 0;
|
||||||
|
matrix[15] = 1;
|
||||||
|
|
||||||
|
return matrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Border render pipeline for selection borders
|
||||||
|
*/
|
||||||
|
export interface BorderPipeline {
|
||||||
|
device: GPUDevice;
|
||||||
|
pipeline: GPURenderPipeline;
|
||||||
|
bindGroupLayout: GPUBindGroupLayout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the border render pipeline for rendering selection borders
|
||||||
|
*/
|
||||||
|
export function createBorderPipeline(
|
||||||
|
config: RenderPipelineConfig,
|
||||||
|
vertexShaderCode: string,
|
||||||
|
fragmentShaderCode: string
|
||||||
|
): BorderPipeline {
|
||||||
|
const { device, format } = config;
|
||||||
|
|
||||||
|
// Create shader modules
|
||||||
|
const vertexModule = device.createShaderModule({
|
||||||
|
label: 'Border Vertex Shader',
|
||||||
|
code: vertexShaderCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fragmentModule = device.createShaderModule({
|
||||||
|
label: 'Border Fragment Shader',
|
||||||
|
code: fragmentShaderCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create bind group layout for uniforms
|
||||||
|
const bindGroupLayout = device.createBindGroupLayout({
|
||||||
|
label: 'Border Bind Group Layout',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
// Uniform buffer for view and model matrices
|
||||||
|
binding: 0,
|
||||||
|
visibility: GPUShaderStage.VERTEX,
|
||||||
|
buffer: { type: 'uniform' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Uniform buffer for border color
|
||||||
|
binding: 1,
|
||||||
|
visibility: GPUShaderStage.FRAGMENT,
|
||||||
|
buffer: { type: 'uniform' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create pipeline layout
|
||||||
|
const pipelineLayout = device.createPipelineLayout({
|
||||||
|
label: 'Border Pipeline Layout',
|
||||||
|
bindGroupLayouts: [bindGroupLayout],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create render pipeline for line rendering
|
||||||
|
const pipeline = device.createRenderPipeline({
|
||||||
|
label: 'Border Render Pipeline',
|
||||||
|
layout: pipelineLayout,
|
||||||
|
vertex: {
|
||||||
|
module: vertexModule,
|
||||||
|
entryPoint: 'main',
|
||||||
|
buffers: [
|
||||||
|
{
|
||||||
|
// Vertex buffer layout: position (vec2) only
|
||||||
|
arrayStride: 2 * 4, // 2 floats * 4 bytes
|
||||||
|
attributes: [
|
||||||
|
{
|
||||||
|
shaderLocation: 0,
|
||||||
|
offset: 0,
|
||||||
|
format: 'float32x2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fragment: {
|
||||||
|
module: fragmentModule,
|
||||||
|
entryPoint: 'main',
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
format,
|
||||||
|
blend: {
|
||||||
|
color: {
|
||||||
|
srcFactor: 'src-alpha',
|
||||||
|
dstFactor: 'one-minus-src-alpha',
|
||||||
|
operation: 'add',
|
||||||
|
},
|
||||||
|
alpha: {
|
||||||
|
srcFactor: 'one',
|
||||||
|
dstFactor: 'one-minus-src-alpha',
|
||||||
|
operation: 'add',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
primitive: {
|
||||||
|
topology: 'line-list',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
device,
|
||||||
|
pipeline,
|
||||||
|
bindGroupLayout,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a uniform buffer for border color
|
||||||
|
* Layout: color (vec4) = 16 bytes
|
||||||
|
*/
|
||||||
|
export function createBorderColorBuffer(device: GPUDevice): GPUBuffer {
|
||||||
|
return device.createBuffer({
|
||||||
|
label: 'Border Color Buffer',
|
||||||
|
size: 16, // vec4 (16 bytes)
|
||||||
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a bind group for border rendering
|
||||||
|
*/
|
||||||
|
export function createBorderBindGroup(
|
||||||
|
device: GPUDevice,
|
||||||
|
layout: GPUBindGroupLayout,
|
||||||
|
uniformBuffer: GPUBuffer,
|
||||||
|
colorBuffer: GPUBuffer
|
||||||
|
): GPUBindGroup {
|
||||||
|
return device.createBindGroup({
|
||||||
|
label: 'Border Bind Group',
|
||||||
|
layout,
|
||||||
|
entries: [
|
||||||
|
{ binding: 0, resource: { buffer: uniformBuffer } },
|
||||||
|
{ binding: 1, resource: { buffer: colorBuffer } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill render pipeline for solid color rectangles
|
||||||
|
*/
|
||||||
|
export interface FillPipeline {
|
||||||
|
device: GPUDevice;
|
||||||
|
pipeline: GPURenderPipeline;
|
||||||
|
bindGroupLayout: GPUBindGroupLayout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the fill render pipeline for rendering solid color rectangles
|
||||||
|
*/
|
||||||
|
export function createFillPipeline(
|
||||||
|
config: RenderPipelineConfig,
|
||||||
|
vertexShaderCode: string,
|
||||||
|
fragmentShaderCode: string
|
||||||
|
): FillPipeline {
|
||||||
|
const { device, format } = config;
|
||||||
|
|
||||||
|
// Create shader modules
|
||||||
|
const vertexModule = device.createShaderModule({
|
||||||
|
label: 'Fill Vertex Shader',
|
||||||
|
code: vertexShaderCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fragmentModule = device.createShaderModule({
|
||||||
|
label: 'Fill Fragment Shader',
|
||||||
|
code: fragmentShaderCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create bind group layout for uniforms
|
||||||
|
const bindGroupLayout = device.createBindGroupLayout({
|
||||||
|
label: 'Fill Bind Group Layout',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
// Uniform buffer for view and model matrices
|
||||||
|
binding: 0,
|
||||||
|
visibility: GPUShaderStage.VERTEX,
|
||||||
|
buffer: { type: 'uniform' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Uniform buffer for fill color
|
||||||
|
binding: 1,
|
||||||
|
visibility: GPUShaderStage.FRAGMENT,
|
||||||
|
buffer: { type: 'uniform' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create pipeline layout
|
||||||
|
const pipelineLayout = device.createPipelineLayout({
|
||||||
|
label: 'Fill Pipeline Layout',
|
||||||
|
bindGroupLayouts: [bindGroupLayout],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create render pipeline for triangle rendering
|
||||||
|
const pipeline = device.createRenderPipeline({
|
||||||
|
label: 'Fill Render Pipeline',
|
||||||
|
layout: pipelineLayout,
|
||||||
|
vertex: {
|
||||||
|
module: vertexModule,
|
||||||
|
entryPoint: 'main',
|
||||||
|
buffers: [
|
||||||
|
{
|
||||||
|
// Vertex buffer layout: position (vec2) + texCoord (vec2)
|
||||||
|
arrayStride: 4 * 4, // 4 floats * 4 bytes
|
||||||
|
attributes: [
|
||||||
|
{
|
||||||
|
shaderLocation: 0,
|
||||||
|
offset: 0,
|
||||||
|
format: 'float32x2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shaderLocation: 1,
|
||||||
|
offset: 2 * 4,
|
||||||
|
format: 'float32x2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fragment: {
|
||||||
|
module: fragmentModule,
|
||||||
|
entryPoint: 'main',
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
format,
|
||||||
|
blend: {
|
||||||
|
color: {
|
||||||
|
srcFactor: 'src-alpha',
|
||||||
|
dstFactor: 'one-minus-src-alpha',
|
||||||
|
operation: 'add',
|
||||||
|
},
|
||||||
|
alpha: {
|
||||||
|
srcFactor: 'one',
|
||||||
|
dstFactor: 'one-minus-src-alpha',
|
||||||
|
operation: 'add',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
primitive: {
|
||||||
|
topology: 'triangle-list',
|
||||||
|
cullMode: 'none',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
device,
|
||||||
|
pipeline,
|
||||||
|
bindGroupLayout,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a uniform buffer for fill color
|
||||||
|
*/
|
||||||
|
export function createFillColorBuffer(device: GPUDevice): GPUBuffer {
|
||||||
|
return device.createBuffer({
|
||||||
|
label: 'Fill Color Buffer',
|
||||||
|
size: 16, // vec4 (16 bytes)
|
||||||
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a bind group for fill rendering
|
||||||
|
*/
|
||||||
|
export function createFillBindGroup(
|
||||||
|
device: GPUDevice,
|
||||||
|
layout: GPUBindGroupLayout,
|
||||||
|
uniformBuffer: GPUBuffer,
|
||||||
|
colorBuffer: GPUBuffer
|
||||||
|
): GPUBindGroup {
|
||||||
|
return device.createBindGroup({
|
||||||
|
label: 'Fill Bind Group',
|
||||||
|
layout,
|
||||||
|
entries: [
|
||||||
|
{ binding: 0, resource: { buffer: uniformBuffer } },
|
||||||
|
{ binding: 1, resource: { buffer: colorBuffer } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
164
src/core/renderer/shaders.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
// WGSL Shader definitions for WebGPU rendering
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertex shader for rendering textured quads
|
||||||
|
*
|
||||||
|
* Uniforms:
|
||||||
|
* - viewProjection: mat4x4<f32> - Combined view and projection matrix
|
||||||
|
* - model: mat4x4<f32> - Model transformation matrix
|
||||||
|
*
|
||||||
|
* Inputs:
|
||||||
|
* - position: vec2<f32> - Vertex position in local space
|
||||||
|
* - texCoord: vec2<f32> - Texture coordinates
|
||||||
|
*
|
||||||
|
* Outputs:
|
||||||
|
* - position: vec4<f32> - Clip space position
|
||||||
|
* - texCoord: vec2<f32> - Interpolated texture coordinates
|
||||||
|
*/
|
||||||
|
export const vertexShaderCode = /* wgsl */ `
|
||||||
|
struct VertexInput {
|
||||||
|
@location(0) position: vec2<f32>,
|
||||||
|
@location(1) texCoord: vec2<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VertexOutput {
|
||||||
|
@builtin(position) position: vec4<f32>,
|
||||||
|
@location(0) texCoord: vec2<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Uniforms {
|
||||||
|
viewProjection: mat4x4<f32>,
|
||||||
|
model: mat4x4<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn main(input: VertexInput) -> VertexOutput {
|
||||||
|
var output: VertexOutput;
|
||||||
|
|
||||||
|
// Transform vertex position: model -> view -> projection
|
||||||
|
let worldPos = uniforms.model * vec4<f32>(input.position, 0.0, 1.0);
|
||||||
|
output.position = uniforms.viewProjection * worldPos;
|
||||||
|
|
||||||
|
// Pass through texture coordinates
|
||||||
|
output.texCoord = input.texCoord;
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragment shader for texture sampling
|
||||||
|
*
|
||||||
|
* Inputs:
|
||||||
|
* - texCoord: vec2<f32> - Interpolated texture coordinates
|
||||||
|
*
|
||||||
|
* Outputs:
|
||||||
|
* - color: vec4<f32> - Final fragment color
|
||||||
|
*/
|
||||||
|
export const fragmentShaderCode = /* wgsl */ `
|
||||||
|
@group(0) @binding(1) var textureSampler: sampler;
|
||||||
|
@group(0) @binding(2) var textureData: texture_2d<f32>;
|
||||||
|
|
||||||
|
struct FragmentInput {
|
||||||
|
@location(0) texCoord: vec2<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn main(input: FragmentInput) -> @location(0) vec4<f32> {
|
||||||
|
return textureSample(textureData, textureSampler, input.texCoord);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertex shader for selection border rendering
|
||||||
|
* Uses line primitives to draw element borders
|
||||||
|
*/
|
||||||
|
export const borderVertexShaderCode = /* wgsl */ `
|
||||||
|
struct VertexInput {
|
||||||
|
@location(0) position: vec2<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VertexOutput {
|
||||||
|
@builtin(position) position: vec4<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Uniforms {
|
||||||
|
viewProjection: mat4x4<f32>,
|
||||||
|
model: mat4x4<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn main(input: VertexInput) -> VertexOutput {
|
||||||
|
var output: VertexOutput;
|
||||||
|
let worldPos = uniforms.model * vec4<f32>(input.position, 0.0, 1.0);
|
||||||
|
output.position = uniforms.viewProjection * worldPos;
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragment shader for selection border (solid color)
|
||||||
|
*/
|
||||||
|
export const borderFragmentShaderCode = /* wgsl */ `
|
||||||
|
struct BorderUniforms {
|
||||||
|
color: vec4<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
@group(0) @binding(1) var<uniform> borderUniforms: BorderUniforms;
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn main() -> @location(0) vec4<f32> {
|
||||||
|
return borderUniforms.color;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertex shader for solid color fill rendering
|
||||||
|
* Uses the same quad geometry as texture rendering
|
||||||
|
*/
|
||||||
|
export const fillVertexShaderCode = /* wgsl */ `
|
||||||
|
struct VertexInput {
|
||||||
|
@location(0) position: vec2<f32>,
|
||||||
|
@location(1) texCoord: vec2<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VertexOutput {
|
||||||
|
@builtin(position) position: vec4<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Uniforms {
|
||||||
|
viewProjection: mat4x4<f32>,
|
||||||
|
model: mat4x4<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn main(input: VertexInput) -> VertexOutput {
|
||||||
|
var output: VertexOutput;
|
||||||
|
let worldPos = uniforms.model * vec4<f32>(input.position, 0.0, 1.0);
|
||||||
|
output.position = uniforms.viewProjection * worldPos;
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragment shader for solid color fill
|
||||||
|
*/
|
||||||
|
export const fillFragmentShaderCode = /* wgsl */ `
|
||||||
|
struct FillUniforms {
|
||||||
|
color: vec4<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
@group(0) @binding(1) var<uniform> fillUniforms: FillUniforms;
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn main() -> @location(0) vec4<f32> {
|
||||||
|
return fillUniforms.color;
|
||||||
|
}
|
||||||
|
`;
|
||||||
312
src/core/renderer/texture.ts
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
// Texture Manager for WebGPU
|
||||||
|
import type { AppError } from '../../types';
|
||||||
|
|
||||||
|
export interface TextureInfo {
|
||||||
|
texture: GPUTexture;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
source: 'image' | 'video' | 'canvas';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages GPU textures for the WebGPU renderer
|
||||||
|
*/
|
||||||
|
export class TextureManager {
|
||||||
|
private device: GPUDevice;
|
||||||
|
private textures: Map<string, TextureInfo> = new Map();
|
||||||
|
private onError: ((error: AppError) => void) | null = null;
|
||||||
|
private placeholderTexture: GPUTexture | null = null;
|
||||||
|
|
||||||
|
constructor(device: GPUDevice) {
|
||||||
|
this.device = device;
|
||||||
|
this.createPlaceholderTexture();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a placeholder texture (1x1 gray pixel)
|
||||||
|
* Used when actual texture is not yet loaded
|
||||||
|
*/
|
||||||
|
private createPlaceholderTexture(): void {
|
||||||
|
this.placeholderTexture = this.device.createTexture({
|
||||||
|
label: 'Placeholder Texture',
|
||||||
|
size: { width: 1, height: 1 },
|
||||||
|
format: 'rgba8unorm',
|
||||||
|
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write a gray pixel
|
||||||
|
const data = new Uint8Array([128, 128, 128, 255]);
|
||||||
|
this.device.queue.writeTexture(
|
||||||
|
{ texture: this.placeholderTexture },
|
||||||
|
data,
|
||||||
|
{ bytesPerRow: 4 },
|
||||||
|
{ width: 1, height: 1 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the placeholder texture
|
||||||
|
*/
|
||||||
|
getPlaceholderTexture(): GPUTexture | null {
|
||||||
|
return this.placeholderTexture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a texture from an ImageBitmap
|
||||||
|
*/
|
||||||
|
createFromImage(id: string, image: ImageBitmap): GPUTexture {
|
||||||
|
const texture = this.device.createTexture({
|
||||||
|
label: `Texture: ${id}`,
|
||||||
|
size: {
|
||||||
|
width: image.width,
|
||||||
|
height: image.height,
|
||||||
|
},
|
||||||
|
format: 'rgba8unorm',
|
||||||
|
usage:
|
||||||
|
GPUTextureUsage.TEXTURE_BINDING |
|
||||||
|
GPUTextureUsage.COPY_DST |
|
||||||
|
GPUTextureUsage.RENDER_ATTACHMENT,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy image data to texture
|
||||||
|
this.device.queue.copyExternalImageToTexture(
|
||||||
|
{ source: image },
|
||||||
|
{ texture },
|
||||||
|
{ width: image.width, height: image.height }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store texture info
|
||||||
|
this.textures.set(id, {
|
||||||
|
texture,
|
||||||
|
width: image.width,
|
||||||
|
height: image.height,
|
||||||
|
source: 'image',
|
||||||
|
});
|
||||||
|
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a texture from an HTMLVideoElement
|
||||||
|
*/
|
||||||
|
createFromVideo(id: string, video: HTMLVideoElement): GPUTexture {
|
||||||
|
const texture = this.device.createTexture({
|
||||||
|
label: `Video Texture: ${id}`,
|
||||||
|
size: {
|
||||||
|
width: video.videoWidth,
|
||||||
|
height: video.videoHeight,
|
||||||
|
},
|
||||||
|
format: 'rgba8unorm',
|
||||||
|
usage:
|
||||||
|
GPUTextureUsage.TEXTURE_BINDING |
|
||||||
|
GPUTextureUsage.COPY_DST |
|
||||||
|
GPUTextureUsage.RENDER_ATTACHMENT,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy current video frame to texture
|
||||||
|
this.device.queue.copyExternalImageToTexture(
|
||||||
|
{ source: video },
|
||||||
|
{ texture },
|
||||||
|
{ width: video.videoWidth, height: video.videoHeight }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store texture info
|
||||||
|
this.textures.set(id, {
|
||||||
|
texture,
|
||||||
|
width: video.videoWidth,
|
||||||
|
height: video.videoHeight,
|
||||||
|
source: 'video',
|
||||||
|
});
|
||||||
|
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a texture from an HTMLCanvasElement (for text rendering)
|
||||||
|
*/
|
||||||
|
createFromCanvas(id: string, canvas: HTMLCanvasElement): GPUTexture {
|
||||||
|
const texture = this.device.createTexture({
|
||||||
|
label: `Canvas Texture: ${id}`,
|
||||||
|
size: {
|
||||||
|
width: canvas.width,
|
||||||
|
height: canvas.height,
|
||||||
|
},
|
||||||
|
format: 'rgba8unorm',
|
||||||
|
usage:
|
||||||
|
GPUTextureUsage.TEXTURE_BINDING |
|
||||||
|
GPUTextureUsage.COPY_DST |
|
||||||
|
GPUTextureUsage.RENDER_ATTACHMENT,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy canvas content to texture
|
||||||
|
this.device.queue.copyExternalImageToTexture(
|
||||||
|
{ source: canvas },
|
||||||
|
{ texture },
|
||||||
|
{ width: canvas.width, height: canvas.height }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store texture info
|
||||||
|
this.textures.set(id, {
|
||||||
|
texture,
|
||||||
|
width: canvas.width,
|
||||||
|
height: canvas.height,
|
||||||
|
source: 'canvas',
|
||||||
|
});
|
||||||
|
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing texture from a video frame
|
||||||
|
* Used for video playback to update texture each frame
|
||||||
|
*/
|
||||||
|
updateFromVideo(id: string, video: HTMLVideoElement): boolean {
|
||||||
|
const info = this.textures.get(id);
|
||||||
|
if (!info) {
|
||||||
|
console.warn(`Texture not found: ${id}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if video dimensions changed
|
||||||
|
if (info.width !== video.videoWidth || info.height !== video.videoHeight) {
|
||||||
|
// Recreate texture with new dimensions
|
||||||
|
this.destroy(id);
|
||||||
|
this.createFromVideo(id, video);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing texture
|
||||||
|
this.device.queue.copyExternalImageToTexture(
|
||||||
|
{ source: video },
|
||||||
|
{ texture: info.texture },
|
||||||
|
{ width: video.videoWidth, height: video.videoHeight }
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing texture from an ImageBitmap
|
||||||
|
* Used for GIF animation frame updates
|
||||||
|
*/
|
||||||
|
updateFromImage(id: string, image: ImageBitmap): boolean {
|
||||||
|
const info = this.textures.get(id);
|
||||||
|
if (!info) {
|
||||||
|
console.warn(`Texture not found: ${id}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if dimensions changed
|
||||||
|
if (info.width !== image.width || info.height !== image.height) {
|
||||||
|
// Recreate texture with new dimensions
|
||||||
|
this.destroy(id);
|
||||||
|
this.createFromImage(id, image);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing texture
|
||||||
|
this.device.queue.copyExternalImageToTexture(
|
||||||
|
{ source: image },
|
||||||
|
{ texture: info.texture },
|
||||||
|
{ width: image.width, height: image.height }
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing texture from a canvas
|
||||||
|
* Used for text content updates
|
||||||
|
*/
|
||||||
|
updateFromCanvas(id: string, canvas: HTMLCanvasElement): boolean {
|
||||||
|
const info = this.textures.get(id);
|
||||||
|
if (!info) {
|
||||||
|
console.warn(`Texture not found: ${id}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if dimensions changed
|
||||||
|
if (info.width !== canvas.width || info.height !== canvas.height) {
|
||||||
|
// Recreate texture with new dimensions
|
||||||
|
this.destroy(id);
|
||||||
|
this.createFromCanvas(id, canvas);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing texture
|
||||||
|
this.device.queue.copyExternalImageToTexture(
|
||||||
|
{ source: canvas },
|
||||||
|
{ texture: info.texture },
|
||||||
|
{ width: canvas.width, height: canvas.height }
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get texture info by ID
|
||||||
|
*/
|
||||||
|
getTextureInfo(id: string): TextureInfo | undefined {
|
||||||
|
return this.textures.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get texture by ID
|
||||||
|
*/
|
||||||
|
getTexture(id: string): GPUTexture | undefined {
|
||||||
|
return this.textures.get(id)?.texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a texture exists
|
||||||
|
*/
|
||||||
|
hasTexture(id: string): boolean {
|
||||||
|
return this.textures.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy a specific texture
|
||||||
|
*/
|
||||||
|
destroy(id: string): void {
|
||||||
|
const info = this.textures.get(id);
|
||||||
|
if (info) {
|
||||||
|
info.texture.destroy();
|
||||||
|
this.textures.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy all textures
|
||||||
|
*/
|
||||||
|
destroyAll(): void {
|
||||||
|
for (const [, info] of this.textures) {
|
||||||
|
info.texture.destroy();
|
||||||
|
}
|
||||||
|
this.textures.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of managed textures
|
||||||
|
*/
|
||||||
|
get count(): number {
|
||||||
|
return this.textures.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set error callback
|
||||||
|
*/
|
||||||
|
setOnError(callback: (error: AppError) => void): void {
|
||||||
|
this.onError = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit an error
|
||||||
|
*/
|
||||||
|
private emitError(error: AppError): void {
|
||||||
|
console.error('TextureManager error:', error);
|
||||||
|
if (this.onError) {
|
||||||
|
this.onError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
519
src/core/scene/element.ts
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
/**
|
||||||
|
* Media Element Model
|
||||||
|
* Defines the MediaElement class for managing canvas elements
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ElementType,
|
||||||
|
ElementData,
|
||||||
|
MediaElement as IMediaElement,
|
||||||
|
ImageData,
|
||||||
|
GifData,
|
||||||
|
VideoData,
|
||||||
|
TextData,
|
||||||
|
YouTubeData,
|
||||||
|
} from '../../types';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ID Generation
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
let elementIdCounter = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique element ID
|
||||||
|
*/
|
||||||
|
export function generateElementId(): string {
|
||||||
|
return `element_${Date.now()}_${++elementIdCounter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the ID counter (useful for testing)
|
||||||
|
*/
|
||||||
|
export function resetElementIdCounter(): void {
|
||||||
|
elementIdCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Bounding Box
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface BoundingBox {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
// Corners for rotation support
|
||||||
|
topLeft: { x: number; y: number };
|
||||||
|
topRight: { x: number; y: number };
|
||||||
|
bottomLeft: { x: number; y: number };
|
||||||
|
bottomRight: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate bounding box for an element
|
||||||
|
* Supports rotation by computing rotated corners
|
||||||
|
*/
|
||||||
|
export function calculateBoundingBox(element: IMediaElement): BoundingBox {
|
||||||
|
const { x, y, width, height, rotation } = element;
|
||||||
|
|
||||||
|
// Center of the element
|
||||||
|
const cx = x + width / 2;
|
||||||
|
const cy = y + height / 2;
|
||||||
|
|
||||||
|
// Half dimensions
|
||||||
|
const hw = width / 2;
|
||||||
|
const hh = height / 2;
|
||||||
|
|
||||||
|
// Convert rotation to radians
|
||||||
|
const rad = (rotation * Math.PI) / 180;
|
||||||
|
const cos = Math.cos(rad);
|
||||||
|
const sin = Math.sin(rad);
|
||||||
|
|
||||||
|
// Calculate rotated corners
|
||||||
|
const corners = [
|
||||||
|
{ dx: -hw, dy: -hh }, // top-left
|
||||||
|
{ dx: hw, dy: -hh }, // top-right
|
||||||
|
{ dx: -hw, dy: hh }, // bottom-left
|
||||||
|
{ dx: hw, dy: hh }, // bottom-right
|
||||||
|
].map(({ dx, dy }) => ({
|
||||||
|
x: cx + dx * cos - dy * sin,
|
||||||
|
y: cy + dx * sin + dy * cos,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Find axis-aligned bounding box
|
||||||
|
const xs = corners.map(c => c.x);
|
||||||
|
const ys = corners.map(c => c.y);
|
||||||
|
const minX = Math.min(...xs);
|
||||||
|
const maxX = Math.max(...xs);
|
||||||
|
const minY = Math.min(...ys);
|
||||||
|
const maxY = Math.max(...ys);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: minX,
|
||||||
|
y: minY,
|
||||||
|
width: maxX - minX,
|
||||||
|
height: maxY - minY,
|
||||||
|
topLeft: corners[0],
|
||||||
|
topRight: corners[1],
|
||||||
|
bottomLeft: corners[2],
|
||||||
|
bottomRight: corners[3],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Collision Detection
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a point is inside an element's bounding box
|
||||||
|
* Uses rotation-aware detection
|
||||||
|
*/
|
||||||
|
export function isPointInElement(
|
||||||
|
px: number,
|
||||||
|
py: number,
|
||||||
|
element: IMediaElement
|
||||||
|
): boolean {
|
||||||
|
const { x, y, width, height, rotation } = element;
|
||||||
|
|
||||||
|
// Center of the element
|
||||||
|
const cx = x + width / 2;
|
||||||
|
const cy = y + height / 2;
|
||||||
|
|
||||||
|
// Convert rotation to radians (negative to reverse transform)
|
||||||
|
const rad = (-rotation * Math.PI) / 180;
|
||||||
|
const cos = Math.cos(rad);
|
||||||
|
const sin = Math.sin(rad);
|
||||||
|
|
||||||
|
// Transform point to element's local coordinate system
|
||||||
|
const dx = px - cx;
|
||||||
|
const dy = py - cy;
|
||||||
|
const localX = dx * cos - dy * sin;
|
||||||
|
const localY = dx * sin + dy * cos;
|
||||||
|
|
||||||
|
// Check if point is within half-dimensions
|
||||||
|
return Math.abs(localX) <= width / 2 && Math.abs(localY) <= height / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two elements' bounding boxes intersect
|
||||||
|
* Uses axis-aligned bounding box for simplicity
|
||||||
|
*/
|
||||||
|
export function doElementsIntersect(
|
||||||
|
element1: IMediaElement,
|
||||||
|
element2: IMediaElement
|
||||||
|
): boolean {
|
||||||
|
const box1 = calculateBoundingBox(element1);
|
||||||
|
const box2 = calculateBoundingBox(element2);
|
||||||
|
|
||||||
|
return !(
|
||||||
|
box1.x + box1.width < box2.x ||
|
||||||
|
box2.x + box2.width < box1.x ||
|
||||||
|
box1.y + box1.height < box2.y ||
|
||||||
|
box2.y + box2.height < box1.y
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an element intersects with a selection rectangle
|
||||||
|
*/
|
||||||
|
export function doesElementIntersectRect(
|
||||||
|
element: IMediaElement,
|
||||||
|
rectX: number,
|
||||||
|
rectY: number,
|
||||||
|
rectWidth: number,
|
||||||
|
rectHeight: number
|
||||||
|
): boolean {
|
||||||
|
const box = calculateBoundingBox(element);
|
||||||
|
|
||||||
|
// Normalize rect (handle negative width/height from drag direction)
|
||||||
|
const rx = rectWidth < 0 ? rectX + rectWidth : rectX;
|
||||||
|
const ry = rectHeight < 0 ? rectY + rectHeight : rectY;
|
||||||
|
const rw = Math.abs(rectWidth);
|
||||||
|
const rh = Math.abs(rectHeight);
|
||||||
|
|
||||||
|
// Check for intersection (two rectangles intersect if they overlap on both axes)
|
||||||
|
// Two rectangles do NOT intersect if one is completely to the side of the other
|
||||||
|
const leftOfRect = box.x + box.width < rx;
|
||||||
|
const rightOfRect = rx + rw < box.x;
|
||||||
|
const aboveRect = box.y + box.height < ry;
|
||||||
|
const belowRect = ry + rh < box.y;
|
||||||
|
|
||||||
|
const noOverlap = leftOfRect || rightOfRect || aboveRect || belowRect;
|
||||||
|
|
||||||
|
console.log('doesElementIntersectRect:', {
|
||||||
|
box: { x: box.x, y: box.y, w: box.width, h: box.height },
|
||||||
|
rect: { rx, ry, rw, rh },
|
||||||
|
checks: { leftOfRect, rightOfRect, aboveRect, belowRect },
|
||||||
|
noOverlap,
|
||||||
|
result: !noOverlap
|
||||||
|
});
|
||||||
|
|
||||||
|
return !noOverlap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MediaElement Class
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export class MediaElement implements IMediaElement {
|
||||||
|
id: string;
|
||||||
|
type: ElementType;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
rotation: number;
|
||||||
|
zIndex: number;
|
||||||
|
selected: boolean;
|
||||||
|
data: ElementData;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
type: ElementType,
|
||||||
|
data: ElementData,
|
||||||
|
options: Partial<{
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
rotation: number;
|
||||||
|
zIndex: number;
|
||||||
|
selected: boolean;
|
||||||
|
}> = {}
|
||||||
|
) {
|
||||||
|
this.id = options.id ?? generateElementId();
|
||||||
|
this.type = type;
|
||||||
|
this.data = data;
|
||||||
|
|
||||||
|
// Set default dimensions based on type
|
||||||
|
const defaultDimensions = this.getDefaultDimensions(type, data);
|
||||||
|
|
||||||
|
this.x = options.x ?? 0;
|
||||||
|
this.y = options.y ?? 0;
|
||||||
|
this.width = options.width ?? defaultDimensions.width;
|
||||||
|
this.height = options.height ?? defaultDimensions.height;
|
||||||
|
this.rotation = options.rotation ?? 0;
|
||||||
|
this.zIndex = options.zIndex ?? 0;
|
||||||
|
this.selected = options.selected ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default dimensions based on element type and data
|
||||||
|
*/
|
||||||
|
private getDefaultDimensions(
|
||||||
|
type: ElementType,
|
||||||
|
data: ElementData
|
||||||
|
): { width: number; height: number } {
|
||||||
|
switch (type) {
|
||||||
|
case 'image':
|
||||||
|
case 'gif': {
|
||||||
|
const imgData = data as ImageData | GifData;
|
||||||
|
return {
|
||||||
|
width: imgData.originalWidth || 200,
|
||||||
|
height: imgData.originalHeight || 200,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'video': {
|
||||||
|
// Default video dimensions
|
||||||
|
return { width: 320, height: 240 };
|
||||||
|
}
|
||||||
|
case 'text': {
|
||||||
|
const textData = data as TextData;
|
||||||
|
// Estimate text dimensions based on content length
|
||||||
|
const estimatedWidth = Math.max(100, textData.content.length * 10);
|
||||||
|
return { width: estimatedWidth, height: textData.fontSize * 1.5 };
|
||||||
|
}
|
||||||
|
case 'youtube': {
|
||||||
|
// Default YouTube embed dimensions (16:9)
|
||||||
|
return { width: 480, height: 270 };
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return { width: 200, height: 200 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the bounding box of this element
|
||||||
|
*/
|
||||||
|
getBoundingBox(): BoundingBox {
|
||||||
|
return calculateBoundingBox(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a point is inside this element
|
||||||
|
*/
|
||||||
|
containsPoint(px: number, py: number): boolean {
|
||||||
|
return isPointInElement(px, py, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this element intersects with another element
|
||||||
|
*/
|
||||||
|
intersects(other: IMediaElement): boolean {
|
||||||
|
return doElementsIntersect(this, other);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this element intersects with a rectangle
|
||||||
|
*/
|
||||||
|
intersectsRect(
|
||||||
|
rectX: number,
|
||||||
|
rectY: number,
|
||||||
|
rectWidth: number,
|
||||||
|
rectHeight: number
|
||||||
|
): boolean {
|
||||||
|
return doesElementIntersectRect(this, rectX, rectY, rectWidth, rectHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the center point of this element
|
||||||
|
*/
|
||||||
|
getCenter(): { x: number; y: number } {
|
||||||
|
return {
|
||||||
|
x: this.x + this.width / 2,
|
||||||
|
y: this.y + this.height / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the element by delta values
|
||||||
|
*/
|
||||||
|
move(deltaX: number, deltaY: number): void {
|
||||||
|
this.x += deltaX;
|
||||||
|
this.y += deltaY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the element to a specific position
|
||||||
|
*/
|
||||||
|
moveTo(x: number, y: number): void {
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize the element while maintaining aspect ratio
|
||||||
|
*/
|
||||||
|
resizeProportional(newWidth: number): void {
|
||||||
|
const aspectRatio = this.height / this.width;
|
||||||
|
this.width = newWidth;
|
||||||
|
this.height = newWidth * aspectRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize the element to specific dimensions
|
||||||
|
*/
|
||||||
|
resize(width: number, height: number): void {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clone this element with a new ID
|
||||||
|
*/
|
||||||
|
clone(offsetX = 20, offsetY = 20): MediaElement {
|
||||||
|
return new MediaElement(this.type, { ...this.data }, {
|
||||||
|
x: this.x + offsetX,
|
||||||
|
y: this.y + offsetY,
|
||||||
|
width: this.width,
|
||||||
|
height: this.height,
|
||||||
|
rotation: this.rotation,
|
||||||
|
zIndex: this.zIndex,
|
||||||
|
selected: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert to a plain object (for serialization)
|
||||||
|
*/
|
||||||
|
toJSON(): IMediaElement {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
type: this.type,
|
||||||
|
x: this.x,
|
||||||
|
y: this.y,
|
||||||
|
width: this.width,
|
||||||
|
height: this.height,
|
||||||
|
rotation: this.rotation,
|
||||||
|
zIndex: this.zIndex,
|
||||||
|
selected: this.selected,
|
||||||
|
data: this.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a MediaElement from a plain object
|
||||||
|
* Note: selected state is always reset to false when restoring
|
||||||
|
*/
|
||||||
|
static fromJSON(json: IMediaElement): MediaElement {
|
||||||
|
return new MediaElement(json.type, json.data, {
|
||||||
|
id: json.id,
|
||||||
|
x: json.x,
|
||||||
|
y: json.y,
|
||||||
|
width: json.width,
|
||||||
|
height: json.height,
|
||||||
|
rotation: json.rotation,
|
||||||
|
zIndex: json.zIndex,
|
||||||
|
selected: false, // Always reset selection state when restoring
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Factory Functions
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an image element
|
||||||
|
*/
|
||||||
|
export function createImageElement(
|
||||||
|
src: string,
|
||||||
|
originalWidth: number,
|
||||||
|
originalHeight: number,
|
||||||
|
options: Partial<{ x: number; y: number; width: number; height: number }> = {}
|
||||||
|
): MediaElement {
|
||||||
|
const data: ImageData = { src, originalWidth, originalHeight };
|
||||||
|
return new MediaElement('image', data, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a GIF element
|
||||||
|
*/
|
||||||
|
export function createGifElement(
|
||||||
|
src: string,
|
||||||
|
originalWidth: number,
|
||||||
|
originalHeight: number,
|
||||||
|
frames: ImageBitmap[],
|
||||||
|
frameDelays: number[],
|
||||||
|
options: Partial<{ x: number; y: number; width: number; height: number }> = {}
|
||||||
|
): MediaElement {
|
||||||
|
const data: GifData = {
|
||||||
|
src,
|
||||||
|
originalWidth,
|
||||||
|
originalHeight,
|
||||||
|
frames,
|
||||||
|
frameDelays,
|
||||||
|
currentFrame: 0,
|
||||||
|
};
|
||||||
|
return new MediaElement('gif', data, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a video element
|
||||||
|
*/
|
||||||
|
export function createVideoElement(
|
||||||
|
src: string,
|
||||||
|
options: Partial<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
loopStart: number;
|
||||||
|
loopEnd: number;
|
||||||
|
muted: boolean;
|
||||||
|
}> = {}
|
||||||
|
): MediaElement {
|
||||||
|
const data: VideoData = {
|
||||||
|
src,
|
||||||
|
loopStart: options.loopStart ?? 0,
|
||||||
|
loopEnd: options.loopEnd ?? 100,
|
||||||
|
muted: options.muted ?? true,
|
||||||
|
};
|
||||||
|
return new MediaElement('video', data, {
|
||||||
|
x: options.x,
|
||||||
|
y: options.y,
|
||||||
|
width: options.width,
|
||||||
|
height: options.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a text element
|
||||||
|
*/
|
||||||
|
export function createTextElement(
|
||||||
|
content: string,
|
||||||
|
options: Partial<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
fontSize: number;
|
||||||
|
color: string;
|
||||||
|
}> = {}
|
||||||
|
): MediaElement {
|
||||||
|
const data: TextData = {
|
||||||
|
content,
|
||||||
|
fontSize: options.fontSize ?? 16,
|
||||||
|
color: options.color ?? '#ffffff',
|
||||||
|
};
|
||||||
|
return new MediaElement('text', data, {
|
||||||
|
x: options.x,
|
||||||
|
y: options.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a YouTube element
|
||||||
|
*/
|
||||||
|
export function createYouTubeElement(
|
||||||
|
videoId: string,
|
||||||
|
options: Partial<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
loopStart: number;
|
||||||
|
loopEnd: number;
|
||||||
|
}> = {}
|
||||||
|
): MediaElement {
|
||||||
|
const data: YouTubeData = {
|
||||||
|
videoId,
|
||||||
|
loopStart: options.loopStart ?? 0,
|
||||||
|
loopEnd: options.loopEnd ?? 100,
|
||||||
|
};
|
||||||
|
return new MediaElement('youtube', data, {
|
||||||
|
x: options.x,
|
||||||
|
y: options.y,
|
||||||
|
width: options.width,
|
||||||
|
height: options.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
677
src/core/scene/index.ts
Normal file
@@ -0,0 +1,677 @@
|
|||||||
|
/**
|
||||||
|
* Scene Manager
|
||||||
|
* Manages scene state, element operations, and view transformations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, computed, type Ref, type ComputedRef } from 'vue';
|
||||||
|
import type {
|
||||||
|
MediaElement as IMediaElement,
|
||||||
|
ElementType,
|
||||||
|
ElementData,
|
||||||
|
ViewTransform,
|
||||||
|
} from '../../types';
|
||||||
|
import {
|
||||||
|
MediaElement,
|
||||||
|
isPointInElement,
|
||||||
|
doesElementIntersectRect,
|
||||||
|
} from './element';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Constants
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const MIN_SCALE = 0.01;
|
||||||
|
const MAX_SCALE = 2.0;
|
||||||
|
const DEFAULT_SCALE = 1.0;
|
||||||
|
const WORKSPACE_PADDING = 500;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Scene State Interface
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface WorkspaceBounds {
|
||||||
|
minX: number;
|
||||||
|
minY: number;
|
||||||
|
maxX: number;
|
||||||
|
maxY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Scene Manager Class
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export class SceneManager {
|
||||||
|
private _elements: Map<string, MediaElement> = new Map();
|
||||||
|
private _selectedIds: Set<string> = new Set();
|
||||||
|
private _viewTransform: ViewTransform = {
|
||||||
|
scale: DEFAULT_SCALE,
|
||||||
|
translateX: 0,
|
||||||
|
translateY: 0,
|
||||||
|
};
|
||||||
|
private _workspaceBounds: WorkspaceBounds = {
|
||||||
|
minX: -1000,
|
||||||
|
minY: -1000,
|
||||||
|
maxX: 1000,
|
||||||
|
maxY: 1000,
|
||||||
|
};
|
||||||
|
private _nextZIndex = 1;
|
||||||
|
private _version: Ref<number> = ref(0);
|
||||||
|
|
||||||
|
readonly elements: ComputedRef<MediaElement[]>;
|
||||||
|
readonly selectedElements: ComputedRef<MediaElement[]>;
|
||||||
|
readonly viewTransform: ComputedRef<ViewTransform>;
|
||||||
|
readonly hasSelection: ComputedRef<boolean>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
this.elements = computed(() => {
|
||||||
|
self._version.value;
|
||||||
|
return Array.from(self._elements.values()).sort((a, b) => a.zIndex - b.zIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.selectedElements = computed(() => {
|
||||||
|
self._version.value;
|
||||||
|
return Array.from(self._elements.values()).filter(el => el.selected);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.viewTransform = computed(() => {
|
||||||
|
self._version.value;
|
||||||
|
return { ...self._viewTransform };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.hasSelection = computed(() => {
|
||||||
|
self._version.value;
|
||||||
|
return self._selectedIds.size > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(): void {
|
||||||
|
this._version.value++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Element Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
addElement(
|
||||||
|
type: ElementType,
|
||||||
|
data: ElementData,
|
||||||
|
options: Partial<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
rotation: number;
|
||||||
|
}> = {}
|
||||||
|
): MediaElement {
|
||||||
|
const element = new MediaElement(type, data, {
|
||||||
|
...options,
|
||||||
|
zIndex: this._nextZIndex++,
|
||||||
|
});
|
||||||
|
|
||||||
|
this._elements.set(element.id, element);
|
||||||
|
this.updateWorkspaceBounds();
|
||||||
|
this.notify();
|
||||||
|
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
addExistingElement(element: MediaElement): void {
|
||||||
|
if (element.zIndex >= this._nextZIndex) {
|
||||||
|
this._nextZIndex = element.zIndex + 1;
|
||||||
|
}
|
||||||
|
this._elements.set(element.id, element);
|
||||||
|
this.updateWorkspaceBounds();
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeElement(id: string): boolean {
|
||||||
|
const element = this._elements.get(id);
|
||||||
|
if (!element) return false;
|
||||||
|
|
||||||
|
this._elements.delete(id);
|
||||||
|
this._selectedIds.delete(id);
|
||||||
|
this.updateWorkspaceBounds();
|
||||||
|
this.notify();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getElement(id: string): MediaElement | undefined {
|
||||||
|
return this._elements.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateElement(
|
||||||
|
id: string,
|
||||||
|
updates: Partial<Omit<IMediaElement, 'id' | 'type' | 'data'>>
|
||||||
|
): boolean {
|
||||||
|
const element = this._elements.get(id);
|
||||||
|
if (!element) return false;
|
||||||
|
|
||||||
|
Object.assign(element, updates);
|
||||||
|
this.updateWorkspaceBounds();
|
||||||
|
this.notify();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Selection Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
selectElement(id: string, addToSelection = false): boolean {
|
||||||
|
const element = this._elements.get(id);
|
||||||
|
if (!element) return false;
|
||||||
|
|
||||||
|
if (!addToSelection) {
|
||||||
|
this.deselectAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
element.selected = true;
|
||||||
|
this._selectedIds.add(id);
|
||||||
|
this.notify();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
deselectElement(id: string): boolean {
|
||||||
|
const element = this._elements.get(id);
|
||||||
|
if (!element) return false;
|
||||||
|
|
||||||
|
element.selected = false;
|
||||||
|
this._selectedIds.delete(id);
|
||||||
|
this.notify();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
deselectAll(): void {
|
||||||
|
for (const id of this._selectedIds) {
|
||||||
|
const element = this._elements.get(id);
|
||||||
|
if (element) {
|
||||||
|
element.selected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._selectedIds.clear();
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSelection(id: string): boolean {
|
||||||
|
const element = this._elements.get(id);
|
||||||
|
if (!element) return false;
|
||||||
|
|
||||||
|
if (element.selected) {
|
||||||
|
this.deselectElement(id);
|
||||||
|
} else {
|
||||||
|
this.selectElement(id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectInRect(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
addToSelection = false
|
||||||
|
): void {
|
||||||
|
console.log('selectInRect called:', { x, y, width, height, elementCount: this._elements.size });
|
||||||
|
|
||||||
|
if (!addToSelection) {
|
||||||
|
this.deselectAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const element of this._elements.values()) {
|
||||||
|
const intersects = doesElementIntersectRect(element, x, y, width, height);
|
||||||
|
console.log('Element check:', element.id, 'intersects:', intersects);
|
||||||
|
if (intersects) {
|
||||||
|
element.selected = true;
|
||||||
|
this._selectedIds.add(element.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Selected count:', this._selectedIds.size);
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
selectAll(): void {
|
||||||
|
for (const element of this._elements.values()) {
|
||||||
|
element.selected = true;
|
||||||
|
this._selectedIds.add(element.id);
|
||||||
|
}
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Batch Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
deleteSelected(): MediaElement[] {
|
||||||
|
const deleted: MediaElement[] = [];
|
||||||
|
|
||||||
|
for (const id of this._selectedIds) {
|
||||||
|
const element = this._elements.get(id);
|
||||||
|
if (element) {
|
||||||
|
deleted.push(element);
|
||||||
|
this._elements.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._selectedIds.clear();
|
||||||
|
this.updateWorkspaceBounds();
|
||||||
|
this.notify();
|
||||||
|
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
moveSelected(deltaX: number, deltaY: number): void {
|
||||||
|
for (const id of this._selectedIds) {
|
||||||
|
const element = this._elements.get(id);
|
||||||
|
if (element) {
|
||||||
|
element.move(deltaX, deltaY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.updateWorkspaceBounds();
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the bounding box of all selected elements
|
||||||
|
*/
|
||||||
|
getSelectionBounds(): { x: number; y: number; width: number; height: number } | null {
|
||||||
|
if (this._selectedIds.size === 0) return null;
|
||||||
|
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
|
||||||
|
for (const id of this._selectedIds) {
|
||||||
|
const element = this._elements.get(id);
|
||||||
|
if (element) {
|
||||||
|
minX = Math.min(minX, element.x);
|
||||||
|
minY = Math.min(minY, element.y);
|
||||||
|
maxX = Math.max(maxX, element.x + element.width);
|
||||||
|
maxY = Math.max(maxY, element.y + element.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minX === Infinity) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: minX,
|
||||||
|
y: minY,
|
||||||
|
width: maxX - minX,
|
||||||
|
height: maxY - minY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scale all selected elements from a pivot point
|
||||||
|
* @param scaleX - horizontal scale factor
|
||||||
|
* @param scaleY - vertical scale factor
|
||||||
|
* @param pivotX - pivot point X in world coordinates
|
||||||
|
* @param pivotY - pivot point Y in world coordinates
|
||||||
|
*/
|
||||||
|
scaleSelected(scaleX: number, scaleY: number, pivotX: number, pivotY: number): void {
|
||||||
|
for (const id of this._selectedIds) {
|
||||||
|
const element = this._elements.get(id);
|
||||||
|
if (element) {
|
||||||
|
// Calculate new position relative to pivot
|
||||||
|
const relX = element.x - pivotX;
|
||||||
|
const relY = element.y - pivotY;
|
||||||
|
|
||||||
|
element.x = pivotX + relX * scaleX;
|
||||||
|
element.y = pivotY + relY * scaleY;
|
||||||
|
element.width = Math.max(20, element.width * scaleX);
|
||||||
|
element.height = Math.max(20, element.height * scaleY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.updateWorkspaceBounds();
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
cloneSelected(offsetX = 20, offsetY = 20): MediaElement[] {
|
||||||
|
const clones: MediaElement[] = [];
|
||||||
|
|
||||||
|
for (const id of this._selectedIds) {
|
||||||
|
const element = this._elements.get(id);
|
||||||
|
if (element) {
|
||||||
|
const clone = element.clone(offsetX, offsetY);
|
||||||
|
clone.zIndex = this._nextZIndex++;
|
||||||
|
this._elements.set(clone.id, clone);
|
||||||
|
clones.push(clone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.deselectAll();
|
||||||
|
for (const clone of clones) {
|
||||||
|
clone.selected = true;
|
||||||
|
this._selectedIds.add(clone.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateWorkspaceBounds();
|
||||||
|
this.notify();
|
||||||
|
return clones;
|
||||||
|
}
|
||||||
|
|
||||||
|
bringToFront(): void {
|
||||||
|
for (const id of this._selectedIds) {
|
||||||
|
const element = this._elements.get(id);
|
||||||
|
if (element) {
|
||||||
|
element.zIndex = this._nextZIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
sendToBack(): void {
|
||||||
|
let minZ = Infinity;
|
||||||
|
for (const element of this._elements.values()) {
|
||||||
|
if (!element.selected && element.zIndex < minZ) {
|
||||||
|
minZ = element.zIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let z = Math.max(0, minZ - this._selectedIds.size);
|
||||||
|
for (const id of this._selectedIds) {
|
||||||
|
const element = this._elements.get(id);
|
||||||
|
if (element) {
|
||||||
|
element.zIndex = z++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Hit Testing
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
getElementAtPoint(screenX: number, screenY: number): MediaElement | null {
|
||||||
|
const worldX = this.screenToWorldX(screenX);
|
||||||
|
const worldY = this.screenToWorldY(screenY);
|
||||||
|
|
||||||
|
const sortedElements = Array.from(this._elements.values())
|
||||||
|
.sort((a, b) => b.zIndex - a.zIndex);
|
||||||
|
|
||||||
|
for (const element of sortedElements) {
|
||||||
|
if (isPointInElement(worldX, worldY, element)) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getElementsAtPoint(screenX: number, screenY: number): MediaElement[] {
|
||||||
|
const worldX = this.screenToWorldX(screenX);
|
||||||
|
const worldY = this.screenToWorldY(screenY);
|
||||||
|
|
||||||
|
return Array.from(this._elements.values())
|
||||||
|
.filter(element => isPointInElement(worldX, worldY, element))
|
||||||
|
.sort((a, b) => b.zIndex - a.zIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// View Transform Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
zoom(delta: number, centerX: number, centerY: number): void {
|
||||||
|
const oldScale = this._viewTransform.scale;
|
||||||
|
const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, oldScale * (1 + delta)));
|
||||||
|
|
||||||
|
if (newScale === oldScale) return;
|
||||||
|
|
||||||
|
const worldX = this.screenToWorldX(centerX);
|
||||||
|
const worldY = this.screenToWorldY(centerY);
|
||||||
|
|
||||||
|
this._viewTransform.scale = newScale;
|
||||||
|
this._viewTransform.translateX = centerX - worldX * newScale;
|
||||||
|
this._viewTransform.translateY = centerY - worldY * newScale;
|
||||||
|
|
||||||
|
this.clampViewToBounds();
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
setZoom(scale: number, centerX?: number, centerY?: number): void {
|
||||||
|
const clampedScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale));
|
||||||
|
|
||||||
|
if (centerX !== undefined && centerY !== undefined) {
|
||||||
|
const worldX = this.screenToWorldX(centerX);
|
||||||
|
const worldY = this.screenToWorldY(centerY);
|
||||||
|
|
||||||
|
this._viewTransform.scale = clampedScale;
|
||||||
|
this._viewTransform.translateX = centerX - worldX * clampedScale;
|
||||||
|
this._viewTransform.translateY = centerY - worldY * clampedScale;
|
||||||
|
} else {
|
||||||
|
this._viewTransform.scale = clampedScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clampViewToBounds();
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
pan(deltaX: number, deltaY: number): void {
|
||||||
|
this._viewTransform.translateX += deltaX;
|
||||||
|
this._viewTransform.translateY += deltaY;
|
||||||
|
this.clampViewToBounds();
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
setViewTransform(transform: Partial<ViewTransform>): void {
|
||||||
|
if (transform.scale !== undefined) {
|
||||||
|
this._viewTransform.scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, transform.scale));
|
||||||
|
}
|
||||||
|
if (transform.translateX !== undefined) {
|
||||||
|
this._viewTransform.translateX = transform.translateX;
|
||||||
|
}
|
||||||
|
if (transform.translateY !== undefined) {
|
||||||
|
this._viewTransform.translateY = transform.translateY;
|
||||||
|
}
|
||||||
|
this.clampViewToBounds();
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
resetView(): void {
|
||||||
|
this._viewTransform.scale = DEFAULT_SCALE;
|
||||||
|
this._viewTransform.translateX = 0;
|
||||||
|
this._viewTransform.translateY = 0;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fitToView(canvasWidth: number, canvasHeight: number, padding = 50): void {
|
||||||
|
if (this._elements.size === 0) {
|
||||||
|
this.resetView();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounds = this._workspaceBounds;
|
||||||
|
const contentWidth = bounds.maxX - bounds.minX;
|
||||||
|
const contentHeight = bounds.maxY - bounds.minY;
|
||||||
|
|
||||||
|
if (contentWidth <= 0 || contentHeight <= 0) {
|
||||||
|
this.resetView();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scaleX = (canvasWidth - padding * 2) / contentWidth;
|
||||||
|
const scaleY = (canvasHeight - padding * 2) / contentHeight;
|
||||||
|
const scale = Math.min(scaleX, scaleY, MAX_SCALE);
|
||||||
|
|
||||||
|
const centerX = (bounds.minX + bounds.maxX) / 2;
|
||||||
|
const centerY = (bounds.minY + bounds.maxY) / 2;
|
||||||
|
|
||||||
|
this._viewTransform.scale = scale;
|
||||||
|
this._viewTransform.translateX = canvasWidth / 2 - centerX * scale;
|
||||||
|
this._viewTransform.translateY = canvasHeight / 2 - centerY * scale;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Coordinate Conversion
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
screenToWorldX(screenX: number): number {
|
||||||
|
return (screenX - this._viewTransform.translateX) / this._viewTransform.scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
screenToWorldY(screenY: number): number {
|
||||||
|
return (screenY - this._viewTransform.translateY) / this._viewTransform.scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
|
||||||
|
return {
|
||||||
|
x: this.screenToWorldX(screenX),
|
||||||
|
y: this.screenToWorldY(screenY),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
worldToScreenX(worldX: number): number {
|
||||||
|
return worldX * this._viewTransform.scale + this._viewTransform.translateX;
|
||||||
|
}
|
||||||
|
|
||||||
|
worldToScreenY(worldY: number): number {
|
||||||
|
return worldY * this._viewTransform.scale + this._viewTransform.translateY;
|
||||||
|
}
|
||||||
|
|
||||||
|
worldToScreen(worldX: number, worldY: number): { x: number; y: number } {
|
||||||
|
return {
|
||||||
|
x: this.worldToScreenX(worldX),
|
||||||
|
y: this.worldToScreenY(worldY),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Workspace Bounds
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private updateWorkspaceBounds(): void {
|
||||||
|
if (this._elements.size === 0) {
|
||||||
|
this._workspaceBounds = {
|
||||||
|
minX: -1000,
|
||||||
|
minY: -1000,
|
||||||
|
maxX: 1000,
|
||||||
|
maxY: 1000,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
|
||||||
|
for (const element of this._elements.values()) {
|
||||||
|
const box = element.getBoundingBox();
|
||||||
|
minX = Math.min(minX, box.x);
|
||||||
|
minY = Math.min(minY, box.y);
|
||||||
|
maxX = Math.max(maxX, box.x + box.width);
|
||||||
|
maxY = Math.max(maxY, box.y + box.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._workspaceBounds = {
|
||||||
|
minX: minX - WORKSPACE_PADDING,
|
||||||
|
minY: minY - WORKSPACE_PADDING,
|
||||||
|
maxX: maxX + WORKSPACE_PADDING,
|
||||||
|
maxY: maxY + WORKSPACE_PADDING,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private clampViewToBounds(): void {
|
||||||
|
// Ensures workspace is always at least partially visible
|
||||||
|
}
|
||||||
|
|
||||||
|
getWorkspaceBounds(): WorkspaceBounds {
|
||||||
|
return { ...this._workspaceBounds };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Scene Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this._elements.clear();
|
||||||
|
this._selectedIds.clear();
|
||||||
|
this._nextZIndex = 1;
|
||||||
|
this._viewTransform = {
|
||||||
|
scale: DEFAULT_SCALE,
|
||||||
|
translateX: 0,
|
||||||
|
translateY: 0,
|
||||||
|
};
|
||||||
|
this.updateWorkspaceBounds();
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllElements(): MediaElement[] {
|
||||||
|
return Array.from(this._elements.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
getElementCount(): number {
|
||||||
|
return this._elements.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSelectedCount(): number {
|
||||||
|
return this._selectedIds.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): {
|
||||||
|
elements: IMediaElement[];
|
||||||
|
viewTransform: ViewTransform;
|
||||||
|
workspaceBounds: WorkspaceBounds;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
elements: Array.from(this._elements.values()).map(el => el.toJSON()),
|
||||||
|
viewTransform: { ...this._viewTransform },
|
||||||
|
workspaceBounds: { ...this._workspaceBounds },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreState(state: {
|
||||||
|
elements: IMediaElement[];
|
||||||
|
viewTransform?: ViewTransform;
|
||||||
|
}): void {
|
||||||
|
this.clear();
|
||||||
|
|
||||||
|
for (const elementData of state.elements) {
|
||||||
|
const element = MediaElement.fromJSON(elementData);
|
||||||
|
this.addExistingElement(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.viewTransform) {
|
||||||
|
this.setViewTransform(state.viewTransform);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Singleton Instance
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
let sceneManagerInstance: SceneManager | null = null;
|
||||||
|
|
||||||
|
export function getSceneManager(): SceneManager {
|
||||||
|
if (!sceneManagerInstance) {
|
||||||
|
sceneManagerInstance = new SceneManager();
|
||||||
|
}
|
||||||
|
return sceneManagerInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetSceneManager(): void {
|
||||||
|
sceneManagerInstance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export element utilities
|
||||||
|
export {
|
||||||
|
MediaElement,
|
||||||
|
generateElementId,
|
||||||
|
calculateBoundingBox,
|
||||||
|
isPointInElement,
|
||||||
|
doElementsIntersect,
|
||||||
|
doesElementIntersectRect,
|
||||||
|
createImageElement,
|
||||||
|
createGifElement,
|
||||||
|
createVideoElement,
|
||||||
|
createTextElement,
|
||||||
|
createYouTubeElement,
|
||||||
|
} from './element';
|
||||||
413
src/core/scene/serializer.ts
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
/**
|
||||||
|
* Scene Serialization/Deserialization
|
||||||
|
* Implements .purgif file format compatibility
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Scene,
|
||||||
|
SerializedElement,
|
||||||
|
MediaElement as IMediaElement,
|
||||||
|
ElementType,
|
||||||
|
ViewTransform,
|
||||||
|
ImageData,
|
||||||
|
GifData,
|
||||||
|
VideoData,
|
||||||
|
TextData,
|
||||||
|
YouTubeData,
|
||||||
|
} from '../../types';
|
||||||
|
import { MediaElement } from './element';
|
||||||
|
import { SceneManager } from './index';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Constants
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const SCENE_VERSION = '1.0.0';
|
||||||
|
const PURGIF_MAGIC = 'PURGIF'; // File format identifier
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Scene File Format
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface SceneFile {
|
||||||
|
magic: string;
|
||||||
|
version: string;
|
||||||
|
scene: Scene;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Serialization Functions
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a MediaElement to SerializedElement format
|
||||||
|
*/
|
||||||
|
export function serializeElement(element: IMediaElement): SerializedElement {
|
||||||
|
const serialized: SerializedElement = {
|
||||||
|
type: element.type,
|
||||||
|
path: '',
|
||||||
|
x: element.x,
|
||||||
|
y: element.y,
|
||||||
|
width: element.width,
|
||||||
|
height: element.height,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (element.type) {
|
||||||
|
case 'image':
|
||||||
|
case 'gif': {
|
||||||
|
const imgData = element.data as ImageData | GifData;
|
||||||
|
serialized.path = imgData.src; // base64 data URL
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'video': {
|
||||||
|
const videoData = element.data as VideoData;
|
||||||
|
serialized.path = videoData.src;
|
||||||
|
serialized.loopPairs = [[videoData.loopStart, videoData.loopEnd]];
|
||||||
|
serialized.activeLoopPair = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'text': {
|
||||||
|
const textData = element.data as TextData;
|
||||||
|
// Encode text content as base64 to handle special characters
|
||||||
|
serialized.path = `text:${btoa(encodeURIComponent(textData.content))}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'youtube': {
|
||||||
|
const ytData = element.data as YouTubeData;
|
||||||
|
serialized.path = `youtube:${ytData.videoId}`;
|
||||||
|
serialized.loopPairs = [[ytData.loopStart, ytData.loopEnd]];
|
||||||
|
serialized.activeLoopPair = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return serialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialize a SerializedElement to MediaElement
|
||||||
|
*/
|
||||||
|
export function deserializeElement(serialized: SerializedElement): MediaElement {
|
||||||
|
let data;
|
||||||
|
|
||||||
|
switch (serialized.type) {
|
||||||
|
case 'image': {
|
||||||
|
data = {
|
||||||
|
src: serialized.path,
|
||||||
|
originalWidth: serialized.width,
|
||||||
|
originalHeight: serialized.height,
|
||||||
|
} as ImageData;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'gif': {
|
||||||
|
data = {
|
||||||
|
src: serialized.path,
|
||||||
|
originalWidth: serialized.width,
|
||||||
|
originalHeight: serialized.height,
|
||||||
|
frames: [], // Will be populated when loading
|
||||||
|
frameDelays: [],
|
||||||
|
currentFrame: 0,
|
||||||
|
} as GifData;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'video': {
|
||||||
|
const loopPair = serialized.loopPairs?.[serialized.activeLoopPair ?? 0] ?? [0, 100];
|
||||||
|
data = {
|
||||||
|
src: serialized.path,
|
||||||
|
loopStart: loopPair[0],
|
||||||
|
loopEnd: loopPair[1],
|
||||||
|
muted: true,
|
||||||
|
} as VideoData;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'text': {
|
||||||
|
// Decode text content from base64
|
||||||
|
const content = serialized.path.startsWith('text:')
|
||||||
|
? decodeURIComponent(atob(serialized.path.slice(5)))
|
||||||
|
: serialized.path;
|
||||||
|
data = {
|
||||||
|
content,
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#ffffff',
|
||||||
|
} as TextData;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'youtube': {
|
||||||
|
const videoId = serialized.path.startsWith('youtube:')
|
||||||
|
? serialized.path.slice(8)
|
||||||
|
: serialized.path;
|
||||||
|
const loopPair = serialized.loopPairs?.[serialized.activeLoopPair ?? 0] ?? [0, 100];
|
||||||
|
data = {
|
||||||
|
videoId,
|
||||||
|
loopStart: loopPair[0],
|
||||||
|
loopEnd: loopPair[1],
|
||||||
|
} as YouTubeData;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown element type: ${serialized.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MediaElement(serialized.type, data, {
|
||||||
|
x: serialized.x,
|
||||||
|
y: serialized.y,
|
||||||
|
width: serialized.width,
|
||||||
|
height: serialized.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a SceneManager state to Scene format
|
||||||
|
*/
|
||||||
|
export function serializeScene(manager: SceneManager): Scene {
|
||||||
|
const state = manager.getState();
|
||||||
|
const bounds = manager.getWorkspaceBounds();
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: SCENE_VERSION,
|
||||||
|
currentScale: state.viewTransform.scale,
|
||||||
|
translate: {
|
||||||
|
translateX: state.viewTransform.translateX,
|
||||||
|
translateY: state.viewTransform.translateY,
|
||||||
|
},
|
||||||
|
workspaceRect: {
|
||||||
|
x1: bounds.minX,
|
||||||
|
y1: bounds.minY,
|
||||||
|
x2: bounds.maxX,
|
||||||
|
y2: bounds.maxY,
|
||||||
|
},
|
||||||
|
elements: state.elements.map(serializeElement),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialize a Scene to SceneManager state
|
||||||
|
*/
|
||||||
|
export function deserializeScene(scene: Scene): {
|
||||||
|
elements: MediaElement[];
|
||||||
|
viewTransform: ViewTransform;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
elements: scene.elements.map(deserializeElement),
|
||||||
|
viewTransform: {
|
||||||
|
scale: scene.currentScale,
|
||||||
|
translateX: scene.translate.translateX,
|
||||||
|
translateY: scene.translate.translateY,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// File I/O Functions
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert scene to JSON string for saving
|
||||||
|
*/
|
||||||
|
export function sceneToJson(manager: SceneManager): string {
|
||||||
|
const sceneFile: SceneFile = {
|
||||||
|
magic: PURGIF_MAGIC,
|
||||||
|
version: SCENE_VERSION,
|
||||||
|
scene: serializeScene(manager),
|
||||||
|
};
|
||||||
|
|
||||||
|
return JSON.stringify(sceneFile, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse JSON string to scene data
|
||||||
|
*/
|
||||||
|
export function jsonToScene(json: string): {
|
||||||
|
elements: MediaElement[];
|
||||||
|
viewTransform: ViewTransform;
|
||||||
|
} {
|
||||||
|
const sceneFile = JSON.parse(json) as SceneFile;
|
||||||
|
|
||||||
|
// Validate file format
|
||||||
|
if (sceneFile.magic !== PURGIF_MAGIC) {
|
||||||
|
throw new Error('Invalid file format: not a valid .purgif file');
|
||||||
|
}
|
||||||
|
|
||||||
|
return deserializeScene(sceneFile.scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load scene from JSON and apply to SceneManager
|
||||||
|
*/
|
||||||
|
export function loadSceneFromJson(manager: SceneManager, json: string): void {
|
||||||
|
const { elements, viewTransform } = jsonToScene(json);
|
||||||
|
|
||||||
|
manager.clear();
|
||||||
|
|
||||||
|
for (const element of elements) {
|
||||||
|
manager.addExistingElement(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.setViewTransform(viewTransform);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Base64 Utilities
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert ArrayBuffer to base64 string
|
||||||
|
*/
|
||||||
|
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.byteLength; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert base64 string to ArrayBuffer
|
||||||
|
*/
|
||||||
|
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert base64 string to Blob
|
||||||
|
*/
|
||||||
|
export function base64ToBlob(base64: string, mimeType: string): Blob {
|
||||||
|
const buffer = base64ToArrayBuffer(base64);
|
||||||
|
return new Blob([buffer], { type: mimeType });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Blob to base64 data URL
|
||||||
|
*/
|
||||||
|
export async function blobToBase64DataUrl(blob: Blob): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert File to base64 data URL
|
||||||
|
*/
|
||||||
|
export async function fileToBase64DataUrl(file: File): Promise<string> {
|
||||||
|
return blobToBase64DataUrl(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract base64 data from data URL
|
||||||
|
*/
|
||||||
|
export function extractBase64FromDataUrl(dataUrl: string): string {
|
||||||
|
const match = dataUrl.match(/^data:[^;]+;base64,(.+)$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error('Invalid data URL format');
|
||||||
|
}
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get MIME type from data URL
|
||||||
|
*/
|
||||||
|
export function getMimeTypeFromDataUrl(dataUrl: string): string {
|
||||||
|
const match = dataUrl.match(/^data:([^;]+);base64,/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error('Invalid data URL format');
|
||||||
|
}
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create data URL from base64 and MIME type
|
||||||
|
*/
|
||||||
|
export function createDataUrl(base64: string, mimeType: string): string {
|
||||||
|
return `data:${mimeType};base64,${base64}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Image Processing Utilities
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load image from data URL and get dimensions
|
||||||
|
*/
|
||||||
|
export async function loadImageFromDataUrl(
|
||||||
|
dataUrl: string
|
||||||
|
): Promise<{ image: HTMLImageElement; width: number; height: number }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
resolve({
|
||||||
|
image: img,
|
||||||
|
width: img.naturalWidth,
|
||||||
|
height: img.naturalHeight,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = dataUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create ImageBitmap from data URL
|
||||||
|
*/
|
||||||
|
export async function createImageBitmapFromDataUrl(
|
||||||
|
dataUrl: string
|
||||||
|
): Promise<ImageBitmap> {
|
||||||
|
const blob = base64ToBlob(
|
||||||
|
extractBase64FromDataUrl(dataUrl),
|
||||||
|
getMimeTypeFromDataUrl(dataUrl)
|
||||||
|
);
|
||||||
|
return createImageBitmap(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Validation Functions
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate scene file structure
|
||||||
|
*/
|
||||||
|
export function validateSceneFile(data: unknown): data is SceneFile {
|
||||||
|
if (typeof data !== 'object' || data === null) return false;
|
||||||
|
|
||||||
|
const file = data as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (file.magic !== PURGIF_MAGIC) return false;
|
||||||
|
if (typeof file.version !== 'string') return false;
|
||||||
|
if (typeof file.scene !== 'object' || file.scene === null) return false;
|
||||||
|
|
||||||
|
const scene = file.scene as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (typeof scene.currentScale !== 'number') return false;
|
||||||
|
if (typeof scene.translate !== 'object') return false;
|
||||||
|
if (!Array.isArray(scene.elements)) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate element type
|
||||||
|
*/
|
||||||
|
export function isValidElementType(type: string): type is ElementType {
|
||||||
|
return ['image', 'gif', 'video', 'text', 'youtube'].includes(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Migration Functions
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate old scene format to current version
|
||||||
|
*/
|
||||||
|
export function migrateScene(sceneFile: SceneFile): SceneFile {
|
||||||
|
// Currently no migrations needed
|
||||||
|
// Add version-specific migrations here as needed
|
||||||
|
return sceneFile;
|
||||||
|
}
|
||||||
8
src/env.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="@webgpu/types" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
4
src/main.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import App from "./App.vue";
|
||||||
|
|
||||||
|
createApp(App).mount("#app");
|
||||||
21
src/stores/scene.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// Scene state store
|
||||||
|
// Implementation will be added in Task 6
|
||||||
|
|
||||||
|
import { reactive } from 'vue';
|
||||||
|
import type { MediaElement, ViewTransform } from '@/types';
|
||||||
|
|
||||||
|
export interface SceneState {
|
||||||
|
elements: MediaElement[];
|
||||||
|
selectedIds: Set<string>;
|
||||||
|
viewTransform: ViewTransform;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sceneState = reactive<SceneState>({
|
||||||
|
elements: [],
|
||||||
|
selectedIds: new Set(),
|
||||||
|
viewTransform: {
|
||||||
|
scale: 1,
|
||||||
|
translateX: 0,
|
||||||
|
translateY: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
25
src/stores/ui.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// UI state store
|
||||||
|
// Implementation will be added in Task 6
|
||||||
|
|
||||||
|
import { reactive } from 'vue';
|
||||||
|
import type { AppState, ContextMenuState, InputMode } from '@/types';
|
||||||
|
|
||||||
|
export interface UIState extends AppState {
|
||||||
|
inputMode: InputMode;
|
||||||
|
contextMenu: ContextMenuState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uiState = reactive<UIState>({
|
||||||
|
isInitialized: false,
|
||||||
|
isLocked: false,
|
||||||
|
showHelp: false,
|
||||||
|
alwaysOnTop: false,
|
||||||
|
windowOpacity: 1,
|
||||||
|
inputMode: 'default',
|
||||||
|
contextMenu: {
|
||||||
|
visible: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
201
src/types/index.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
// ============================================
|
||||||
|
// Media Element Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export type ElementType = 'image' | 'gif' | 'video' | 'text' | 'youtube';
|
||||||
|
|
||||||
|
export interface ImageData {
|
||||||
|
src: string; // base64 data URL
|
||||||
|
originalWidth: number;
|
||||||
|
originalHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GifData extends ImageData {
|
||||||
|
frames: ImageBitmap[];
|
||||||
|
frameDelays: number[];
|
||||||
|
currentFrame: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoData {
|
||||||
|
src: string;
|
||||||
|
loopStart: number; // percentage 0-100
|
||||||
|
loopEnd: number; // percentage 0-100
|
||||||
|
muted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextData {
|
||||||
|
content: string;
|
||||||
|
fontSize: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YouTubeData {
|
||||||
|
videoId: string;
|
||||||
|
loopStart: number;
|
||||||
|
loopEnd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ElementData = ImageData | GifData | VideoData | TextData | YouTubeData;
|
||||||
|
|
||||||
|
export interface MediaElement {
|
||||||
|
id: string;
|
||||||
|
type: ElementType;
|
||||||
|
|
||||||
|
// Transform properties
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
rotation: number;
|
||||||
|
|
||||||
|
// Layer order
|
||||||
|
zIndex: number;
|
||||||
|
|
||||||
|
// Selection state
|
||||||
|
selected: boolean;
|
||||||
|
|
||||||
|
// Type-specific data
|
||||||
|
data: ElementData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Scene Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface SerializedElement {
|
||||||
|
type: ElementType;
|
||||||
|
path: string; // base64 for images, file path for videos
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
loopPairs?: [number, number][];
|
||||||
|
activeLoopPair?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Scene {
|
||||||
|
version: string;
|
||||||
|
|
||||||
|
// View state
|
||||||
|
currentScale: number;
|
||||||
|
translate: {
|
||||||
|
translateX: number;
|
||||||
|
translateY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Workspace bounds
|
||||||
|
workspaceRect: {
|
||||||
|
x1: number;
|
||||||
|
y1: number;
|
||||||
|
x2: number;
|
||||||
|
y2: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Element list
|
||||||
|
elements: SerializedElement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// History Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface HistoryState {
|
||||||
|
elements: SerializedElement[];
|
||||||
|
currentScale: number;
|
||||||
|
translate: { translateX: number; translateY: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Input Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export type InputMode = 'default' | 'panning' | 'zooming' | 'selecting' | 'dragging' | 'resizing';
|
||||||
|
|
||||||
|
export interface MousePosition {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Renderer Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface ViewTransform {
|
||||||
|
scale: number;
|
||||||
|
translateX: number;
|
||||||
|
translateY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderElement {
|
||||||
|
id: string;
|
||||||
|
texture: GPUTexture;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
rotation: number;
|
||||||
|
zIndex: number;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Clipboard Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface ClipboardData {
|
||||||
|
type: 'image' | 'text' | 'files' | 'empty';
|
||||||
|
data?: string;
|
||||||
|
files?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Error Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export enum ErrorType {
|
||||||
|
WEBGPU_NOT_SUPPORTED = 'WEBGPU_NOT_SUPPORTED',
|
||||||
|
TEXTURE_LOAD_FAILED = 'TEXTURE_LOAD_FAILED',
|
||||||
|
FILE_READ_ERROR = 'FILE_READ_ERROR',
|
||||||
|
FILE_WRITE_ERROR = 'FILE_WRITE_ERROR',
|
||||||
|
CLIPBOARD_ERROR = 'CLIPBOARD_ERROR',
|
||||||
|
INVALID_SCENE_FORMAT = 'INVALID_SCENE_FORMAT',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppError {
|
||||||
|
type: ErrorType;
|
||||||
|
message: string;
|
||||||
|
details?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// UI Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface MenuItem {
|
||||||
|
label: string;
|
||||||
|
action: () => void;
|
||||||
|
checked?: boolean;
|
||||||
|
submenu?: MenuItem[];
|
||||||
|
separator?: boolean;
|
||||||
|
shortcut?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuState {
|
||||||
|
visible: boolean;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
items: MenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// App State Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface AppState {
|
||||||
|
isInitialized: boolean;
|
||||||
|
isLocked: boolean;
|
||||||
|
showHelp: boolean;
|
||||||
|
alwaysOnTop: boolean;
|
||||||
|
windowOpacity: number;
|
||||||
|
}
|
||||||
398
src/utils/clipboard.ts
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
/**
|
||||||
|
* Clipboard utilities for copy/paste operations
|
||||||
|
*
|
||||||
|
* This module provides clipboard integration with Tauri backend
|
||||||
|
* for copying/pasting media elements and external content.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import type { MediaElement, ElementData, ImageData, GifData, TextData } from '../types';
|
||||||
|
import { loadImageFromBase64 } from './image';
|
||||||
|
import { loadGifFromBase64 } from './gif';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Clipboard Content Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Matches Rust ClipboardContent enum with serde tag/content format
|
||||||
|
export type ClipboardContent =
|
||||||
|
| { type: 'image'; data: string }
|
||||||
|
| { type: 'text'; data: string }
|
||||||
|
| { type: 'files'; data: string[] }
|
||||||
|
| { type: 'empty' };
|
||||||
|
|
||||||
|
export interface InternalClipboardData {
|
||||||
|
type: 'elements';
|
||||||
|
elements: SerializedClipboardElement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SerializedClipboardElement {
|
||||||
|
type: string;
|
||||||
|
data: ElementData;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
rotation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal clipboard for element copy/paste
|
||||||
|
let internalClipboard: InternalClipboardData | null = null;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Tauri Clipboard Commands
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read clipboard contents from system clipboard via Tauri
|
||||||
|
*/
|
||||||
|
export async function readClipboard(): Promise<ClipboardContent> {
|
||||||
|
try {
|
||||||
|
const result = await invoke<ClipboardContent>('read_clipboard');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to read clipboard:', error);
|
||||||
|
return { type: 'empty' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write image to system clipboard via Tauri
|
||||||
|
*/
|
||||||
|
export async function writeClipboardImage(base64Data: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await invoke('write_clipboard_image_base64', { base64Data });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to write image to clipboard:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write text to system clipboard via Tauri
|
||||||
|
*/
|
||||||
|
export async function writeClipboardText(text: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await invoke('write_clipboard_text', { text });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to write text to clipboard:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read image from file path as base64 via Tauri
|
||||||
|
*/
|
||||||
|
export async function readImageAsBase64(path: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const result = await invoke<string>('read_image_as_base64', { path });
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to read image as base64:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Element Copy/Paste
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write multiple images to clipboard as files (preserves GIF animation)
|
||||||
|
*/
|
||||||
|
export async function writeClipboardImagesAsFiles(images: Array<{ src: string; type: string }>): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const imageData = images.map(img => [img.src, img.type]);
|
||||||
|
await invoke('write_clipboard_images_as_files', { images: imageData });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to write images to clipboard:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy selected elements to internal clipboard
|
||||||
|
* Also copies to system clipboard for external paste
|
||||||
|
*/
|
||||||
|
export async function copyElements(elements: MediaElement[]): Promise<boolean> {
|
||||||
|
if (elements.length === 0) return false;
|
||||||
|
|
||||||
|
// Serialize elements for internal clipboard
|
||||||
|
const serialized: SerializedClipboardElement[] = elements.map(el => ({
|
||||||
|
type: el.type,
|
||||||
|
data: el.data,
|
||||||
|
width: el.width,
|
||||||
|
height: el.height,
|
||||||
|
rotation: el.rotation,
|
||||||
|
}));
|
||||||
|
|
||||||
|
internalClipboard = {
|
||||||
|
type: 'elements',
|
||||||
|
elements: serialized,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collect image/gif elements for system clipboard
|
||||||
|
const imageElements: Array<{ src: string; type: string }> = [];
|
||||||
|
|
||||||
|
for (const element of elements) {
|
||||||
|
if (element.type === 'image') {
|
||||||
|
const imageData = element.data as ImageData;
|
||||||
|
if (imageData.src) {
|
||||||
|
imageElements.push({ src: imageData.src, type: 'image/png' });
|
||||||
|
}
|
||||||
|
} else if (element.type === 'gif') {
|
||||||
|
const gifData = element.data as GifData;
|
||||||
|
if (gifData.src) {
|
||||||
|
imageElements.push({ src: gifData.src, type: 'image/gif' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy images to system clipboard as files (preserves GIF animation)
|
||||||
|
if (imageElements.length > 0) {
|
||||||
|
await writeClipboardImagesAsFiles(imageElements);
|
||||||
|
} else if (elements.length === 1 && elements[0].type === 'text') {
|
||||||
|
// For single text element, copy text content
|
||||||
|
const textData = elements[0].data as TextData;
|
||||||
|
await writeClipboardText(textData.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if internal clipboard has elements
|
||||||
|
*/
|
||||||
|
export function hasInternalClipboardData(): boolean {
|
||||||
|
return internalClipboard !== null && internalClipboard.elements.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get elements from internal clipboard
|
||||||
|
*/
|
||||||
|
export function getInternalClipboardElements(): SerializedClipboardElement[] {
|
||||||
|
return internalClipboard?.elements ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear internal clipboard
|
||||||
|
*/
|
||||||
|
export function clearInternalClipboard(): void {
|
||||||
|
internalClipboard = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Paste Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface PasteResult {
|
||||||
|
type: 'elements' | 'image' | 'gif' | 'text' | 'files' | 'youtube' | 'empty';
|
||||||
|
elements?: SerializedClipboardElement[];
|
||||||
|
imageData?: {
|
||||||
|
src: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
gifData?: {
|
||||||
|
src: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
frames: ImageBitmap[];
|
||||||
|
frameDelays: number[];
|
||||||
|
};
|
||||||
|
textContent?: string;
|
||||||
|
filePaths?: string[];
|
||||||
|
youtubeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process paste operation - checks internal clipboard first, then system clipboard
|
||||||
|
*/
|
||||||
|
export async function processPaste(): Promise<PasteResult> {
|
||||||
|
// First check internal clipboard for copied elements
|
||||||
|
if (hasInternalClipboardData()) {
|
||||||
|
return {
|
||||||
|
type: 'elements',
|
||||||
|
elements: getInternalClipboardElements(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read from system clipboard
|
||||||
|
const clipboardContent = await readClipboard();
|
||||||
|
|
||||||
|
switch (clipboardContent.type) {
|
||||||
|
case 'image':
|
||||||
|
return await processImagePaste(clipboardContent.data);
|
||||||
|
|
||||||
|
case 'text':
|
||||||
|
return processTextPaste(clipboardContent.data);
|
||||||
|
|
||||||
|
case 'files':
|
||||||
|
// Files are returned as string array directly
|
||||||
|
if (clipboardContent.type === 'files') {
|
||||||
|
return await processFilesPaste(clipboardContent.data);
|
||||||
|
}
|
||||||
|
return { type: 'empty' };
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { type: 'empty' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process image paste from clipboard
|
||||||
|
*/
|
||||||
|
async function processImagePaste(base64Data: string): Promise<PasteResult> {
|
||||||
|
try {
|
||||||
|
// Check if it's a GIF
|
||||||
|
if (base64Data.includes('image/gif')) {
|
||||||
|
const gifData = await loadGifFromBase64(base64Data);
|
||||||
|
return {
|
||||||
|
type: 'gif',
|
||||||
|
gifData: {
|
||||||
|
src: base64Data,
|
||||||
|
width: gifData.width,
|
||||||
|
height: gifData.height,
|
||||||
|
frames: gifData.frames.map(f => f.bitmap),
|
||||||
|
frameDelays: gifData.frames.map(f => f.delay),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular image
|
||||||
|
const bitmap = await loadImageFromBase64(base64Data);
|
||||||
|
return {
|
||||||
|
type: 'image',
|
||||||
|
imageData: {
|
||||||
|
src: base64Data,
|
||||||
|
width: bitmap.width,
|
||||||
|
height: bitmap.height,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to process image paste:', error);
|
||||||
|
return { type: 'empty' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process text paste from clipboard
|
||||||
|
*/
|
||||||
|
function processTextPaste(text: string): PasteResult {
|
||||||
|
// Check for YouTube URL
|
||||||
|
const youtubeId = extractYouTubeId(text);
|
||||||
|
if (youtubeId) {
|
||||||
|
return {
|
||||||
|
type: 'youtube',
|
||||||
|
youtubeId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular text
|
||||||
|
return {
|
||||||
|
type: 'text',
|
||||||
|
textContent: text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process file paths paste from clipboard
|
||||||
|
*/
|
||||||
|
async function processFilesPaste(filePaths: string[]): Promise<PasteResult> {
|
||||||
|
if (filePaths.length === 0) {
|
||||||
|
return { type: 'empty' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// For single image file, load and return as image/gif
|
||||||
|
if (filePaths.length === 1) {
|
||||||
|
const path = filePaths[0];
|
||||||
|
const ext = path.split('.').pop()?.toLowerCase() || '';
|
||||||
|
|
||||||
|
if (['png', 'jpg', 'jpeg', 'bmp', 'webp'].includes(ext)) {
|
||||||
|
const base64 = await readImageAsBase64(path);
|
||||||
|
if (base64) {
|
||||||
|
return processImagePaste(base64);
|
||||||
|
}
|
||||||
|
} else if (ext === 'gif') {
|
||||||
|
// For GIF files, load with animation frames preserved
|
||||||
|
const base64 = await readImageAsBase64(path);
|
||||||
|
if (base64) {
|
||||||
|
try {
|
||||||
|
const gifData = await loadGifFromBase64(base64);
|
||||||
|
return {
|
||||||
|
type: 'gif',
|
||||||
|
gifData: {
|
||||||
|
src: base64,
|
||||||
|
width: gifData.width,
|
||||||
|
height: gifData.height,
|
||||||
|
frames: gifData.frames.map(f => f.bitmap),
|
||||||
|
frameDelays: gifData.frames.map(f => f.delay),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse GIF:', error);
|
||||||
|
// Fallback to static image
|
||||||
|
return processImagePaste(base64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return file paths for further processing (multiple files)
|
||||||
|
return {
|
||||||
|
type: 'files',
|
||||||
|
filePaths,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// YouTube URL Detection
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract YouTube video ID from URL
|
||||||
|
*/
|
||||||
|
export function extractYouTubeId(text: string): string | null {
|
||||||
|
const patterns = [
|
||||||
|
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
|
||||||
|
/youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = text.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Utility Functions
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if clipboard contains image data
|
||||||
|
*/
|
||||||
|
export async function hasClipboardImage(): Promise<boolean> {
|
||||||
|
const content = await readClipboard();
|
||||||
|
return content.type === 'image';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if clipboard contains text data
|
||||||
|
*/
|
||||||
|
export async function hasClipboardText(): Promise<boolean> {
|
||||||
|
const content = await readClipboard();
|
||||||
|
return content.type === 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get clipboard content type
|
||||||
|
*/
|
||||||
|
export async function getClipboardType(): Promise<'image' | 'text' | 'files' | 'empty'> {
|
||||||
|
const content = await readClipboard();
|
||||||
|
return content.type;
|
||||||
|
}
|
||||||
631
src/utils/gif.ts
Normal file
@@ -0,0 +1,631 @@
|
|||||||
|
/**
|
||||||
|
* GIF processing utilities
|
||||||
|
*
|
||||||
|
* This module provides GIF parsing, frame extraction, and animation control.
|
||||||
|
* Uses a custom GIF parser to extract individual frames and timing information.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { base64ToBlob, blobToBase64 } from './image';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// GIF Frame Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface GifFrame {
|
||||||
|
/** ImageBitmap for this frame */
|
||||||
|
bitmap: ImageBitmap;
|
||||||
|
/** Delay in milliseconds before showing next frame */
|
||||||
|
delay: number;
|
||||||
|
/** Frame index */
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GifData {
|
||||||
|
/** All frames of the GIF */
|
||||||
|
frames: GifFrame[];
|
||||||
|
/** Total duration of one loop in milliseconds */
|
||||||
|
totalDuration: number;
|
||||||
|
/** Width of the GIF */
|
||||||
|
width: number;
|
||||||
|
/** Height of the GIF */
|
||||||
|
height: number;
|
||||||
|
/** Number of loops (0 = infinite) */
|
||||||
|
loopCount: number;
|
||||||
|
/** Original base64 data for copying */
|
||||||
|
originalData: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// GIF Parser
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse GIF file and extract all frames
|
||||||
|
* Uses canvas-based approach for broad compatibility
|
||||||
|
*/
|
||||||
|
export async function parseGif(source: File | Blob | string): Promise<GifData> {
|
||||||
|
let blob: Blob;
|
||||||
|
let originalData: string;
|
||||||
|
|
||||||
|
if (typeof source === 'string') {
|
||||||
|
// Base64 data URL
|
||||||
|
blob = base64ToBlob(source);
|
||||||
|
originalData = source;
|
||||||
|
} else {
|
||||||
|
blob = source;
|
||||||
|
originalData = await blobToBase64(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
|
const frames = await extractGifFrames(arrayBuffer);
|
||||||
|
|
||||||
|
if (frames.length === 0) {
|
||||||
|
throw new Error('No frames found in GIF');
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalDuration = frames.reduce((sum, frame) => sum + frame.delay, 0);
|
||||||
|
const { width, height } = frames[0].bitmap;
|
||||||
|
|
||||||
|
return {
|
||||||
|
frames,
|
||||||
|
totalDuration,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
loopCount: 0, // Default to infinite loop
|
||||||
|
originalData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract frames from GIF ArrayBuffer using low-level parsing
|
||||||
|
*/
|
||||||
|
async function extractGifFrames(buffer: ArrayBuffer): Promise<GifFrame[]> {
|
||||||
|
const data = new Uint8Array(buffer);
|
||||||
|
const frames: GifFrame[] = [];
|
||||||
|
|
||||||
|
// Verify GIF signature
|
||||||
|
const signature = String.fromCharCode(...data.slice(0, 6));
|
||||||
|
if (!signature.startsWith('GIF')) {
|
||||||
|
throw new Error('Invalid GIF file');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse logical screen descriptor
|
||||||
|
const width = data[6] | (data[7] << 8);
|
||||||
|
const height = data[8] | (data[9] << 8);
|
||||||
|
const packed = data[10];
|
||||||
|
const hasGlobalColorTable = (packed & 0x80) !== 0;
|
||||||
|
const globalColorTableSize = hasGlobalColorTable ? 3 * (1 << ((packed & 0x07) + 1)) : 0;
|
||||||
|
|
||||||
|
// Skip global color table
|
||||||
|
let offset = 13 + globalColorTableSize;
|
||||||
|
|
||||||
|
// Create canvas for compositing frames
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
|
||||||
|
// Previous frame canvas for disposal
|
||||||
|
const prevCanvas = document.createElement('canvas');
|
||||||
|
prevCanvas.width = width;
|
||||||
|
prevCanvas.height = height;
|
||||||
|
const prevCtx = prevCanvas.getContext('2d')!;
|
||||||
|
|
||||||
|
let frameIndex = 0;
|
||||||
|
let delay = 100; // Default delay in ms
|
||||||
|
let disposalMethod = 0;
|
||||||
|
let transparentIndex = -1;
|
||||||
|
let hasTransparency = false;
|
||||||
|
|
||||||
|
// Parse blocks
|
||||||
|
while (offset < data.length) {
|
||||||
|
const blockType = data[offset++];
|
||||||
|
|
||||||
|
if (blockType === 0x21) {
|
||||||
|
// Extension block
|
||||||
|
const extType = data[offset++];
|
||||||
|
|
||||||
|
if (extType === 0xF9) {
|
||||||
|
// Graphics Control Extension
|
||||||
|
const blockSize = data[offset++];
|
||||||
|
const packedByte = data[offset];
|
||||||
|
disposalMethod = (packedByte >> 2) & 0x07;
|
||||||
|
hasTransparency = (packedByte & 0x01) !== 0;
|
||||||
|
delay = (data[offset + 1] | (data[offset + 2] << 8)) * 10; // Convert to ms
|
||||||
|
if (delay === 0) delay = 100; // Minimum delay
|
||||||
|
transparentIndex = hasTransparency ? data[offset + 3] : -1;
|
||||||
|
offset += blockSize + 1; // +1 for block terminator
|
||||||
|
} else {
|
||||||
|
// Skip other extensions
|
||||||
|
while (data[offset] !== 0) {
|
||||||
|
offset += data[offset] + 1;
|
||||||
|
}
|
||||||
|
offset++; // Block terminator
|
||||||
|
}
|
||||||
|
} else if (blockType === 0x2C) {
|
||||||
|
// Image descriptor
|
||||||
|
const frameLeft = data[offset] | (data[offset + 1] << 8);
|
||||||
|
const frameTop = data[offset + 2] | (data[offset + 3] << 8);
|
||||||
|
const frameWidth = data[offset + 4] | (data[offset + 5] << 8);
|
||||||
|
const frameHeight = data[offset + 6] | (data[offset + 7] << 8);
|
||||||
|
const framePacked = data[offset + 8];
|
||||||
|
offset += 9;
|
||||||
|
|
||||||
|
const hasLocalColorTable = (framePacked & 0x80) !== 0;
|
||||||
|
const interlaced = (framePacked & 0x40) !== 0;
|
||||||
|
const localColorTableSize = hasLocalColorTable ? 3 * (1 << ((framePacked & 0x07) + 1)) : 0;
|
||||||
|
|
||||||
|
// Get color table
|
||||||
|
let colorTable: Uint8Array;
|
||||||
|
if (hasLocalColorTable) {
|
||||||
|
colorTable = data.slice(offset, offset + localColorTableSize);
|
||||||
|
offset += localColorTableSize;
|
||||||
|
} else {
|
||||||
|
colorTable = data.slice(13, 13 + globalColorTableSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode LZW compressed image data
|
||||||
|
const minCodeSize = data[offset++];
|
||||||
|
const imageData = decodeLZW(data, offset, minCodeSize, frameWidth * frameHeight);
|
||||||
|
|
||||||
|
// Skip to end of image data
|
||||||
|
while (data[offset] !== 0) {
|
||||||
|
offset += data[offset] + 1;
|
||||||
|
}
|
||||||
|
offset++; // Block terminator
|
||||||
|
|
||||||
|
// Handle disposal method
|
||||||
|
if (disposalMethod === 2) {
|
||||||
|
// Restore to background
|
||||||
|
ctx.clearRect(frameLeft, frameTop, frameWidth, frameHeight);
|
||||||
|
} else if (disposalMethod === 3) {
|
||||||
|
// Restore to previous
|
||||||
|
ctx.drawImage(prevCanvas, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save current state for potential restoration
|
||||||
|
prevCtx.clearRect(0, 0, width, height);
|
||||||
|
prevCtx.drawImage(canvas, 0, 0);
|
||||||
|
|
||||||
|
// Draw frame pixels
|
||||||
|
const frameImageData = ctx.createImageData(frameWidth, frameHeight);
|
||||||
|
let pixelIndex = 0;
|
||||||
|
|
||||||
|
for (let y = 0; y < frameHeight; y++) {
|
||||||
|
const row = interlaced ? getInterlacedRow(y, frameHeight) : y;
|
||||||
|
for (let x = 0; x < frameWidth; x++) {
|
||||||
|
const colorIndex = imageData[row * frameWidth + x];
|
||||||
|
const destIndex = (y * frameWidth + x) * 4;
|
||||||
|
|
||||||
|
if (hasTransparency && colorIndex === transparentIndex) {
|
||||||
|
frameImageData.data[destIndex] = 0;
|
||||||
|
frameImageData.data[destIndex + 1] = 0;
|
||||||
|
frameImageData.data[destIndex + 2] = 0;
|
||||||
|
frameImageData.data[destIndex + 3] = 0;
|
||||||
|
} else {
|
||||||
|
const colorOffset = colorIndex * 3;
|
||||||
|
frameImageData.data[destIndex] = colorTable[colorOffset];
|
||||||
|
frameImageData.data[destIndex + 1] = colorTable[colorOffset + 1];
|
||||||
|
frameImageData.data[destIndex + 2] = colorTable[colorOffset + 2];
|
||||||
|
frameImageData.data[destIndex + 3] = 255;
|
||||||
|
}
|
||||||
|
pixelIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create temporary canvas for frame
|
||||||
|
const tempCanvas = document.createElement('canvas');
|
||||||
|
tempCanvas.width = frameWidth;
|
||||||
|
tempCanvas.height = frameHeight;
|
||||||
|
const tempCtx = tempCanvas.getContext('2d')!;
|
||||||
|
tempCtx.putImageData(frameImageData, 0, 0);
|
||||||
|
|
||||||
|
// Composite frame onto main canvas
|
||||||
|
ctx.drawImage(tempCanvas, frameLeft, frameTop);
|
||||||
|
|
||||||
|
// Create ImageBitmap from current canvas state
|
||||||
|
const bitmap = await createImageBitmap(canvas);
|
||||||
|
|
||||||
|
frames.push({
|
||||||
|
bitmap,
|
||||||
|
delay,
|
||||||
|
index: frameIndex++,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset for next frame
|
||||||
|
delay = 100;
|
||||||
|
transparentIndex = -1;
|
||||||
|
hasTransparency = false;
|
||||||
|
} else if (blockType === 0x3B) {
|
||||||
|
// Trailer - end of GIF
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
// Unknown block, try to skip
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode LZW compressed data
|
||||||
|
*/
|
||||||
|
function decodeLZW(
|
||||||
|
data: Uint8Array,
|
||||||
|
offset: number,
|
||||||
|
minCodeSize: number,
|
||||||
|
pixelCount: number
|
||||||
|
): Uint8Array {
|
||||||
|
const clearCode = 1 << minCodeSize;
|
||||||
|
const endCode = clearCode + 1;
|
||||||
|
let codeSize = minCodeSize + 1;
|
||||||
|
let codeMask = (1 << codeSize) - 1;
|
||||||
|
let nextCode = endCode + 1;
|
||||||
|
|
||||||
|
// Initialize code table
|
||||||
|
const codeTable: number[][] = [];
|
||||||
|
for (let i = 0; i < clearCode; i++) {
|
||||||
|
codeTable[i] = [i];
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = new Uint8Array(pixelCount);
|
||||||
|
let outputIndex = 0;
|
||||||
|
|
||||||
|
// Read sub-blocks
|
||||||
|
let bitBuffer = 0;
|
||||||
|
let bitsInBuffer = 0;
|
||||||
|
let blockOffset = offset;
|
||||||
|
let blockSize = data[blockOffset++];
|
||||||
|
let blockIndex = 0;
|
||||||
|
|
||||||
|
function readCode(): number {
|
||||||
|
while (bitsInBuffer < codeSize) {
|
||||||
|
if (blockIndex >= blockSize) {
|
||||||
|
blockSize = data[blockOffset++];
|
||||||
|
blockIndex = 0;
|
||||||
|
if (blockSize === 0) return endCode;
|
||||||
|
}
|
||||||
|
bitBuffer |= data[blockOffset++] << bitsInBuffer;
|
||||||
|
blockIndex++;
|
||||||
|
bitsInBuffer += 8;
|
||||||
|
}
|
||||||
|
const code = bitBuffer & codeMask;
|
||||||
|
bitBuffer >>= codeSize;
|
||||||
|
bitsInBuffer -= codeSize;
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
let prevCode = -1;
|
||||||
|
|
||||||
|
while (outputIndex < pixelCount) {
|
||||||
|
const code = readCode();
|
||||||
|
|
||||||
|
if (code === clearCode) {
|
||||||
|
// Reset
|
||||||
|
codeSize = minCodeSize + 1;
|
||||||
|
codeMask = (1 << codeSize) - 1;
|
||||||
|
nextCode = endCode + 1;
|
||||||
|
codeTable.length = clearCode;
|
||||||
|
for (let i = 0; i < clearCode; i++) {
|
||||||
|
codeTable[i] = [i];
|
||||||
|
}
|
||||||
|
prevCode = -1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === endCode) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sequence: number[];
|
||||||
|
|
||||||
|
if (code < nextCode) {
|
||||||
|
sequence = codeTable[code];
|
||||||
|
} else if (code === nextCode && prevCode !== -1) {
|
||||||
|
sequence = [...codeTable[prevCode], codeTable[prevCode][0]];
|
||||||
|
} else {
|
||||||
|
// Invalid code
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output sequence
|
||||||
|
for (const pixel of sequence) {
|
||||||
|
if (outputIndex < pixelCount) {
|
||||||
|
output[outputIndex++] = pixel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new code to table
|
||||||
|
if (prevCode !== -1 && nextCode < 4096) {
|
||||||
|
codeTable[nextCode++] = [...codeTable[prevCode], sequence[0]];
|
||||||
|
|
||||||
|
// Increase code size if needed
|
||||||
|
if (nextCode > codeMask && codeSize < 12) {
|
||||||
|
codeSize++;
|
||||||
|
codeMask = (1 << codeSize) - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prevCode = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get interlaced row mapping
|
||||||
|
*/
|
||||||
|
function getInterlacedRow(y: number, height: number): number {
|
||||||
|
const pass1Rows = Math.ceil(height / 8);
|
||||||
|
const pass2Rows = Math.ceil((height - 4) / 8);
|
||||||
|
const pass3Rows = Math.ceil((height - 2) / 4);
|
||||||
|
|
||||||
|
if (y < pass1Rows) {
|
||||||
|
return y * 8;
|
||||||
|
} else if (y < pass1Rows + pass2Rows) {
|
||||||
|
return (y - pass1Rows) * 8 + 4;
|
||||||
|
} else if (y < pass1Rows + pass2Rows + pass3Rows) {
|
||||||
|
return (y - pass1Rows - pass2Rows) * 4 + 2;
|
||||||
|
} else {
|
||||||
|
return (y - pass1Rows - pass2Rows - pass3Rows) * 2 + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// GIF Animation Controller
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export class GifAnimator {
|
||||||
|
private gifData: GifData;
|
||||||
|
private currentFrameIndex: number = 0;
|
||||||
|
private isPlaying: boolean = false;
|
||||||
|
private animationTimer: number | null = null;
|
||||||
|
private onFrameChange: ((frame: GifFrame) => void) | null = null;
|
||||||
|
private startTime: number = 0;
|
||||||
|
|
||||||
|
constructor(gifData: GifData) {
|
||||||
|
this.gifData = gifData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set callback for frame changes
|
||||||
|
*/
|
||||||
|
setOnFrameChange(callback: (frame: GifFrame) => void): void {
|
||||||
|
this.onFrameChange = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start animation playback
|
||||||
|
*/
|
||||||
|
play(): void {
|
||||||
|
if (this.isPlaying) return;
|
||||||
|
this.isPlaying = true;
|
||||||
|
this.startTime = performance.now() - this.getElapsedTimeForFrame(this.currentFrameIndex);
|
||||||
|
this.scheduleNextFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause animation playback
|
||||||
|
*/
|
||||||
|
pause(): void {
|
||||||
|
this.isPlaying = false;
|
||||||
|
if (this.animationTimer !== null) {
|
||||||
|
cancelAnimationFrame(this.animationTimer);
|
||||||
|
this.animationTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop animation and reset to first frame
|
||||||
|
*/
|
||||||
|
stop(): void {
|
||||||
|
this.pause();
|
||||||
|
this.currentFrameIndex = 0;
|
||||||
|
this.notifyFrameChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle play/pause
|
||||||
|
*/
|
||||||
|
toggle(): void {
|
||||||
|
if (this.isPlaying) {
|
||||||
|
this.pause();
|
||||||
|
} else {
|
||||||
|
this.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go to a specific frame
|
||||||
|
*/
|
||||||
|
goToFrame(index: number): void {
|
||||||
|
if (index < 0 || index >= this.gifData.frames.length) return;
|
||||||
|
this.currentFrameIndex = index;
|
||||||
|
this.startTime = performance.now() - this.getElapsedTimeForFrame(index);
|
||||||
|
this.notifyFrameChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go to next frame
|
||||||
|
*/
|
||||||
|
nextFrame(): void {
|
||||||
|
this.currentFrameIndex = (this.currentFrameIndex + 1) % this.gifData.frames.length;
|
||||||
|
this.notifyFrameChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go to previous frame
|
||||||
|
*/
|
||||||
|
prevFrame(): void {
|
||||||
|
this.currentFrameIndex =
|
||||||
|
(this.currentFrameIndex - 1 + this.gifData.frames.length) % this.gifData.frames.length;
|
||||||
|
this.notifyFrameChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current frame
|
||||||
|
*/
|
||||||
|
getCurrentFrame(): GifFrame {
|
||||||
|
return this.gifData.frames[this.currentFrameIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current frame index
|
||||||
|
*/
|
||||||
|
getCurrentFrameIndex(): number {
|
||||||
|
return this.currentFrameIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total frame count
|
||||||
|
*/
|
||||||
|
getFrameCount(): number {
|
||||||
|
return this.gifData.frames.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if animation is playing
|
||||||
|
*/
|
||||||
|
getIsPlaying(): boolean {
|
||||||
|
return this.isPlaying;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get GIF data
|
||||||
|
*/
|
||||||
|
getGifData(): GifData {
|
||||||
|
return this.gifData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate elapsed time for a given frame index
|
||||||
|
*/
|
||||||
|
private getElapsedTimeForFrame(frameIndex: number): number {
|
||||||
|
let elapsed = 0;
|
||||||
|
for (let i = 0; i < frameIndex; i++) {
|
||||||
|
elapsed += this.gifData.frames[i].delay;
|
||||||
|
}
|
||||||
|
return elapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule the next frame update
|
||||||
|
*/
|
||||||
|
private scheduleNextFrame(): void {
|
||||||
|
if (!this.isPlaying) return;
|
||||||
|
|
||||||
|
this.animationTimer = requestAnimationFrame(() => {
|
||||||
|
const elapsed = performance.now() - this.startTime;
|
||||||
|
const loopedElapsed = elapsed % this.gifData.totalDuration;
|
||||||
|
|
||||||
|
// Find current frame based on elapsed time
|
||||||
|
let accumulatedTime = 0;
|
||||||
|
let newFrameIndex = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < this.gifData.frames.length; i++) {
|
||||||
|
accumulatedTime += this.gifData.frames[i].delay;
|
||||||
|
if (loopedElapsed < accumulatedTime) {
|
||||||
|
newFrameIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newFrameIndex !== this.currentFrameIndex) {
|
||||||
|
this.currentFrameIndex = newFrameIndex;
|
||||||
|
this.notifyFrameChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scheduleNextFrame();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify frame change callback
|
||||||
|
*/
|
||||||
|
private notifyFrameChange(): void {
|
||||||
|
if (this.onFrameChange) {
|
||||||
|
this.onFrameChange(this.getCurrentFrame());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy the animator and release resources
|
||||||
|
*/
|
||||||
|
destroy(): void {
|
||||||
|
this.pause();
|
||||||
|
this.onFrameChange = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// GIF Loading Helpers
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load GIF from a File
|
||||||
|
*/
|
||||||
|
export async function loadGifFromFile(file: File): Promise<GifData> {
|
||||||
|
return parseGif(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load GIF from a base64 data URL
|
||||||
|
*/
|
||||||
|
export async function loadGifFromBase64(base64DataUrl: string): Promise<GifData> {
|
||||||
|
return parseGif(base64DataUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load GIF from a URL
|
||||||
|
*/
|
||||||
|
export async function loadGifFromUrl(url: string): Promise<GifData> {
|
||||||
|
const response = await fetch(url);
|
||||||
|
const blob = await response.blob();
|
||||||
|
return parseGif(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// GIF Copy Support
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the original GIF data as a Blob for clipboard operations
|
||||||
|
* This preserves the animation when copying
|
||||||
|
*/
|
||||||
|
export function getGifAsBlob(gifData: GifData): Blob {
|
||||||
|
return base64ToBlob(gifData.originalData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the original GIF data as base64 for serialization
|
||||||
|
*/
|
||||||
|
export function getGifAsBase64(gifData: GifData): string {
|
||||||
|
return gifData.originalData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file is an animated GIF (has multiple frames)
|
||||||
|
*/
|
||||||
|
export async function isAnimatedGif(source: File | Blob): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const gifData = await parseGif(source);
|
||||||
|
return gifData.frames.length > 1;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Cleanup
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release all ImageBitmaps in a GifData object
|
||||||
|
*/
|
||||||
|
export function disposeGifData(gifData: GifData): void {
|
||||||
|
for (const frame of gifData.frames) {
|
||||||
|
frame.bitmap.close();
|
||||||
|
}
|
||||||
|
gifData.frames = [];
|
||||||
|
}
|
||||||
375
src/utils/image.ts
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
/**
|
||||||
|
* Image processing utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Supported image formats
|
||||||
|
export const SUPPORTED_IMAGE_FORMATS = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'];
|
||||||
|
export const SUPPORTED_IMAGE_MIME_TYPES = [
|
||||||
|
'image/png',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/gif',
|
||||||
|
'image/bmp',
|
||||||
|
'image/webp',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file is a supported image format
|
||||||
|
*/
|
||||||
|
export function isSupportedImageFormat(filename: string): boolean {
|
||||||
|
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||||
|
return SUPPORTED_IMAGE_FORMATS.includes(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a MIME type is a supported image format
|
||||||
|
*/
|
||||||
|
export function isSupportedImageMimeType(mimeType: string): boolean {
|
||||||
|
return SUPPORTED_IMAGE_MIME_TYPES.includes(mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file is a GIF
|
||||||
|
*/
|
||||||
|
export function isGifFile(filename: string): boolean {
|
||||||
|
return filename.toLowerCase().endsWith('.gif');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a MIME type is GIF
|
||||||
|
*/
|
||||||
|
export function isGifMimeType(mimeType: string): boolean {
|
||||||
|
return mimeType === 'image/gif';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Image Loading
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load an image from a File object and create an ImageBitmap
|
||||||
|
*/
|
||||||
|
export async function loadImageFromFile(file: File): Promise<ImageBitmap> {
|
||||||
|
return createImageBitmap(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load an image from a Blob and create an ImageBitmap
|
||||||
|
*/
|
||||||
|
export async function loadImageFromBlob(blob: Blob): Promise<ImageBitmap> {
|
||||||
|
return createImageBitmap(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load an image from a URL and create an ImageBitmap
|
||||||
|
*/
|
||||||
|
export async function loadImageFromUrl(url: string): Promise<ImageBitmap> {
|
||||||
|
const response = await fetch(url);
|
||||||
|
const blob = await response.blob();
|
||||||
|
return createImageBitmap(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load an image from a base64 data URL and create an ImageBitmap
|
||||||
|
*/
|
||||||
|
export async function loadImageFromBase64(base64DataUrl: string): Promise<ImageBitmap> {
|
||||||
|
const blob = base64ToBlob(base64DataUrl);
|
||||||
|
return createImageBitmap(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load an image from an ArrayBuffer and create an ImageBitmap
|
||||||
|
*/
|
||||||
|
export async function loadImageFromArrayBuffer(
|
||||||
|
buffer: ArrayBuffer,
|
||||||
|
mimeType: string = 'image/png'
|
||||||
|
): Promise<ImageBitmap> {
|
||||||
|
const blob = new Blob([buffer], { type: mimeType });
|
||||||
|
return createImageBitmap(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Base64 Encoding/Decoding
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Blob to a base64 data URL
|
||||||
|
*/
|
||||||
|
export function blobToBase64(blob: Blob): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
if (typeof reader.result === 'string') {
|
||||||
|
resolve(reader.result);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Failed to read blob as base64'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a File to a base64 data URL
|
||||||
|
*/
|
||||||
|
export function fileToBase64(file: File): Promise<string> {
|
||||||
|
return blobToBase64(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an ArrayBuffer to a base64 data URL
|
||||||
|
*/
|
||||||
|
export function arrayBufferToBase64(buffer: ArrayBuffer, mimeType: string = 'image/png'): string {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.byteLength; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return `data:${mimeType};base64,${btoa(binary)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a base64 data URL to a Blob
|
||||||
|
*/
|
||||||
|
export function base64ToBlob(base64DataUrl: string): Blob {
|
||||||
|
// Extract the base64 content and MIME type
|
||||||
|
const matches = base64DataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||||
|
if (!matches) {
|
||||||
|
throw new Error('Invalid base64 data URL format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = matches[1];
|
||||||
|
const base64Content = matches[2];
|
||||||
|
|
||||||
|
// Decode base64 to binary
|
||||||
|
const binaryString = atob(base64Content);
|
||||||
|
const bytes = new Uint8Array(binaryString.length);
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Blob([bytes], { type: mimeType });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a base64 data URL to an ArrayBuffer
|
||||||
|
*/
|
||||||
|
export function base64ToArrayBuffer(base64DataUrl: string): ArrayBuffer {
|
||||||
|
const blob = base64ToBlob(base64DataUrl);
|
||||||
|
return blob.arrayBuffer() as unknown as ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract MIME type from a base64 data URL
|
||||||
|
*/
|
||||||
|
export function getMimeTypeFromBase64(base64DataUrl: string): string | null {
|
||||||
|
const matches = base64DataUrl.match(/^data:([^;]+);base64,/);
|
||||||
|
return matches ? matches[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a string is a valid base64 data URL
|
||||||
|
*/
|
||||||
|
export function isBase64DataUrl(str: string): boolean {
|
||||||
|
return /^data:[^;]+;base64,/.test(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Image Information
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface ImageInfo {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
aspectRatio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get image dimensions from an ImageBitmap
|
||||||
|
*/
|
||||||
|
export function getImageInfo(image: ImageBitmap): ImageInfo {
|
||||||
|
return {
|
||||||
|
width: image.width,
|
||||||
|
height: image.height,
|
||||||
|
aspectRatio: image.width / image.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get image dimensions from a File without fully loading it
|
||||||
|
*/
|
||||||
|
export async function getImageInfoFromFile(file: File): Promise<ImageInfo> {
|
||||||
|
const bitmap = await createImageBitmap(file);
|
||||||
|
const info = getImageInfo(bitmap);
|
||||||
|
bitmap.close();
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get image dimensions from a base64 data URL
|
||||||
|
*/
|
||||||
|
export async function getImageInfoFromBase64(base64DataUrl: string): Promise<ImageInfo> {
|
||||||
|
const blob = base64ToBlob(base64DataUrl);
|
||||||
|
const bitmap = await createImageBitmap(blob);
|
||||||
|
const info = getImageInfo(bitmap);
|
||||||
|
bitmap.close();
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Image Conversion
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an ImageBitmap to a canvas
|
||||||
|
*/
|
||||||
|
export function imageBitmapToCanvas(image: ImageBitmap): HTMLCanvasElement {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = image.width;
|
||||||
|
canvas.height = image.height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('Failed to get 2D context');
|
||||||
|
}
|
||||||
|
ctx.drawImage(image, 0, 0);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an ImageBitmap to a base64 data URL
|
||||||
|
*/
|
||||||
|
export async function imageBitmapToBase64(
|
||||||
|
image: ImageBitmap,
|
||||||
|
mimeType: string = 'image/png',
|
||||||
|
quality: number = 0.92
|
||||||
|
): Promise<string> {
|
||||||
|
const canvas = imageBitmapToCanvas(image);
|
||||||
|
return canvas.toDataURL(mimeType, quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an ImageBitmap to a Blob
|
||||||
|
*/
|
||||||
|
export function imageBitmapToBlob(
|
||||||
|
image: ImageBitmap,
|
||||||
|
mimeType: string = 'image/png',
|
||||||
|
quality: number = 0.92
|
||||||
|
): Promise<Blob> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const canvas = imageBitmapToCanvas(image);
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) => {
|
||||||
|
if (blob) {
|
||||||
|
resolve(blob);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Failed to convert ImageBitmap to Blob'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mimeType,
|
||||||
|
quality
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Image Manipulation
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize an ImageBitmap to fit within max dimensions while maintaining aspect ratio
|
||||||
|
*/
|
||||||
|
export async function resizeImageBitmap(
|
||||||
|
image: ImageBitmap,
|
||||||
|
maxWidth: number,
|
||||||
|
maxHeight: number
|
||||||
|
): Promise<ImageBitmap> {
|
||||||
|
const { width, height } = image;
|
||||||
|
|
||||||
|
// Calculate scale to fit within bounds
|
||||||
|
const scale = Math.min(maxWidth / width, maxHeight / height, 1);
|
||||||
|
|
||||||
|
if (scale >= 1) {
|
||||||
|
// No resize needed, return a copy
|
||||||
|
return createImageBitmap(image);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newWidth = Math.round(width * scale);
|
||||||
|
const newHeight = Math.round(height * scale);
|
||||||
|
|
||||||
|
return createImageBitmap(image, {
|
||||||
|
resizeWidth: newWidth,
|
||||||
|
resizeHeight: newHeight,
|
||||||
|
resizeQuality: 'high',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crop an ImageBitmap to a specific region
|
||||||
|
*/
|
||||||
|
export async function cropImageBitmap(
|
||||||
|
image: ImageBitmap,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): Promise<ImageBitmap> {
|
||||||
|
return createImageBitmap(image, x, y, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Utility Functions
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a File as an ArrayBuffer
|
||||||
|
*/
|
||||||
|
export function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
if (reader.result instanceof ArrayBuffer) {
|
||||||
|
resolve(reader.result);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Failed to read file as ArrayBuffer'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a download link for an image
|
||||||
|
*/
|
||||||
|
export function downloadImage(
|
||||||
|
dataUrl: string,
|
||||||
|
filename: string = 'image.png'
|
||||||
|
): void {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load image from clipboard
|
||||||
|
*/
|
||||||
|
export async function loadImageFromClipboard(): Promise<ImageBitmap | null> {
|
||||||
|
try {
|
||||||
|
const clipboardItems = await navigator.clipboard.read();
|
||||||
|
for (const item of clipboardItems) {
|
||||||
|
for (const type of item.types) {
|
||||||
|
if (isSupportedImageMimeType(type)) {
|
||||||
|
const blob = await item.getType(type);
|
||||||
|
return createImageBitmap(blob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
console.warn('Failed to read image from clipboard');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/utils/math.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// Math utilities for coordinate transformations
|
||||||
|
// Implementation will be added as needed
|
||||||
|
|
||||||
|
export function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lerp(a: number, b: number, t: number): number {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateId(): string {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
485
src/utils/scene-service.ts
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
/**
|
||||||
|
* Scene Service
|
||||||
|
* Handles scene save/load operations with Tauri backend
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { getSceneManager, type SceneManager } from '../core/scene';
|
||||||
|
import { sceneToJson, loadSceneFromJson } from '../core/scene/serializer';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface FileDialogResult {
|
||||||
|
path: string | null;
|
||||||
|
cancelled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentFile {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Constants
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const RECENT_FILES_KEY = 'refvroid_recent_files';
|
||||||
|
const LAST_SCENE_KEY = 'refvroid_last_scene';
|
||||||
|
const AUTO_SAVE_KEY = 'refvroid_auto_save';
|
||||||
|
const MAX_RECENT_FILES = 10;
|
||||||
|
const AUTO_SAVE_INTERVAL = 60000; // 1 minute
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Scene Service Class
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export class SceneService {
|
||||||
|
private sceneManager: SceneManager;
|
||||||
|
private currentFilePath: string | null = null;
|
||||||
|
private autoSaveTimer: number | null = null;
|
||||||
|
private isDirty: boolean = false;
|
||||||
|
private onDirtyChange: ((dirty: boolean) => void) | null = null;
|
||||||
|
private onFileNameChange: ((fileName: string | null) => void) | null = null;
|
||||||
|
|
||||||
|
constructor(sceneManager?: SceneManager) {
|
||||||
|
this.sceneManager = sceneManager || getSceneManager();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// File Path Management
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
getCurrentFilePath(): string | null {
|
||||||
|
return this.currentFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentFileName(): string | null {
|
||||||
|
if (!this.currentFilePath) return null;
|
||||||
|
// Extract filename from path
|
||||||
|
const parts = this.currentFilePath.replace(/\\/g, '/').split('/');
|
||||||
|
return parts[parts.length - 1] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentFilePath(path: string | null): void {
|
||||||
|
this.currentFilePath = path;
|
||||||
|
if (path) {
|
||||||
|
this.addToRecentFiles(path);
|
||||||
|
this.saveLastScenePath(path);
|
||||||
|
}
|
||||||
|
// Notify file name change
|
||||||
|
this.onFileNameChange?.(this.getCurrentFileName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set callback for file name changes
|
||||||
|
*/
|
||||||
|
setOnFileNameChange(callback: (fileName: string | null) => void): void {
|
||||||
|
this.onFileNameChange = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Dirty State Management
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
markDirty(): void {
|
||||||
|
if (!this.isDirty) {
|
||||||
|
this.isDirty = true;
|
||||||
|
this.onDirtyChange?.(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markClean(): void {
|
||||||
|
if (this.isDirty) {
|
||||||
|
this.isDirty = false;
|
||||||
|
this.onDirtyChange?.(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getIsDirty(): boolean {
|
||||||
|
return this.isDirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOnDirtyChange(callback: (dirty: boolean) => void): void {
|
||||||
|
this.onDirtyChange = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Save Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save scene to current file or show save dialog
|
||||||
|
*/
|
||||||
|
async save(): Promise<boolean> {
|
||||||
|
if (this.currentFilePath) {
|
||||||
|
return this.saveToPath(this.currentFilePath);
|
||||||
|
}
|
||||||
|
return this.saveAs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show save dialog and save scene
|
||||||
|
*/
|
||||||
|
async saveAs(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const result = await invoke<FileDialogResult>('show_save_dialog', {
|
||||||
|
defaultName: this.getDefaultFileName(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.cancelled || !result.path) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.saveToPath(result.path);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to show save dialog:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save scene to specific path
|
||||||
|
*/
|
||||||
|
async saveToPath(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const json = sceneToJson(this.sceneManager);
|
||||||
|
await invoke('save_scene', { path, data: json });
|
||||||
|
|
||||||
|
this.setCurrentFilePath(path);
|
||||||
|
this.markClean();
|
||||||
|
// Clear auto-save since we saved to file
|
||||||
|
this.clearAutoSave();
|
||||||
|
|
||||||
|
console.log('Scene saved to:', path);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save scene:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultFileName(): string {
|
||||||
|
if (this.currentFilePath) {
|
||||||
|
const parts = this.currentFilePath.split(/[/\\]/);
|
||||||
|
return parts[parts.length - 1];
|
||||||
|
}
|
||||||
|
return 'scene.purgif';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Load Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show open dialog and load scene
|
||||||
|
*/
|
||||||
|
async load(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const result = await invoke<FileDialogResult>('show_open_dialog', {});
|
||||||
|
|
||||||
|
if (result.cancelled || !result.path) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.loadFromPath(result.path);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to show open dialog:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load scene from specific path
|
||||||
|
*/
|
||||||
|
async loadFromPath(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const json = await invoke<string>('load_scene', { path });
|
||||||
|
loadSceneFromJson(this.sceneManager, json);
|
||||||
|
|
||||||
|
this.setCurrentFilePath(path);
|
||||||
|
this.markClean();
|
||||||
|
|
||||||
|
console.log('Scene loaded from:', path);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load scene:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// New Scene
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new empty scene
|
||||||
|
*/
|
||||||
|
newScene(): void {
|
||||||
|
this.sceneManager.clear();
|
||||||
|
this.setCurrentFilePath(null); // This will trigger file name change callback
|
||||||
|
this.markClean();
|
||||||
|
this.clearAutoSave();
|
||||||
|
this.clearLastScenePath(); // Clear last scene path so Ctrl+R won't reload old scene
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Recent Files
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of recent files
|
||||||
|
*/
|
||||||
|
getRecentFiles(): RecentFile[] {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(RECENT_FILES_KEY);
|
||||||
|
if (!stored) return [];
|
||||||
|
return JSON.parse(stored) as RecentFile[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add file to recent files list
|
||||||
|
*/
|
||||||
|
private addToRecentFiles(path: string): void {
|
||||||
|
try {
|
||||||
|
const recent = this.getRecentFiles();
|
||||||
|
|
||||||
|
// Remove if already exists
|
||||||
|
const filtered = recent.filter(f => f.path !== path);
|
||||||
|
|
||||||
|
// Add to front
|
||||||
|
const name = path.split(/[/\\]/).pop() || path;
|
||||||
|
filtered.unshift({
|
||||||
|
path,
|
||||||
|
name,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Limit to max
|
||||||
|
const limited = filtered.slice(0, MAX_RECENT_FILES);
|
||||||
|
|
||||||
|
localStorage.setItem(RECENT_FILES_KEY, JSON.stringify(limited));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update recent files:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear recent files list
|
||||||
|
*/
|
||||||
|
clearRecentFiles(): void {
|
||||||
|
localStorage.removeItem(RECENT_FILES_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a file from recent files
|
||||||
|
*/
|
||||||
|
removeFromRecentFiles(path: string): void {
|
||||||
|
try {
|
||||||
|
const recent = this.getRecentFiles();
|
||||||
|
const filtered = recent.filter(f => f.path !== path);
|
||||||
|
localStorage.setItem(RECENT_FILES_KEY, JSON.stringify(filtered));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove from recent files:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Last Scene Path
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save last opened scene path
|
||||||
|
*/
|
||||||
|
private saveLastScenePath(path: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LAST_SCENE_KEY, path);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save last scene path:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get last opened scene path
|
||||||
|
*/
|
||||||
|
getLastScenePath(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(LAST_SCENE_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear last scene path
|
||||||
|
*/
|
||||||
|
clearLastScenePath(): void {
|
||||||
|
localStorage.removeItem(LAST_SCENE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Auto Save
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start auto-save timer
|
||||||
|
*/
|
||||||
|
startAutoSave(): void {
|
||||||
|
this.stopAutoSave();
|
||||||
|
this.autoSaveTimer = window.setInterval(() => {
|
||||||
|
this.performAutoSave();
|
||||||
|
}, AUTO_SAVE_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop auto-save timer
|
||||||
|
*/
|
||||||
|
stopAutoSave(): void {
|
||||||
|
if (this.autoSaveTimer !== null) {
|
||||||
|
clearInterval(this.autoSaveTimer);
|
||||||
|
this.autoSaveTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform auto-save
|
||||||
|
*/
|
||||||
|
private performAutoSave(): void {
|
||||||
|
if (!this.isDirty) return;
|
||||||
|
this.saveAutoSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger immediate auto-save (public method for important operations)
|
||||||
|
*/
|
||||||
|
triggerAutoSave(): void {
|
||||||
|
this.saveAutoSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save auto-save data to localStorage
|
||||||
|
*/
|
||||||
|
private saveAutoSave(): void {
|
||||||
|
try {
|
||||||
|
const json = sceneToJson(this.sceneManager);
|
||||||
|
const autoSaveData = {
|
||||||
|
json,
|
||||||
|
path: this.currentFilePath,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
localStorage.setItem(AUTO_SAVE_KEY, JSON.stringify(autoSaveData));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to auto-save:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get auto-save data
|
||||||
|
*/
|
||||||
|
getAutoSave(): { json: string; path: string | null; timestamp: number } | null {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(AUTO_SAVE_KEY);
|
||||||
|
if (!stored) return null;
|
||||||
|
return JSON.parse(stored);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore from auto-save
|
||||||
|
*/
|
||||||
|
restoreAutoSave(): boolean {
|
||||||
|
const autoSave = this.getAutoSave();
|
||||||
|
if (!autoSave) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
loadSceneFromJson(this.sceneManager, autoSave.json);
|
||||||
|
this.currentFilePath = autoSave.path;
|
||||||
|
this.markDirty(); // Mark dirty since it's recovered
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to restore auto-save:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear auto-save data
|
||||||
|
*/
|
||||||
|
clearAutoSave(): void {
|
||||||
|
localStorage.removeItem(AUTO_SAVE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if auto-save exists
|
||||||
|
*/
|
||||||
|
hasAutoSave(): boolean {
|
||||||
|
return this.getAutoSave() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Startup Loading
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load scene on startup - always load from file, not localStorage
|
||||||
|
*/
|
||||||
|
async loadOnStartup(): Promise<boolean> {
|
||||||
|
// Always try to load from last saved file first
|
||||||
|
const lastPath = this.getLastScenePath();
|
||||||
|
if (lastPath) {
|
||||||
|
try {
|
||||||
|
// Check if file exists
|
||||||
|
const exists = await invoke<boolean>('file_exists', { path: lastPath });
|
||||||
|
if (exists) {
|
||||||
|
console.log('Loading last scene from file:', lastPath);
|
||||||
|
const success = await this.loadFromPath(lastPath);
|
||||||
|
if (success) {
|
||||||
|
// Clear any stale auto-save data
|
||||||
|
this.clearAutoSave();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load last scene:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Cleanup
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.stopAutoSave();
|
||||||
|
this.onDirtyChange = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Singleton Instance
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
let sceneServiceInstance: SceneService | null = null;
|
||||||
|
|
||||||
|
export function getSceneService(): SceneService {
|
||||||
|
if (!sceneServiceInstance) {
|
||||||
|
sceneServiceInstance = new SceneService();
|
||||||
|
}
|
||||||
|
return sceneServiceInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetSceneService(): void {
|
||||||
|
if (sceneServiceInstance) {
|
||||||
|
sceneServiceInstance.destroy();
|
||||||
|
sceneServiceInstance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
479
src/utils/text.ts
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
/**
|
||||||
|
* Text rendering utilities
|
||||||
|
*
|
||||||
|
* This module provides text-to-canvas rendering for WebGPU texture creation.
|
||||||
|
* Text is rendered to an offscreen canvas which can then be used as a texture source.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Text Style Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface TextStyle {
|
||||||
|
/** Font size in pixels */
|
||||||
|
fontSize: number;
|
||||||
|
/** Font family */
|
||||||
|
fontFamily: string;
|
||||||
|
/** Font weight (normal, bold, etc.) */
|
||||||
|
fontWeight: string;
|
||||||
|
/** Font style (normal, italic) */
|
||||||
|
fontStyle: string;
|
||||||
|
/** Text color (CSS color string) */
|
||||||
|
color: string;
|
||||||
|
/** Background color (CSS color string, 'transparent' for none) */
|
||||||
|
backgroundColor: string;
|
||||||
|
/** Text alignment */
|
||||||
|
textAlign: CanvasTextAlign;
|
||||||
|
/** Vertical alignment */
|
||||||
|
verticalAlign: 'top' | 'middle' | 'bottom';
|
||||||
|
/** Line height multiplier */
|
||||||
|
lineHeight: number;
|
||||||
|
/** Padding in pixels */
|
||||||
|
padding: number;
|
||||||
|
/** Maximum width (0 for auto) */
|
||||||
|
maxWidth: number;
|
||||||
|
/** Word wrap enabled */
|
||||||
|
wordWrap: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextMetrics {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
lines: string[];
|
||||||
|
lineHeights: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Default Style
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const DEFAULT_TEXT_STYLE: TextStyle = {
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: 'Arial, sans-serif',
|
||||||
|
fontWeight: 'normal',
|
||||||
|
fontStyle: 'normal',
|
||||||
|
color: '#ffffff',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
textAlign: 'left',
|
||||||
|
verticalAlign: 'top',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
padding: 8,
|
||||||
|
maxWidth: 0,
|
||||||
|
wordWrap: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Text Measurement
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure text dimensions without rendering
|
||||||
|
*/
|
||||||
|
export function measureText(
|
||||||
|
text: string,
|
||||||
|
style: Partial<TextStyle> = {}
|
||||||
|
): TextMetrics {
|
||||||
|
const fullStyle = { ...DEFAULT_TEXT_STYLE, ...style };
|
||||||
|
|
||||||
|
// Create temporary canvas for measurement
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
|
||||||
|
// Set font
|
||||||
|
ctx.font = `${fullStyle.fontStyle} ${fullStyle.fontWeight} ${fullStyle.fontSize}px ${fullStyle.fontFamily}`;
|
||||||
|
|
||||||
|
// Split text into lines
|
||||||
|
const lines = splitTextIntoLines(ctx, text, fullStyle);
|
||||||
|
|
||||||
|
// Calculate dimensions
|
||||||
|
const lineHeightPx = fullStyle.fontSize * fullStyle.lineHeight;
|
||||||
|
const lineHeights = lines.map(() => lineHeightPx);
|
||||||
|
|
||||||
|
let maxLineWidth = 0;
|
||||||
|
for (const line of lines) {
|
||||||
|
const metrics = ctx.measureText(line);
|
||||||
|
maxLineWidth = Math.max(maxLineWidth, metrics.width);
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = Math.ceil(maxLineWidth + fullStyle.padding * 2);
|
||||||
|
const height = Math.ceil(lines.length * lineHeightPx + fullStyle.padding * 2);
|
||||||
|
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
lines,
|
||||||
|
lineHeights,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split text into lines based on word wrap settings
|
||||||
|
*/
|
||||||
|
function splitTextIntoLines(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
text: string,
|
||||||
|
style: TextStyle
|
||||||
|
): string[] {
|
||||||
|
// First split by explicit newlines
|
||||||
|
const paragraphs = text.split('\n');
|
||||||
|
|
||||||
|
if (!style.wordWrap || style.maxWidth <= 0) {
|
||||||
|
return paragraphs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxWidth = style.maxWidth - style.padding * 2;
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
for (const paragraph of paragraphs) {
|
||||||
|
if (paragraph === '') {
|
||||||
|
lines.push('');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const words = paragraph.split(' ');
|
||||||
|
let currentLine = '';
|
||||||
|
|
||||||
|
for (const word of words) {
|
||||||
|
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
||||||
|
const metrics = ctx.measureText(testLine);
|
||||||
|
|
||||||
|
if (metrics.width > maxWidth && currentLine) {
|
||||||
|
lines.push(currentLine);
|
||||||
|
currentLine = word;
|
||||||
|
} else {
|
||||||
|
currentLine = testLine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentLine) {
|
||||||
|
lines.push(currentLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Text Rendering
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render text to a canvas
|
||||||
|
*/
|
||||||
|
export function renderTextToCanvas(
|
||||||
|
text: string,
|
||||||
|
style: Partial<TextStyle> = {}
|
||||||
|
): HTMLCanvasElement {
|
||||||
|
const fullStyle = { ...DEFAULT_TEXT_STYLE, ...style };
|
||||||
|
|
||||||
|
// Measure text first
|
||||||
|
const metrics = measureText(text, fullStyle);
|
||||||
|
|
||||||
|
// Create canvas with measured dimensions
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = metrics.width;
|
||||||
|
canvas.height = metrics.height;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
|
||||||
|
// Clear and fill background
|
||||||
|
if (fullStyle.backgroundColor !== 'transparent') {
|
||||||
|
ctx.fillStyle = fullStyle.backgroundColor;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
} else {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set text style
|
||||||
|
ctx.font = `${fullStyle.fontStyle} ${fullStyle.fontWeight} ${fullStyle.fontSize}px ${fullStyle.fontFamily}`;
|
||||||
|
ctx.fillStyle = fullStyle.color;
|
||||||
|
ctx.textAlign = fullStyle.textAlign;
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
|
||||||
|
// Calculate starting position
|
||||||
|
const lineHeightPx = fullStyle.fontSize * fullStyle.lineHeight;
|
||||||
|
let startY = fullStyle.padding;
|
||||||
|
|
||||||
|
// Adjust for vertical alignment
|
||||||
|
const totalTextHeight = metrics.lines.length * lineHeightPx;
|
||||||
|
if (fullStyle.verticalAlign === 'middle') {
|
||||||
|
startY = (canvas.height - totalTextHeight) / 2;
|
||||||
|
} else if (fullStyle.verticalAlign === 'bottom') {
|
||||||
|
startY = canvas.height - totalTextHeight - fullStyle.padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate X position based on text alignment
|
||||||
|
let startX = fullStyle.padding;
|
||||||
|
if (fullStyle.textAlign === 'center') {
|
||||||
|
startX = canvas.width / 2;
|
||||||
|
} else if (fullStyle.textAlign === 'right') {
|
||||||
|
startX = canvas.width - fullStyle.padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render each line
|
||||||
|
for (let i = 0; i < metrics.lines.length; i++) {
|
||||||
|
const y = startY + i * lineHeightPx;
|
||||||
|
ctx.fillText(metrics.lines[i], startX, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render text to an ImageBitmap
|
||||||
|
*/
|
||||||
|
export async function renderTextToImageBitmap(
|
||||||
|
text: string,
|
||||||
|
style: Partial<TextStyle> = {}
|
||||||
|
): Promise<ImageBitmap> {
|
||||||
|
const canvas = renderTextToCanvas(text, style);
|
||||||
|
return createImageBitmap(canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Text Element Helper
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface TextElementData {
|
||||||
|
content: string;
|
||||||
|
style: TextStyle;
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
metrics: TextMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a text element with canvas and metrics
|
||||||
|
*/
|
||||||
|
export function createTextElement(
|
||||||
|
content: string,
|
||||||
|
style: Partial<TextStyle> = {}
|
||||||
|
): TextElementData {
|
||||||
|
const fullStyle = { ...DEFAULT_TEXT_STYLE, ...style };
|
||||||
|
const metrics = measureText(content, fullStyle);
|
||||||
|
const canvas = renderTextToCanvas(content, fullStyle);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
style: fullStyle,
|
||||||
|
canvas,
|
||||||
|
metrics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update text element content
|
||||||
|
*/
|
||||||
|
export function updateTextElement(
|
||||||
|
element: TextElementData,
|
||||||
|
content: string
|
||||||
|
): TextElementData {
|
||||||
|
const metrics = measureText(content, element.style);
|
||||||
|
const canvas = renderTextToCanvas(content, element.style);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...element,
|
||||||
|
content,
|
||||||
|
canvas,
|
||||||
|
metrics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update text element style
|
||||||
|
*/
|
||||||
|
export function updateTextElementStyle(
|
||||||
|
element: TextElementData,
|
||||||
|
style: Partial<TextStyle>
|
||||||
|
): TextElementData {
|
||||||
|
const fullStyle = { ...element.style, ...style };
|
||||||
|
const metrics = measureText(element.content, fullStyle);
|
||||||
|
const canvas = renderTextToCanvas(element.content, fullStyle);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...element,
|
||||||
|
style: fullStyle,
|
||||||
|
canvas,
|
||||||
|
metrics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Auto-sizing Text
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate font size to fit text within given dimensions
|
||||||
|
*/
|
||||||
|
export function calculateFitFontSize(
|
||||||
|
text: string,
|
||||||
|
maxWidth: number,
|
||||||
|
maxHeight: number,
|
||||||
|
style: Partial<TextStyle> = {},
|
||||||
|
minFontSize: number = 8,
|
||||||
|
maxFontSize: number = 200
|
||||||
|
): number {
|
||||||
|
let low = minFontSize;
|
||||||
|
let high = maxFontSize;
|
||||||
|
let bestSize = minFontSize;
|
||||||
|
|
||||||
|
while (low <= high) {
|
||||||
|
const mid = Math.floor((low + high) / 2);
|
||||||
|
const testStyle = { ...style, fontSize: mid, maxWidth };
|
||||||
|
const metrics = measureText(text, testStyle);
|
||||||
|
|
||||||
|
if (metrics.width <= maxWidth && metrics.height <= maxHeight) {
|
||||||
|
bestSize = mid;
|
||||||
|
low = mid + 1;
|
||||||
|
} else {
|
||||||
|
high = mid - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render text with auto-sizing to fit dimensions
|
||||||
|
*/
|
||||||
|
export function renderAutoSizedText(
|
||||||
|
text: string,
|
||||||
|
maxWidth: number,
|
||||||
|
maxHeight: number,
|
||||||
|
style: Partial<TextStyle> = {}
|
||||||
|
): HTMLCanvasElement {
|
||||||
|
const fontSize = calculateFitFontSize(text, maxWidth, maxHeight, style);
|
||||||
|
return renderTextToCanvas(text, { ...style, fontSize, maxWidth });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Rich Text Support (Basic)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface RichTextSegment {
|
||||||
|
text: string;
|
||||||
|
style?: Partial<TextStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render rich text with multiple styles
|
||||||
|
* Note: This is a simplified implementation that renders segments inline
|
||||||
|
*/
|
||||||
|
export function renderRichText(
|
||||||
|
segments: RichTextSegment[],
|
||||||
|
baseStyle: Partial<TextStyle> = {}
|
||||||
|
): HTMLCanvasElement {
|
||||||
|
const fullBaseStyle = { ...DEFAULT_TEXT_STYLE, ...baseStyle };
|
||||||
|
|
||||||
|
// First pass: measure total width
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
|
||||||
|
let totalWidth = fullBaseStyle.padding * 2;
|
||||||
|
let maxHeight = 0;
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
const segmentStyle = { ...fullBaseStyle, ...segment.style };
|
||||||
|
ctx.font = `${segmentStyle.fontStyle} ${segmentStyle.fontWeight} ${segmentStyle.fontSize}px ${segmentStyle.fontFamily}`;
|
||||||
|
const metrics = ctx.measureText(segment.text);
|
||||||
|
totalWidth += metrics.width;
|
||||||
|
maxHeight = Math.max(maxHeight, segmentStyle.fontSize * segmentStyle.lineHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set canvas size
|
||||||
|
canvas.width = Math.ceil(totalWidth);
|
||||||
|
canvas.height = Math.ceil(maxHeight + fullBaseStyle.padding * 2);
|
||||||
|
|
||||||
|
// Clear background
|
||||||
|
if (fullBaseStyle.backgroundColor !== 'transparent') {
|
||||||
|
ctx.fillStyle = fullBaseStyle.backgroundColor;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: render segments
|
||||||
|
let x = fullBaseStyle.padding;
|
||||||
|
const y = fullBaseStyle.padding;
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
const segmentStyle = { ...fullBaseStyle, ...segment.style };
|
||||||
|
ctx.font = `${segmentStyle.fontStyle} ${segmentStyle.fontWeight} ${segmentStyle.fontSize}px ${segmentStyle.fontFamily}`;
|
||||||
|
ctx.fillStyle = segmentStyle.color;
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.fillText(segment.text, x, y);
|
||||||
|
|
||||||
|
const metrics = ctx.measureText(segment.text);
|
||||||
|
x += metrics.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Utility Functions
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse CSS color to RGBA values
|
||||||
|
*/
|
||||||
|
export function parseColor(color: string): { r: number; g: number; b: number; a: number } {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 1;
|
||||||
|
canvas.height = 1;
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fillRect(0, 0, 1, 1);
|
||||||
|
const data = ctx.getImageData(0, 0, 1, 1).data;
|
||||||
|
return {
|
||||||
|
r: data[0],
|
||||||
|
g: data[1],
|
||||||
|
b: data[2],
|
||||||
|
a: data[3] / 255,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert RGBA to CSS color string
|
||||||
|
*/
|
||||||
|
export function rgbaToColor(r: number, g: number, b: number, a: number = 1): string {
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if text contains only whitespace
|
||||||
|
*/
|
||||||
|
export function isWhitespaceOnly(text: string): boolean {
|
||||||
|
return /^\s*$/.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate text with ellipsis if it exceeds max width
|
||||||
|
*/
|
||||||
|
export function truncateText(
|
||||||
|
text: string,
|
||||||
|
maxWidth: number,
|
||||||
|
style: Partial<TextStyle> = {},
|
||||||
|
ellipsis: string = '...'
|
||||||
|
): string {
|
||||||
|
const fullStyle = { ...DEFAULT_TEXT_STYLE, ...style };
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
ctx.font = `${fullStyle.fontStyle} ${fullStyle.fontWeight} ${fullStyle.fontSize}px ${fullStyle.fontFamily}`;
|
||||||
|
|
||||||
|
const metrics = ctx.measureText(text);
|
||||||
|
if (metrics.width <= maxWidth) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ellipsisWidth = ctx.measureText(ellipsis).width;
|
||||||
|
const targetWidth = maxWidth - ellipsisWidth;
|
||||||
|
|
||||||
|
let truncated = text;
|
||||||
|
while (truncated.length > 0) {
|
||||||
|
truncated = truncated.slice(0, -1);
|
||||||
|
if (ctx.measureText(truncated).width <= targetWidth) {
|
||||||
|
return truncated + ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ellipsis;
|
||||||
|
}
|
||||||
521
src/utils/video.ts
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
/**
|
||||||
|
* Video processing utilities
|
||||||
|
*
|
||||||
|
* This module provides video element creation, playback control,
|
||||||
|
* and texture update functionality for WebGPU rendering.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Supported video formats
|
||||||
|
export const SUPPORTED_VIDEO_FORMATS = ['mp4', 'webm', 'ogg'];
|
||||||
|
export const SUPPORTED_VIDEO_MIME_TYPES = [
|
||||||
|
'video/mp4',
|
||||||
|
'video/webm',
|
||||||
|
'video/ogg',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file is a supported video format
|
||||||
|
*/
|
||||||
|
export function isSupportedVideoFormat(filename: string): boolean {
|
||||||
|
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||||
|
return SUPPORTED_VIDEO_FORMATS.includes(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a MIME type is a supported video format
|
||||||
|
*/
|
||||||
|
export function isSupportedVideoMimeType(mimeType: string): boolean {
|
||||||
|
return SUPPORTED_VIDEO_MIME_TYPES.includes(mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Video Element Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface VideoInfo {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
duration: number;
|
||||||
|
aspectRatio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoopPoint {
|
||||||
|
start: number; // percentage 0-100
|
||||||
|
end: number; // percentage 0-100
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Video Element Creation
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a video element from a file
|
||||||
|
*/
|
||||||
|
export async function createVideoFromFile(file: File): Promise<HTMLVideoElement> {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
return createVideoFromUrl(url, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a video element from a URL
|
||||||
|
* @param url Video URL
|
||||||
|
* @param revokeOnLoad Whether to revoke the object URL after loading (for blob URLs)
|
||||||
|
*/
|
||||||
|
export async function createVideoFromUrl(
|
||||||
|
url: string,
|
||||||
|
revokeOnLoad: boolean = false
|
||||||
|
): Promise<HTMLVideoElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const video = document.createElement('video');
|
||||||
|
|
||||||
|
// Configure video element for texture use
|
||||||
|
video.crossOrigin = 'anonymous';
|
||||||
|
video.muted = true;
|
||||||
|
video.loop = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
video.preload = 'auto';
|
||||||
|
|
||||||
|
video.onloadedmetadata = () => {
|
||||||
|
if (revokeOnLoad) {
|
||||||
|
// Don't revoke immediately - keep URL for playback
|
||||||
|
// URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
resolve(video);
|
||||||
|
};
|
||||||
|
|
||||||
|
video.onerror = () => {
|
||||||
|
if (revokeOnLoad) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
reject(new Error(`Failed to load video: ${url}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
video.src = url;
|
||||||
|
video.load();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a video element from a base64 data URL
|
||||||
|
*/
|
||||||
|
export async function createVideoFromBase64(base64DataUrl: string): Promise<HTMLVideoElement> {
|
||||||
|
return createVideoFromUrl(base64DataUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Video Information
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get video information
|
||||||
|
*/
|
||||||
|
export function getVideoInfo(video: HTMLVideoElement): VideoInfo {
|
||||||
|
return {
|
||||||
|
width: video.videoWidth,
|
||||||
|
height: video.videoHeight,
|
||||||
|
duration: video.duration,
|
||||||
|
aspectRatio: video.videoWidth / video.videoHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for video to be ready for playback
|
||||||
|
*/
|
||||||
|
export function waitForVideoReady(video: HTMLVideoElement): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (video.readyState >= 3) {
|
||||||
|
// HAVE_FUTURE_DATA or higher
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCanPlay = () => {
|
||||||
|
video.removeEventListener('canplay', onCanPlay);
|
||||||
|
video.removeEventListener('error', onError);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = () => {
|
||||||
|
video.removeEventListener('canplay', onCanPlay);
|
||||||
|
video.removeEventListener('error', onError);
|
||||||
|
reject(new Error('Video failed to load'));
|
||||||
|
};
|
||||||
|
|
||||||
|
video.addEventListener('canplay', onCanPlay);
|
||||||
|
video.addEventListener('error', onError);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Video Controller
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export class VideoController {
|
||||||
|
private video: HTMLVideoElement;
|
||||||
|
private loopPoint: LoopPoint = { start: 0, end: 100 };
|
||||||
|
private isLooping: boolean = true;
|
||||||
|
private frameUpdateCallback: (() => void) | null = null;
|
||||||
|
private animationFrameId: number | null = null;
|
||||||
|
private isUpdating: boolean = false;
|
||||||
|
|
||||||
|
constructor(video: HTMLVideoElement) {
|
||||||
|
this.video = video;
|
||||||
|
this.setupEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup video event listeners
|
||||||
|
*/
|
||||||
|
private setupEventListeners(): void {
|
||||||
|
this.video.addEventListener('timeupdate', this.handleTimeUpdate.bind(this));
|
||||||
|
this.video.addEventListener('ended', this.handleEnded.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle time update for loop point enforcement
|
||||||
|
*/
|
||||||
|
private handleTimeUpdate(): void {
|
||||||
|
if (!this.isLooping) return;
|
||||||
|
|
||||||
|
const currentPercent = (this.video.currentTime / this.video.duration) * 100;
|
||||||
|
|
||||||
|
if (currentPercent >= this.loopPoint.end) {
|
||||||
|
this.seekToPercent(this.loopPoint.start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle video ended event
|
||||||
|
*/
|
||||||
|
private handleEnded(): void {
|
||||||
|
if (this.isLooping) {
|
||||||
|
this.seekToPercent(this.loopPoint.start);
|
||||||
|
this.video.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set callback for frame updates (for texture refresh)
|
||||||
|
*/
|
||||||
|
setFrameUpdateCallback(callback: () => void): void {
|
||||||
|
this.frameUpdateCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start frame update loop for texture synchronization
|
||||||
|
*/
|
||||||
|
startFrameUpdates(): void {
|
||||||
|
if (this.isUpdating) return;
|
||||||
|
this.isUpdating = true;
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
if (!this.isUpdating) return;
|
||||||
|
|
||||||
|
if (!this.video.paused && this.frameUpdateCallback) {
|
||||||
|
this.frameUpdateCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.animationFrameId = requestAnimationFrame(update);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.animationFrameId = requestAnimationFrame(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop frame update loop
|
||||||
|
*/
|
||||||
|
stopFrameUpdates(): void {
|
||||||
|
this.isUpdating = false;
|
||||||
|
if (this.animationFrameId !== null) {
|
||||||
|
cancelAnimationFrame(this.animationFrameId);
|
||||||
|
this.animationFrameId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play the video
|
||||||
|
*/
|
||||||
|
async play(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.video.play();
|
||||||
|
this.startFrameUpdates();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Video play failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause the video
|
||||||
|
*/
|
||||||
|
pause(): void {
|
||||||
|
this.video.pause();
|
||||||
|
this.stopFrameUpdates();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle play/pause
|
||||||
|
*/
|
||||||
|
async toggle(): Promise<void> {
|
||||||
|
if (this.video.paused) {
|
||||||
|
await this.play();
|
||||||
|
} else {
|
||||||
|
this.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the video and reset to loop start
|
||||||
|
*/
|
||||||
|
stop(): void {
|
||||||
|
this.pause();
|
||||||
|
this.seekToPercent(this.loopPoint.start);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek to a specific time in seconds
|
||||||
|
*/
|
||||||
|
seek(time: number): void {
|
||||||
|
this.video.currentTime = Math.max(0, Math.min(time, this.video.duration));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek to a percentage of the video duration
|
||||||
|
*/
|
||||||
|
seekToPercent(percent: number): void {
|
||||||
|
const time = (percent / 100) * this.video.duration;
|
||||||
|
this.seek(time);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set loop points (percentage 0-100)
|
||||||
|
*/
|
||||||
|
setLoopPoints(start: number, end: number): void {
|
||||||
|
this.loopPoint = {
|
||||||
|
start: Math.max(0, Math.min(start, 100)),
|
||||||
|
end: Math.max(0, Math.min(end, 100)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure start < end
|
||||||
|
if (this.loopPoint.start >= this.loopPoint.end) {
|
||||||
|
this.loopPoint.end = Math.min(this.loopPoint.start + 1, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If current time is outside loop, seek to start
|
||||||
|
const currentPercent = (this.video.currentTime / this.video.duration) * 100;
|
||||||
|
if (currentPercent < this.loopPoint.start || currentPercent > this.loopPoint.end) {
|
||||||
|
this.seekToPercent(this.loopPoint.start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current loop points
|
||||||
|
*/
|
||||||
|
getLoopPoints(): LoopPoint {
|
||||||
|
return { ...this.loopPoint };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable/disable looping
|
||||||
|
*/
|
||||||
|
setLooping(enabled: boolean): void {
|
||||||
|
this.isLooping = enabled;
|
||||||
|
this.video.loop = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get looping state
|
||||||
|
*/
|
||||||
|
getIsLooping(): boolean {
|
||||||
|
return this.isLooping;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set muted state
|
||||||
|
*/
|
||||||
|
setMuted(muted: boolean): void {
|
||||||
|
this.video.muted = muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get muted state
|
||||||
|
*/
|
||||||
|
getIsMuted(): boolean {
|
||||||
|
return this.video.muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set volume (0-1)
|
||||||
|
*/
|
||||||
|
setVolume(volume: number): void {
|
||||||
|
this.video.volume = Math.max(0, Math.min(volume, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get volume
|
||||||
|
*/
|
||||||
|
getVolume(): number {
|
||||||
|
return this.video.volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set playback rate
|
||||||
|
*/
|
||||||
|
setPlaybackRate(rate: number): void {
|
||||||
|
this.video.playbackRate = rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get playback rate
|
||||||
|
*/
|
||||||
|
getPlaybackRate(): number {
|
||||||
|
return this.video.playbackRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current time in seconds
|
||||||
|
*/
|
||||||
|
getCurrentTime(): number {
|
||||||
|
return this.video.currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current time as percentage
|
||||||
|
*/
|
||||||
|
getCurrentPercent(): number {
|
||||||
|
return (this.video.currentTime / this.video.duration) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get video duration in seconds
|
||||||
|
*/
|
||||||
|
getDuration(): number {
|
||||||
|
return this.video.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if video is playing
|
||||||
|
*/
|
||||||
|
isPlaying(): boolean {
|
||||||
|
return !this.video.paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the underlying video element
|
||||||
|
*/
|
||||||
|
getVideoElement(): HTMLVideoElement {
|
||||||
|
return this.video;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get video info
|
||||||
|
*/
|
||||||
|
getInfo(): VideoInfo {
|
||||||
|
return getVideoInfo(this.video);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy the controller and release resources
|
||||||
|
*/
|
||||||
|
destroy(): void {
|
||||||
|
this.stopFrameUpdates();
|
||||||
|
this.video.pause();
|
||||||
|
this.video.removeAttribute('src');
|
||||||
|
this.video.load();
|
||||||
|
this.frameUpdateCallback = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Video Texture Update Helper
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if video frame has changed and needs texture update
|
||||||
|
* Uses video's currentTime to detect frame changes
|
||||||
|
*/
|
||||||
|
export class VideoFrameTracker {
|
||||||
|
private lastTime: number = -1;
|
||||||
|
private video: HTMLVideoElement;
|
||||||
|
|
||||||
|
constructor(video: HTMLVideoElement) {
|
||||||
|
this.video = video;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if frame has changed since last check
|
||||||
|
*/
|
||||||
|
hasFrameChanged(): boolean {
|
||||||
|
const currentTime = this.video.currentTime;
|
||||||
|
if (currentTime !== this.lastTime) {
|
||||||
|
this.lastTime = currentTime;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the tracker
|
||||||
|
*/
|
||||||
|
reset(): void {
|
||||||
|
this.lastTime = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Video Loading Helpers
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load video from file and create controller
|
||||||
|
*/
|
||||||
|
export async function loadVideoFromFile(file: File): Promise<VideoController> {
|
||||||
|
const video = await createVideoFromFile(file);
|
||||||
|
await waitForVideoReady(video);
|
||||||
|
return new VideoController(video);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load video from URL and create controller
|
||||||
|
*/
|
||||||
|
export async function loadVideoFromUrl(url: string): Promise<VideoController> {
|
||||||
|
const video = await createVideoFromUrl(url);
|
||||||
|
await waitForVideoReady(video);
|
||||||
|
return new VideoController(video);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Utility Functions
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format time in seconds to MM:SS format
|
||||||
|
*/
|
||||||
|
export function formatTime(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format time in seconds to MM:SS.ms format
|
||||||
|
*/
|
||||||
|
export function formatTimeWithMs(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
const ms = Math.floor((seconds % 1) * 100);
|
||||||
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${ms.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate percentage from time
|
||||||
|
*/
|
||||||
|
export function timeToPercent(time: number, duration: number): number {
|
||||||
|
return (time / duration) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate time from percentage
|
||||||
|
*/
|
||||||
|
export function percentToTime(percent: number, duration: number): number {
|
||||||
|
return (percent / 100) * duration;
|
||||||
|
}
|
||||||
29
tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"types": ["node", "@webgpu/types"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
11
tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
38
vite.config.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig(async () => ({
|
||||||
|
plugins: [vue()],
|
||||||
|
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": resolve(__dirname, "src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
|
//
|
||||||
|
// 1. prevent Vite from obscuring rust errors
|
||||||
|
clearScreen: false,
|
||||||
|
// 2. tauri expects a fixed port, fail if that port is not available
|
||||||
|
server: {
|
||||||
|
port: 1420,
|
||||||
|
strictPort: true,
|
||||||
|
host: host || false,
|
||||||
|
hmr: host
|
||||||
|
? {
|
||||||
|
protocol: "ws",
|
||||||
|
host,
|
||||||
|
port: 1421,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
watch: {
|
||||||
|
// 3. tell Vite to ignore watching `src-tauri`
|
||||||
|
ignored: ["**/src-tauri/**"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||