🎉 初始提交:TinyLabel 图像文字编辑桌面工具
- 基于 Electron + React + TypeScript + Vite - Fabric.js 画布编辑引擎 - Ant Design UI 组件 + Zustand 状态管理 - 支持文字添加、拖拽、字体/颜色/特效编辑 - 支持 PNG / JPEG / WebP 导出
This commit is contained in:
+33
@@ -0,0 +1,33 @@
|
|||||||
|
# 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?
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
dist-electron
|
||||||
|
|
||||||
|
# Platform-specific deps
|
||||||
|
.local-libs
|
||||||
|
|
||||||
|
# IDE / tooling metadata
|
||||||
|
.reasonix
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# TinyLabel 🎨
|
||||||
|
|
||||||
|
**TinyLabel** 是一款轻量级的跨平台图像文字编辑桌面工具。你可以选择背景图片,在上面添加和编辑文字,最后导出为高质量图片。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- ✅ **背景图管理** — 从文件夹加载或上传图片作为背景
|
||||||
|
- ✅ **文字编辑** — 添加文字,自由拖拽移动位置
|
||||||
|
- ✅ **字体属性** — 修改字体、字号、字重、颜色、对齐方式
|
||||||
|
- ✅ **文字特效** — 描边效果、阴影效果、透明度调节
|
||||||
|
- ✅ **图片导出** — 支持 PNG / JPEG / WebP 格式,可调节尺寸和质量
|
||||||
|
- ✅ **系统字体** — 自动加载系统已安装字体
|
||||||
|
- ✅ **跨平台** — 支持 Windows 和 macOS
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装依赖
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 开发模式
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 构建 Windows 版本
|
||||||
|
npm run build:win
|
||||||
|
|
||||||
|
# 构建 macOS 版本
|
||||||
|
npm run build:mac
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 技术 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| Electron | 跨平台桌面框架 |
|
||||||
|
| React + TypeScript | UI 界面 |
|
||||||
|
| Fabric.js | 画布编辑引擎 |
|
||||||
|
| Ant Design | UI 组件库 |
|
||||||
|
| Zustand | 状态管理 |
|
||||||
|
| Vite | 构建工具 |
|
||||||
|
| electron-builder | 打包分发 |
|
||||||
|
|
||||||
|
## 使用指南
|
||||||
|
|
||||||
|
1. 启动程序后,点击左侧「选择文件夹」或「上传图片」加载背景图
|
||||||
|
2. 点击背景图缩略图选中它
|
||||||
|
3. 点击「添加文字」按钮在画布中添加文字
|
||||||
|
4. 选中文字后,在右侧面板调整字体、大小、颜色、特效等属性
|
||||||
|
5. 点击顶部的「导出」按钮,选择格式、质量、尺寸后导出图片
|
||||||
|
|
||||||
|
## 快捷键
|
||||||
|
|
||||||
|
| 快捷键 | 功能 |
|
||||||
|
|--------|------|
|
||||||
|
| `Delete` / `Backspace` | 删除选中文字 |
|
||||||
|
| `Escape` | 取消选中 |
|
||||||
|
| `Ctrl/Cmd + E` | 导出图片 |
|
||||||
|
|
||||||
|
## 构建说明
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:win
|
||||||
|
```
|
||||||
|
|
||||||
|
生成 `release/TinyLabel Setup x.x.x.exe`
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:mac
|
||||||
|
```
|
||||||
|
|
||||||
|
生成 `release/TinyLabel-x.x.x.dmg`
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
appId: com.tinylabel.app
|
||||||
|
productName: TinyLabel
|
||||||
|
copyright: Copyright © 2025
|
||||||
|
|
||||||
|
directories:
|
||||||
|
output: release
|
||||||
|
|
||||||
|
files:
|
||||||
|
- dist
|
||||||
|
- dist-electron
|
||||||
|
|
||||||
|
win:
|
||||||
|
target:
|
||||||
|
- target: nsis
|
||||||
|
arch:
|
||||||
|
- x64
|
||||||
|
icon: build/icon.ico
|
||||||
|
|
||||||
|
mac:
|
||||||
|
target:
|
||||||
|
- target: dmg
|
||||||
|
arch:
|
||||||
|
- x64
|
||||||
|
- arm64
|
||||||
|
icon: build/icon.icns
|
||||||
|
category: public.app-category.graphics-design
|
||||||
|
|
||||||
|
nsis:
|
||||||
|
oneClick: false
|
||||||
|
allowToChangeInstallationDirectory: true
|
||||||
|
createDesktopShortcut: true
|
||||||
|
language: 2052
|
||||||
|
|
||||||
|
dmg:
|
||||||
|
title: TinyLabel
|
||||||
|
contents:
|
||||||
|
- x: 130
|
||||||
|
y: 220
|
||||||
|
type: file
|
||||||
|
- x: 410
|
||||||
|
y: 220
|
||||||
|
type: link
|
||||||
|
path: /Applications
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron'
|
||||||
|
import { join, dirname } from 'path'
|
||||||
|
import { readFileSync, readdirSync } from 'fs'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = dirname(__filename)
|
||||||
|
|
||||||
|
let mainWindow: BrowserWindow | null = null
|
||||||
|
|
||||||
|
// WSL/Linux 兼容配置:仅禁用沙箱 + X11 模式,GPU 让 Electron 自动处理
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
app.commandLine.appendSwitch('no-sandbox')
|
||||||
|
app.commandLine.appendSwitch('ozone-platform', 'x11')
|
||||||
|
app.commandLine.appendSwitch('in-process-gpu')
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
title: 'TinyLabel',
|
||||||
|
show: true,
|
||||||
|
center: true,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
backgroundColor: '#1a1a2e',
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: false,
|
||||||
|
backgroundThrottling: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载完成后最大化
|
||||||
|
mainWindow.once('ready-to-show', () => {
|
||||||
|
console.log('[main] ready-to-show')
|
||||||
|
clearTimeout(forceShowTimer)
|
||||||
|
mainWindow?.maximize()
|
||||||
|
mainWindow?.focus()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 兜底:5 秒后强制弹窗
|
||||||
|
const forceShowTimer = setTimeout(() => {
|
||||||
|
if (mainWindow && !mainWindow.isVisible()) {
|
||||||
|
console.warn('[main] force-show window after timeout')
|
||||||
|
mainWindow.show()
|
||||||
|
mainWindow.focus()
|
||||||
|
}
|
||||||
|
}, 5000)
|
||||||
|
|
||||||
|
mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDesc) => {
|
||||||
|
console.error(`[main] load failed: ${errorCode} ${errorDesc}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const devUrl = process.env.VITE_DEV_SERVER_URL
|
||||||
|
if (devUrl) {
|
||||||
|
console.log(`[main] loading dev URL: ${devUrl}`)
|
||||||
|
mainWindow.loadURL(devUrl)
|
||||||
|
} else {
|
||||||
|
const filePath = join(__dirname, '../dist/index.html')
|
||||||
|
console.log(`[main] loading file: ${filePath}`)
|
||||||
|
mainWindow.loadFile(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
shell.openExternal(url)
|
||||||
|
return { action: 'deny' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 开发模式下打开 DevTools(内嵌在窗口底部)
|
||||||
|
if (devUrl) {
|
||||||
|
mainWindow.webContents.openDevTools()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
console.log('[main] app ready')
|
||||||
|
createWindow()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') app.quit()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── IPC Handlers ────────────────────────────────────────────
|
||||||
|
|
||||||
|
ipcMain.handle('fs:readImages', async (_event, dirPath: string) => {
|
||||||
|
try {
|
||||||
|
const files = readdirSync(dirPath)
|
||||||
|
const images = files
|
||||||
|
.filter((f) => /\.(jpg|jpeg|png|webp|bmp)$/i.test(f))
|
||||||
|
.map((f) => {
|
||||||
|
const fullPath = join(dirPath, f)
|
||||||
|
const buffer = readFileSync(fullPath)
|
||||||
|
const ext = f.split('.').pop()!.toLowerCase()
|
||||||
|
const mime = ext === 'jpg' ? 'jpeg' : ext
|
||||||
|
return { name: f, data: `data:image/${mime};base64,${buffer.toString('base64')}` }
|
||||||
|
})
|
||||||
|
return images
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('dialog:selectDirectory', async () => {
|
||||||
|
if (!mainWindow) return ''
|
||||||
|
const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory'] })
|
||||||
|
return result.canceled ? '' : result.filePaths[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('dialog:saveFile', async (_event, defaultName: string) => {
|
||||||
|
if (!mainWindow) return ''
|
||||||
|
const result = await dialog.showSaveDialog(mainWindow, {
|
||||||
|
defaultPath: defaultName,
|
||||||
|
filters: [
|
||||||
|
{ name: 'Image', extensions: ['png', 'jpg', 'jpeg', 'webp'] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return result.canceled ? '' : result.filePath
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('fs:saveFile', async (_event, filePath: string, base64Data: string) => {
|
||||||
|
try {
|
||||||
|
const { writeFileSync } = await import('fs')
|
||||||
|
const buffer = Buffer.from(base64Data.split(',')[1], 'base64')
|
||||||
|
writeFileSync(filePath, buffer)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('system:getFonts', async () => {
|
||||||
|
return [
|
||||||
|
'Arial', 'Arial Black', 'Calibri', 'Cambria', 'Comic Sans MS',
|
||||||
|
'Courier New', 'Georgia', 'Helvetica', 'Impact',
|
||||||
|
'Microsoft YaHei', 'Microsoft JhengHei',
|
||||||
|
'Noto Sans SC', 'Noto Serif SC', 'PingFang SC',
|
||||||
|
'Segoe UI', 'SimHei', 'SimSun',
|
||||||
|
'Tahoma', 'Times New Roman', 'Trebuchet MS', 'Verdana',
|
||||||
|
'Source Han Sans', 'Source Han Serif',
|
||||||
|
]
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
readImages: (dirPath: string) => ipcRenderer.invoke('fs:readImages', dirPath),
|
||||||
|
selectDirectory: () => ipcRenderer.invoke('dialog:selectDirectory'),
|
||||||
|
saveFile: (filePath: string, base64Data: string) =>
|
||||||
|
ipcRenderer.invoke('fs:saveFile', filePath, base64Data),
|
||||||
|
selectSavePath: (defaultName: string) =>
|
||||||
|
ipcRenderer.invoke('dialog:saveFile', defaultName),
|
||||||
|
getFonts: () => ipcRenderer.invoke('system:getFonts'),
|
||||||
|
}
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('electronAPI', api)
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>TinyLabel - 图像文字编辑工具</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+6859
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "tinylabel",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist-electron/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "ELECTRON_OZONE_PLATFORM_HINT=x11 GDK_BACKEND=x11 LD_LIBRARY_PATH=$(pwd)/.local-libs:$LD_LIBRARY_PATH vite",
|
||||||
|
"dev:win": "vite",
|
||||||
|
"dev:electron": "ELECTRON_OZONE_PLATFORM_HINT=x11 GDK_BACKEND=x11 LD_LIBRARY_PATH=$(pwd)/.local-libs:$LD_LIBRARY_PATH electron .",
|
||||||
|
"build": "tsc -b && vite build && electron-builder",
|
||||||
|
"build:win": "tsc -b && vite build && electron-builder --win",
|
||||||
|
"build:mac": "tsc -b && vite build && electron-builder --mac",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "oxlint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^6.3.2",
|
||||||
|
"antd": "^6.5.1",
|
||||||
|
"fabric": "^7.4.0",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.13.2",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
"electron": "^43.1.0",
|
||||||
|
"electron-builder": "^26.15.3",
|
||||||
|
"oxlint": "^1.71.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.1.1",
|
||||||
|
"vite-plugin-electron": "^1.1.0",
|
||||||
|
"vite-plugin-electron-renderer": "^1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||||
|
<rect width="128" height="128" rx="20" fill="#7c5cfc"/>
|
||||||
|
<text x="64" y="82" font-family="Arial, sans-serif" font-size="64" font-weight="bold" fill="#fff" text-anchor="middle">T</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 258 B |
+69
@@ -0,0 +1,69 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { ConfigProvider, theme } from 'antd'
|
||||||
|
import zhCN from 'antd/locale/zh_CN'
|
||||||
|
import AppLayout from './components/Layout/AppLayout'
|
||||||
|
import ExportDialog from './components/ExportDialog/ExportDialog'
|
||||||
|
import { useEditorStore } from './stores/editorStore'
|
||||||
|
import { loadSystemFonts } from './utils/fontLoader'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [exportOpen, setExportOpen] = useState(false)
|
||||||
|
const initialized = useEditorStore((s) => s.initialized)
|
||||||
|
const setInitialized = useEditorStore((s) => s.setInitialized)
|
||||||
|
const setFonts = useEditorStore((s) => s.setFonts)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function init() {
|
||||||
|
// Load fonts
|
||||||
|
const fonts = await loadSystemFonts()
|
||||||
|
setFonts(fonts)
|
||||||
|
setInitialized(true)
|
||||||
|
}
|
||||||
|
init()
|
||||||
|
}, [setFonts, setInitialized])
|
||||||
|
|
||||||
|
const handleExport = useCallback(() => {
|
||||||
|
setExportOpen(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!initialized) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '100vw',
|
||||||
|
height: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'var(--bg-primary)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontSize: 16,
|
||||||
|
}}>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 32, marginBottom: 12 }}>🎨</div>
|
||||||
|
<div>正在初始化 TinyLabel...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigProvider
|
||||||
|
locale={zhCN}
|
||||||
|
theme={{
|
||||||
|
algorithm: theme.darkAlgorithm,
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#7c5cfc',
|
||||||
|
borderRadius: 6,
|
||||||
|
colorBgContainer: '#1e1e3a',
|
||||||
|
colorBgElevated: '#1e1e3a',
|
||||||
|
colorBorder: '#2a2a4e',
|
||||||
|
colorText: '#e8e8f0',
|
||||||
|
colorTextSecondary: '#a0a0b8',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppLayout onExport={handleExport} />
|
||||||
|
<ExportDialog open={exportOpen} onClose={() => setExportOpen(false)} />
|
||||||
|
</ConfigProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { useEffect, useRef, useCallback, useState } from 'react'
|
||||||
|
import { Canvas, FabricImage, FabricText, IText } from 'fabric'
|
||||||
|
import { useEditorStore } from '@/stores/editorStore'
|
||||||
|
import { Button } from 'antd'
|
||||||
|
import { PlusOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
|
export default function CanvasEditor() {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const canvasInstance = useRef<Canvas | null>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
|
||||||
|
|
||||||
|
const canvas = useEditorStore((s) => s.canvas)
|
||||||
|
const setCanvas = useEditorStore((s) => s.setCanvas)
|
||||||
|
const selectedBackground = useEditorStore((s) => s.selectedBackground)
|
||||||
|
const setSelectedText = useEditorStore((s) => s.setSelectedText)
|
||||||
|
const setTextConfig = useEditorStore((s) => s.setTextConfig)
|
||||||
|
const textConfig = useEditorStore((s) => s.textConfig)
|
||||||
|
|
||||||
|
const PADDING = 48
|
||||||
|
const ASPECT_RATIO = 4 / 3
|
||||||
|
|
||||||
|
/** 根据容器尺寸计算画布宽高,保持比例 */
|
||||||
|
const calcCanvasSize = useCallback((cw: number, ch: number) => {
|
||||||
|
const availW = cw - PADDING
|
||||||
|
const availH = ch - PADDING
|
||||||
|
let w = availW
|
||||||
|
let h = w / ASPECT_RATIO
|
||||||
|
if (h > availH) {
|
||||||
|
h = availH
|
||||||
|
w = h * ASPECT_RATIO
|
||||||
|
}
|
||||||
|
return { width: Math.floor(w), height: Math.floor(h) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// ── ResizeObserver 监听容器尺寸 ──
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const sendSize = () => {
|
||||||
|
const rect = el!.getBoundingClientRect()
|
||||||
|
if (rect.width > 0 && rect.height > 0) {
|
||||||
|
setContainerSize({ width: rect.width, height: rect.height })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendSize()
|
||||||
|
const observer = new ResizeObserver(sendSize)
|
||||||
|
observer.observe(el)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// ── 容器尺寸变化时更新 Fabric 画布 ──
|
||||||
|
useEffect(() => {
|
||||||
|
const fc = canvasInstance.current
|
||||||
|
if (!fc || containerSize.width === 0) return
|
||||||
|
const { width, height } = calcCanvasSize(containerSize.width, containerSize.height)
|
||||||
|
fc.setDimensions({ width, height })
|
||||||
|
// 重新适配背景图
|
||||||
|
if (fc.backgroundImage) {
|
||||||
|
fitBackground(fc)
|
||||||
|
}
|
||||||
|
fc.renderAll()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [containerSize])
|
||||||
|
|
||||||
|
// ── 初始化 Fabric canvas ──
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current || canvasInstance.current) return
|
||||||
|
|
||||||
|
const { width, height } =
|
||||||
|
containerSize.width > 0
|
||||||
|
? calcCanvasSize(containerSize.width, containerSize.height)
|
||||||
|
: { width: 800, height: 600 }
|
||||||
|
|
||||||
|
const fabricCanvas = new Canvas(canvasRef.current, {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
backgroundColor: '#2a2a3a',
|
||||||
|
preserveObjectStacking: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
canvasInstance.current = fabricCanvas
|
||||||
|
setCanvas(fabricCanvas)
|
||||||
|
|
||||||
|
fabricCanvas.on('selection:created', (e) => {
|
||||||
|
const obj = e.selected?.[0]
|
||||||
|
if (obj && obj instanceof FabricText) {
|
||||||
|
setSelectedText(obj)
|
||||||
|
syncTextConfigFromObject(obj)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
fabricCanvas.on('selection:updated', (e) => {
|
||||||
|
const obj = e.selected?.[0]
|
||||||
|
if (obj && obj instanceof FabricText) {
|
||||||
|
setSelectedText(obj)
|
||||||
|
syncTextConfigFromObject(obj)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
fabricCanvas.on('selection:cleared', () => {
|
||||||
|
setSelectedText(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
fabricCanvas.on('object:modified', (e) => {
|
||||||
|
const obj = e.target
|
||||||
|
if (obj && obj instanceof FabricText) {
|
||||||
|
syncTextConfigFromObject(obj)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
fabricCanvas.dispose()
|
||||||
|
canvasInstance.current = null
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Sync text config from Fabric object(需在 init useEffect 之前定义)
|
||||||
|
const syncTextConfigFromObject = useCallback((obj: FabricText) => {
|
||||||
|
setTextConfig({
|
||||||
|
fontFamily: obj.fontFamily,
|
||||||
|
fontSize: obj.fontSize,
|
||||||
|
fontWeight: obj.fontWeight as number,
|
||||||
|
fill: obj.fill as string,
|
||||||
|
textAlign: obj.textAlign as 'left' | 'center' | 'right',
|
||||||
|
stroke: obj.stroke as string,
|
||||||
|
strokeWidth: obj.strokeWidth,
|
||||||
|
opacity: obj.opacity,
|
||||||
|
})
|
||||||
|
if (obj.shadow) {
|
||||||
|
setTextConfig({
|
||||||
|
shadowColor: obj.shadow.color as string,
|
||||||
|
shadowBlur: obj.shadow.blur,
|
||||||
|
shadowOffsetX: obj.shadow.offsetX,
|
||||||
|
shadowOffsetY: obj.shadow.offsetY,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [setTextConfig])
|
||||||
|
|
||||||
|
// ── 背景图适配:contain 模式 + 居中 ──
|
||||||
|
const fitBackground = useCallback((fc: Canvas) => {
|
||||||
|
const bg = fc.backgroundImage as FabricImage | undefined
|
||||||
|
if (!bg) return
|
||||||
|
const cw = fc.width!
|
||||||
|
const ch = fc.height!
|
||||||
|
const imgW = bg.width! * bg.scaleX!
|
||||||
|
const imgH = bg.height! * bg.scaleY!
|
||||||
|
const scaleX = cw / imgW
|
||||||
|
const scaleY = ch / imgH
|
||||||
|
const scale = Math.min(scaleX, scaleY)
|
||||||
|
bg.set({
|
||||||
|
scaleX: bg.scaleX! * scale,
|
||||||
|
scaleY: bg.scaleY! * scale,
|
||||||
|
left: (cw - imgW * scale) / 2,
|
||||||
|
top: (ch - imgH * scale) / 2,
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// ── 加载背景图 ──
|
||||||
|
useEffect(() => {
|
||||||
|
const fc = canvasInstance.current
|
||||||
|
if (!fc) return
|
||||||
|
|
||||||
|
if (selectedBackground) {
|
||||||
|
const imgEl = new Image()
|
||||||
|
imgEl.onload = () => {
|
||||||
|
const cw = fc.width!
|
||||||
|
const ch = fc.height!
|
||||||
|
const scaleX = cw / imgEl.width
|
||||||
|
const scaleY = ch / imgEl.height
|
||||||
|
const scale = Math.min(scaleX, scaleY) // contain 模式,完整显示
|
||||||
|
const imgW = imgEl.width * scale
|
||||||
|
const imgH = imgEl.height * scale
|
||||||
|
|
||||||
|
const bgImage = new FabricImage(imgEl, {
|
||||||
|
scaleX: scale,
|
||||||
|
scaleY: scale,
|
||||||
|
left: (cw - imgW) / 2,
|
||||||
|
top: (ch - imgH) / 2,
|
||||||
|
selectable: false,
|
||||||
|
evented: false,
|
||||||
|
originX: 'left',
|
||||||
|
originY: 'top',
|
||||||
|
})
|
||||||
|
|
||||||
|
fc.backgroundImage = bgImage
|
||||||
|
fc.renderAll()
|
||||||
|
}
|
||||||
|
imgEl.src = selectedBackground.data
|
||||||
|
} else {
|
||||||
|
fc.backgroundImage = undefined
|
||||||
|
fc.renderAll()
|
||||||
|
}
|
||||||
|
}, [selectedBackground])
|
||||||
|
|
||||||
|
// ── 添加文字 ──
|
||||||
|
const handleAddText = useCallback(() => {
|
||||||
|
const fc = canvasInstance.current
|
||||||
|
if (!fc) return
|
||||||
|
|
||||||
|
const text = new IText('双击编辑文字', {
|
||||||
|
fontFamily: textConfig.fontFamily,
|
||||||
|
fontSize: textConfig.fontSize,
|
||||||
|
fontWeight: textConfig.fontWeight,
|
||||||
|
fill: textConfig.fill,
|
||||||
|
textAlign: textConfig.textAlign,
|
||||||
|
left: fc.width! / 2,
|
||||||
|
top: fc.height! / 2,
|
||||||
|
originX: 'center',
|
||||||
|
originY: 'center',
|
||||||
|
stroke: textConfig.stroke,
|
||||||
|
strokeWidth: textConfig.strokeWidth,
|
||||||
|
opacity: textConfig.opacity,
|
||||||
|
})
|
||||||
|
|
||||||
|
fc.add(text)
|
||||||
|
fc.setActiveObject(text)
|
||||||
|
fc.renderAll()
|
||||||
|
setSelectedText(text)
|
||||||
|
}, [textConfig, setSelectedText])
|
||||||
|
|
||||||
|
const noSpace = containerSize.width === 0 || containerSize.height === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 12,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!selectedBackground && !noSpace && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 12 }}>🖼️</div>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 500 }}>请先选择一张背景图</div>
|
||||||
|
<div style={{ fontSize: 13, marginTop: 4, color: 'var(--text-secondary)' }}>
|
||||||
|
从左侧面板选择背景图片开始编辑
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
position: 'relative',
|
||||||
|
display: selectedBackground && !noSpace ? 'block' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<canvas ref={canvasRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedBackground && !noSpace && (
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleAddText}
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--accent)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
marginTop: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
添加文字
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Slider,
|
||||||
|
InputNumber,
|
||||||
|
Button,
|
||||||
|
message,
|
||||||
|
Typography,
|
||||||
|
Space,
|
||||||
|
QRCode,
|
||||||
|
Divider,
|
||||||
|
} from 'antd'
|
||||||
|
import { DownloadOutlined, SettingOutlined } from '@ant-design/icons'
|
||||||
|
import { useEditorStore } from '@/stores/editorStore'
|
||||||
|
import { exportAndSave, formatFileSize } from '@/utils/imageExporter'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
interface ExportDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExportDialog({ open, onClose }: ExportDialogProps) {
|
||||||
|
const canvas = useEditorStore((s) => s.canvas)
|
||||||
|
const exportConfig = useEditorStore((s) => s.exportConfig)
|
||||||
|
const setExportConfig = useEditorStore((s) => s.setExportConfig)
|
||||||
|
const [estimatedSize, setEstimatedSize] = useState<string>('')
|
||||||
|
const [exporting, setExporting] = useState(false)
|
||||||
|
|
||||||
|
// Update size estimate when config changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvas || !open) return
|
||||||
|
// Estimate from canvas dimensions
|
||||||
|
const w = canvas.width || 800
|
||||||
|
const h = canvas.height || 600
|
||||||
|
const exportW = Math.round(w * exportConfig.scaleFactor)
|
||||||
|
const exportH = Math.round(h * exportConfig.scaleFactor)
|
||||||
|
// Rough size estimation
|
||||||
|
let bytes = exportW * exportH * 4 // RGBA
|
||||||
|
if (exportConfig.format === 'jpeg' || exportConfig.format === 'webp') {
|
||||||
|
bytes = Math.round(bytes * (exportConfig.quality / 100) * 0.3)
|
||||||
|
} else {
|
||||||
|
bytes = Math.round(bytes * 0.5)
|
||||||
|
}
|
||||||
|
setEstimatedSize(formatFileSize(bytes))
|
||||||
|
}, [canvas, open, exportConfig])
|
||||||
|
|
||||||
|
const handleExport = useCallback(async () => {
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
setExporting(true)
|
||||||
|
try {
|
||||||
|
const success = await exportAndSave(canvas, exportConfig)
|
||||||
|
if (success) {
|
||||||
|
message.success('图片导出成功!')
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
message.error('导出失败,请重试')
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
setExporting(false)
|
||||||
|
}
|
||||||
|
}, [canvas, exportConfig, onClose])
|
||||||
|
|
||||||
|
const canvasW = canvas?.width || 800
|
||||||
|
const canvasH = canvas?.height || 600
|
||||||
|
const exportW = Math.round(canvasW * exportConfig.scaleFactor)
|
||||||
|
const exportH = Math.round(canvasH * exportConfig.scaleFactor)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<DownloadOutlined />
|
||||||
|
<span>导出图片</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
width={520}
|
||||||
|
footer={null}
|
||||||
|
destroyOnClose
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<div style={{ padding: '8px 0', display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
{/* Format */}
|
||||||
|
<div>
|
||||||
|
<Text style={{ color: 'var(--text-secondary)', fontSize: 12, display: 'block', marginBottom: 8 }}>
|
||||||
|
图片格式
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
value={exportConfig.format}
|
||||||
|
onChange={(v) => setExportConfig({ format: v })}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
options={[
|
||||||
|
{ value: 'png', label: 'PNG(无损,支持透明)' },
|
||||||
|
{ value: 'jpeg', label: 'JPEG(有损,体积小)' },
|
||||||
|
{ value: 'webp', label: 'WebP(现代格式,高效)' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quality (for JPEG/WebP) */}
|
||||||
|
{(exportConfig.format === 'jpeg' || exportConfig.format === 'webp') && (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
|
<Text style={{ color: 'var(--text-secondary)', fontSize: 12 }}>图片质量</Text>
|
||||||
|
<Text style={{ color: 'var(--accent)', fontSize: 12, fontWeight: 600 }}>
|
||||||
|
{exportConfig.quality}%
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={exportConfig.quality}
|
||||||
|
onChange={(v) => setExportConfig({ quality: v })}
|
||||||
|
min={10}
|
||||||
|
max={100}
|
||||||
|
marks={{ 10: '低', 50: '中', 80: '高', 100: '最佳' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scale / Size */}
|
||||||
|
<div>
|
||||||
|
<Text style={{ color: 'var(--text-secondary)', fontSize: 12, display: 'block', marginBottom: 8 }}>
|
||||||
|
导出尺寸
|
||||||
|
</Text>
|
||||||
|
<div style={{ display: 'flex', gap: 12, alignItems: 'center', marginBottom: 8 }}>
|
||||||
|
<InputNumber
|
||||||
|
value={exportW}
|
||||||
|
disabled
|
||||||
|
size="small"
|
||||||
|
addonBefore="宽"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>×</span>
|
||||||
|
<InputNumber
|
||||||
|
value={exportH}
|
||||||
|
disabled
|
||||||
|
size="small"
|
||||||
|
addonBefore="高"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={exportConfig.scaleFactor}
|
||||||
|
onChange={(v) => setExportConfig({ scaleFactor: v })}
|
||||||
|
min={0.25}
|
||||||
|
max={4}
|
||||||
|
step={0.25}
|
||||||
|
marks={{
|
||||||
|
0.25: '0.25×',
|
||||||
|
0.5: '0.5×',
|
||||||
|
1: '1×',
|
||||||
|
2: '2×',
|
||||||
|
4: '4×',
|
||||||
|
}}
|
||||||
|
tooltip={{ formatter: (v) => `${v}×` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-canvas)',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '12px 16px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<Space direction="vertical" size={2}>
|
||||||
|
<Text style={{ color: 'var(--text-secondary)', fontSize: 12 }}>
|
||||||
|
导出分辨率
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: 'var(--text-primary)', fontSize: 14, fontWeight: 500 }}>
|
||||||
|
{exportW} × {exportH}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
<Space direction="vertical" size={2} style={{ textAlign: 'right' }}>
|
||||||
|
<Text style={{ color: 'var(--text-secondary)', fontSize: 12 }}>
|
||||||
|
预估文件大小
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: 'var(--accent)', fontSize: 14, fontWeight: 500 }}>
|
||||||
|
{estimatedSize}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 8 }}>
|
||||||
|
<Button onClick={onClose}>取消</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
onClick={handleExport}
|
||||||
|
loading={exporting}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
导出图片
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Button, Tooltip } from 'antd'
|
||||||
|
import {
|
||||||
|
PictureOutlined,
|
||||||
|
ExportOutlined,
|
||||||
|
UndoOutlined,
|
||||||
|
RedoOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
MenuOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import CanvasEditor from '../CanvasEditor/CanvasEditor'
|
||||||
|
import ToolPanel from '../ToolPanel/ToolPanel'
|
||||||
|
import PropertyPanel from '../PropertyPanel/PropertyPanel'
|
||||||
|
import { useEditorStore } from '@/stores/editorStore'
|
||||||
|
|
||||||
|
interface AppLayoutProps {
|
||||||
|
onExport: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppLayout({ onExport }: AppLayoutProps) {
|
||||||
|
const [showToolPanel, setShowToolPanel] = useState(true)
|
||||||
|
const [showPropertyPanel, setShowPropertyPanel] = useState(true)
|
||||||
|
const canvas = useEditorStore((s) => s.canvas)
|
||||||
|
const selectedText = useEditorStore((s) => s.selectedText)
|
||||||
|
const setSelectedText = useEditorStore((s) => s.setSelectedText)
|
||||||
|
const selectedBackground = useEditorStore((s) => s.selectedBackground)
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Delete/Backspace → remove selected text
|
||||||
|
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedText) {
|
||||||
|
const tag = (e.target as HTMLElement)?.tagName
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||||||
|
e.preventDefault()
|
||||||
|
handleDelete()
|
||||||
|
}
|
||||||
|
// Escape → deselect
|
||||||
|
if (e.key === 'Escape' && canvas) {
|
||||||
|
canvas.discardActiveObject()
|
||||||
|
canvas.renderAll()
|
||||||
|
setSelectedText(null)
|
||||||
|
}
|
||||||
|
// Ctrl/Cmd + E → export
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'e') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (selectedBackground) onExport()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (!canvas || !selectedText) return
|
||||||
|
canvas.remove(selectedText)
|
||||||
|
canvas.discardActiveObject()
|
||||||
|
canvas.renderAll()
|
||||||
|
setSelectedText(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100vh',
|
||||||
|
background: 'var(--bg-primary)',
|
||||||
|
}}>
|
||||||
|
{/* ─── Top Bar ─── */}
|
||||||
|
<header style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
height: 48,
|
||||||
|
padding: '0 16px',
|
||||||
|
background: 'var(--bg-secondary)',
|
||||||
|
borderBottom: '1px solid var(--border-color)',
|
||||||
|
flexShrink: 0,
|
||||||
|
WebkitAppRegion: 'drag',
|
||||||
|
} as React.CSSProperties & { WebkitAppRegion?: string }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<span style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)', letterSpacing: 1 }}>
|
||||||
|
TinyLabel
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-secondary)', marginLeft: 4 }}>
|
||||||
|
图像文字编辑工具
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, WebkitAppRegion: 'no-drag' } as React.CSSProperties & { WebkitAppRegion?: string }}>
|
||||||
|
<Tooltip title="撤销">
|
||||||
|
<Button type="text" icon={<UndoOutlined />} size="small" />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="重做">
|
||||||
|
<Button type="text" icon={<RedoOutlined />} size="small" />
|
||||||
|
</Tooltip>
|
||||||
|
<div style={{ width: 1, height: 20, background: 'var(--border-color)', margin: '0 8px' }} />
|
||||||
|
<Tooltip title="删除选中对象">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
size="small"
|
||||||
|
disabled={!selectedText}
|
||||||
|
onClick={handleDelete}
|
||||||
|
danger
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<div style={{ width: 1, height: 20, background: 'var(--border-color)', margin: '0 8px' }} />
|
||||||
|
<Tooltip title="切换左侧面板">
|
||||||
|
<Button
|
||||||
|
type={showToolPanel ? 'primary' : 'text'}
|
||||||
|
icon={<MenuOutlined />}
|
||||||
|
size="small"
|
||||||
|
onClick={() => setShowToolPanel(!showToolPanel)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="切换右侧面板">
|
||||||
|
<Button
|
||||||
|
type={showPropertyPanel ? 'primary' : 'text'}
|
||||||
|
icon={<PictureOutlined />}
|
||||||
|
size="small"
|
||||||
|
onClick={() => setShowPropertyPanel(!showPropertyPanel)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<div style={{ width: 1, height: 20, background: 'var(--border-color)', margin: '0 8px' }} />
|
||||||
|
<Tooltip title="导出图片">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<ExportOutlined />}
|
||||||
|
onClick={onExport}
|
||||||
|
disabled={!selectedBackground}
|
||||||
|
>
|
||||||
|
导出
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ─── Main Content ─── */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* Left Panel */}
|
||||||
|
{showToolPanel && (
|
||||||
|
<aside style={{
|
||||||
|
width: 280,
|
||||||
|
flexShrink: 0,
|
||||||
|
background: 'var(--bg-secondary)',
|
||||||
|
borderRight: '1px solid var(--border-color)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}>
|
||||||
|
<ToolPanel />
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Center: Canvas */}
|
||||||
|
<main style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'var(--bg-canvas)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
}}>
|
||||||
|
<CanvasEditor />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Right Panel */}
|
||||||
|
{showPropertyPanel && (
|
||||||
|
<aside style={{
|
||||||
|
width: 300,
|
||||||
|
flexShrink: 0,
|
||||||
|
background: 'var(--bg-secondary)',
|
||||||
|
borderLeft: '1px solid var(--border-color)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}>
|
||||||
|
<PropertyPanel />
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
Slider,
|
||||||
|
InputNumber,
|
||||||
|
ColorPicker,
|
||||||
|
Collapse,
|
||||||
|
Typography,
|
||||||
|
Segmented,
|
||||||
|
Switch,
|
||||||
|
} from 'antd'
|
||||||
|
import { useEditorStore } from '@/stores/editorStore'
|
||||||
|
import type { FabricText } from 'fabric'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
export default function PropertyPanel() {
|
||||||
|
const canvas = useEditorStore((s) => s.canvas)
|
||||||
|
const selectedText = useEditorStore((s) => s.selectedText)
|
||||||
|
const textConfig = useEditorStore((s) => s.textConfig)
|
||||||
|
const setTextConfig = useEditorStore((s) => s.setTextConfig)
|
||||||
|
const fonts = useEditorStore((s) => s.fonts)
|
||||||
|
|
||||||
|
// Apply changes to Fabric object
|
||||||
|
const applyToSelected = useCallback((changes: Record<string, any>) => {
|
||||||
|
if (!canvas || !selectedText) return
|
||||||
|
selectedText.set(changes)
|
||||||
|
canvas.renderAll()
|
||||||
|
}, [canvas, selectedText])
|
||||||
|
|
||||||
|
const handleFontFamily = (value: string) => {
|
||||||
|
setTextConfig({ fontFamily: value })
|
||||||
|
applyToSelected({ fontFamily: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFontSize = (value: number | null) => {
|
||||||
|
if (!value) return
|
||||||
|
setTextConfig({ fontSize: value })
|
||||||
|
applyToSelected({ fontSize: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFontWeight = (value: number | null) => {
|
||||||
|
if (value === null) return
|
||||||
|
setTextConfig({ fontWeight: value })
|
||||||
|
applyToSelected({ fontWeight: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFillColor = (value: string) => {
|
||||||
|
setTextConfig({ fill: value })
|
||||||
|
applyToSelected({ fill: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTextAlign = (value: string) => {
|
||||||
|
setTextConfig({ textAlign: value as 'left' | 'center' | 'right' })
|
||||||
|
applyToSelected({ textAlign: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStrokeColor = (value: string) => {
|
||||||
|
setTextConfig({ stroke: value })
|
||||||
|
applyToSelected({ stroke: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStrokeWidth = (value: number | null) => {
|
||||||
|
if (!value && value !== 0) return
|
||||||
|
setTextConfig({ strokeWidth: value })
|
||||||
|
applyToSelected({ strokeWidth: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShadowColor = (value: string) => {
|
||||||
|
setTextConfig({ shadowColor: value })
|
||||||
|
applyShadow(value, textConfig.shadowBlur, textConfig.shadowOffsetX, textConfig.shadowOffsetY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShadowBlur = (value: number | null) => {
|
||||||
|
if (value === null) return
|
||||||
|
setTextConfig({ shadowBlur: value })
|
||||||
|
applyShadow(textConfig.shadowColor, value, textConfig.shadowOffsetX, textConfig.shadowOffsetY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShadowOffsetX = (value: number | null) => {
|
||||||
|
if (value === null) return
|
||||||
|
setTextConfig({ shadowOffsetX: value })
|
||||||
|
applyShadow(textConfig.shadowColor, textConfig.shadowBlur, value, textConfig.shadowOffsetY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShadowOffsetY = (value: number | null) => {
|
||||||
|
if (value === null) return
|
||||||
|
setTextConfig({ shadowOffsetY: value })
|
||||||
|
applyShadow(textConfig.shadowColor, textConfig.shadowBlur, textConfig.shadowOffsetX, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyShadow = (color: string, blur: number, offsetX: number, offsetY: number) => {
|
||||||
|
if (!selectedText || !canvas) return
|
||||||
|
if (blur > 0 || offsetX !== 0 || offsetY !== 0) {
|
||||||
|
selectedText.set({
|
||||||
|
shadow: {
|
||||||
|
color,
|
||||||
|
blur,
|
||||||
|
offsetX,
|
||||||
|
offsetY,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
selectedText.set({ shadow: null })
|
||||||
|
}
|
||||||
|
canvas.renderAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpacity = (value: number | null) => {
|
||||||
|
if (value === null) return
|
||||||
|
setTextConfig({ opacity: value })
|
||||||
|
applyToSelected({ opacity: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedText) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
height: '100%',
|
||||||
|
padding: 24,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 40, marginBottom: 12 }}>✏️</div>
|
||||||
|
<Text style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||||
|
选择画布中的文字
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: 'var(--text-secondary)', fontSize: 12, marginTop: 4, opacity: 0.7 }}>
|
||||||
|
点击「添加文字」或选中已有文字进行编辑
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
key: 'typography',
|
||||||
|
label: '文字属性',
|
||||||
|
children: (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
{/* Font */}
|
||||||
|
<div>
|
||||||
|
<Text style={{ color: 'var(--text-secondary)', fontSize: 12, display: 'block', marginBottom: 6 }}>
|
||||||
|
字体
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
value={textConfig.fontFamily}
|
||||||
|
onChange={handleFontFamily}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
size="small"
|
||||||
|
showSearch
|
||||||
|
options={fonts.map((f) => ({ value: f, label: f }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Size & Weight */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<Text style={{ color: 'var(--text-secondary)', fontSize: 12, display: 'block', marginBottom: 6 }}>
|
||||||
|
字号
|
||||||
|
</Text>
|
||||||
|
<InputNumber
|
||||||
|
value={textConfig.fontSize}
|
||||||
|
onChange={handleFontSize}
|
||||||
|
min={8}
|
||||||
|
max={300}
|
||||||
|
size="small"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text style={{ color: 'var(--text-secondary)', fontSize: 12, display: 'block', marginBottom: 6 }}>
|
||||||
|
字重
|
||||||
|
</Text>
|
||||||
|
<Slider
|
||||||
|
value={textConfig.fontWeight}
|
||||||
|
onChange={handleFontWeight}
|
||||||
|
min={100}
|
||||||
|
max={900}
|
||||||
|
step={100}
|
||||||
|
marks={{ 100: '细', 400: '常规', 700: '粗', 900: '超' }}
|
||||||
|
tooltip={{ formatter: (v) => `${v}` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color */}
|
||||||
|
<div>
|
||||||
|
<Text style={{ color: 'var(--text-secondary)', fontSize: 12, display: 'block', marginBottom: 6 }}>
|
||||||
|
文字颜色
|
||||||
|
</Text>
|
||||||
|
<ColorPicker
|
||||||
|
value={textConfig.fill}
|
||||||
|
onChange={(c) => handleFillColor(c.toHexString())}
|
||||||
|
size="small"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
showText
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text Align */}
|
||||||
|
<div>
|
||||||
|
<Text style={{ color: 'var(--text-secondary)', fontSize: 12, display: 'block', marginBottom: 6 }}>
|
||||||
|
对齐方式
|
||||||
|
</Text>
|
||||||
|
<Segmented
|
||||||
|
value={textConfig.textAlign}
|
||||||
|
onChange={handleTextAlign}
|
||||||
|
options={[
|
||||||
|
{ value: 'left', label: '左' },
|
||||||
|
{ value: 'center', label: '中' },
|
||||||
|
{ value: 'right', label: '右' },
|
||||||
|
]}
|
||||||
|
block
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Opacity */}
|
||||||
|
<div>
|
||||||
|
<Text style={{ color: 'var(--text-secondary)', fontSize: 12, display: 'block', marginBottom: 6 }}>
|
||||||
|
不透明度 ({Math.round(textConfig.opacity * 100)}%)
|
||||||
|
</Text>
|
||||||
|
<Slider
|
||||||
|
value={textConfig.opacity}
|
||||||
|
onChange={handleOpacity}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
tooltip={{ formatter: (v) => `${Math.round(v! * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stroke',
|
||||||
|
label: '描边效果',
|
||||||
|
children: (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '3fr 1fr', gap: 12, alignItems: 'center' }}>
|
||||||
|
<ColorPicker
|
||||||
|
value={textConfig.stroke}
|
||||||
|
onChange={(c) => handleStrokeColor(c.toHexString())}
|
||||||
|
size="small"
|
||||||
|
showText
|
||||||
|
disabled={textConfig.strokeWidth === 0}
|
||||||
|
/>
|
||||||
|
<InputNumber
|
||||||
|
value={textConfig.strokeWidth}
|
||||||
|
onChange={handleStrokeWidth}
|
||||||
|
min={0}
|
||||||
|
max={20}
|
||||||
|
size="small"
|
||||||
|
addonAfter="px"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={textConfig.strokeWidth}
|
||||||
|
onChange={handleStrokeWidth}
|
||||||
|
min={0}
|
||||||
|
max={20}
|
||||||
|
marks={{ 0: '关', 5: '5px', 10: '10px', 15: '15px' }}
|
||||||
|
tooltip={{ formatter: (v) => `${v}px` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'shadow',
|
||||||
|
label: '阴影效果',
|
||||||
|
children: (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<ColorPicker
|
||||||
|
value={textConfig.shadowColor}
|
||||||
|
onChange={(c) => handleShadowColor(c.toHexString())}
|
||||||
|
size="small"
|
||||||
|
showText
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text style={{ color: 'var(--text-secondary)', fontSize: 12, display: 'block', marginBottom: 6 }}>
|
||||||
|
模糊程度
|
||||||
|
</Text>
|
||||||
|
<Slider
|
||||||
|
value={textConfig.shadowBlur}
|
||||||
|
onChange={handleShadowBlur}
|
||||||
|
min={0}
|
||||||
|
max={50}
|
||||||
|
marks={{ 0: '无', 10: '10', 25: '25', 50: '50' }}
|
||||||
|
tooltip={{ formatter: (v) => `${v}px` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<Text style={{ color: 'var(--text-secondary)', fontSize: 12, display: 'block', marginBottom: 6 }}>
|
||||||
|
水平偏移
|
||||||
|
</Text>
|
||||||
|
<InputNumber
|
||||||
|
value={textConfig.shadowOffsetX}
|
||||||
|
onChange={handleShadowOffsetX}
|
||||||
|
min={-50}
|
||||||
|
max={50}
|
||||||
|
size="small"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text style={{ color: 'var(--text-secondary)', fontSize: 12, display: 'block', marginBottom: 6 }}>
|
||||||
|
垂直偏移
|
||||||
|
</Text>
|
||||||
|
<InputNumber
|
||||||
|
value={textConfig.shadowOffsetY}
|
||||||
|
onChange={handleShadowOffsetY}
|
||||||
|
min={-50}
|
||||||
|
max={50}
|
||||||
|
size="small"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
padding: '16px 16px 12px',
|
||||||
|
borderBottom: '1px solid var(--border-color)',
|
||||||
|
}}>
|
||||||
|
<Text strong style={{ fontSize: 13, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||||
|
属性
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', padding: '8px 12px' }}>
|
||||||
|
<Collapse
|
||||||
|
defaultActiveKey={['typography', 'stroke', 'shadow']}
|
||||||
|
items={items}
|
||||||
|
ghost
|
||||||
|
size="small"
|
||||||
|
style={{ background: 'transparent' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Button, Upload, message, Typography } from 'antd'
|
||||||
|
import { FolderOpenOutlined, UploadOutlined } from '@ant-design/icons'
|
||||||
|
import { useEditorStore, type BackgroundImage } from '@/stores/editorStore'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
export default function ToolPanel() {
|
||||||
|
const backgrounds = useEditorStore((s) => s.backgrounds)
|
||||||
|
const setBackgrounds = useEditorStore((s) => s.setBackgrounds)
|
||||||
|
const selectedBackground = useEditorStore((s) => s.selectedBackground)
|
||||||
|
const selectBackground = useEditorStore((s) => s.selectBackground)
|
||||||
|
const backgroundsDir = useEditorStore((s) => s.backgroundsDir)
|
||||||
|
const setBackgroundsDir = useEditorStore((s) => s.setBackgroundsDir)
|
||||||
|
|
||||||
|
// Try to load backgrounds from default directory on startup
|
||||||
|
useEffect(() => {
|
||||||
|
loadFromDefaultDir()
|
||||||
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const loadFromDefaultDir = async () => {
|
||||||
|
if (!window.electronAPI) return
|
||||||
|
// Check if there's a backgrounds folder next to the app
|
||||||
|
const images = await window.electronAPI.readImages('./backgrounds')
|
||||||
|
if (images.length > 0) {
|
||||||
|
setBackgrounds(images)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectDir = async () => {
|
||||||
|
if (!window.electronAPI) return
|
||||||
|
const dir = await window.electronAPI.selectDirectory()
|
||||||
|
if (!dir) return
|
||||||
|
setBackgroundsDir(dir)
|
||||||
|
const images = await window.electronAPI.readImages(dir)
|
||||||
|
if (images.length === 0) {
|
||||||
|
message.info('该目录中没有找到图片文件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBackgrounds(images)
|
||||||
|
message.success(`已加载 ${images.length} 张背景图`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpload = (file: File) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const data = e.target?.result as string
|
||||||
|
const newBg: BackgroundImage = {
|
||||||
|
name: file.name,
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
setBackgrounds([...backgrounds, newBg])
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
return false // Prevent default upload
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{
|
||||||
|
padding: '16px 16px 12px',
|
||||||
|
borderBottom: '1px solid var(--border-color)',
|
||||||
|
}}>
|
||||||
|
<Text strong style={{ fontSize: 13, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||||
|
背景图
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
borderBottom: '1px solid var(--border-color)',
|
||||||
|
}}>
|
||||||
|
{window.electronAPI && (
|
||||||
|
<Button
|
||||||
|
icon={<FolderOpenOutlined />}
|
||||||
|
onClick={handleSelectDir}
|
||||||
|
block
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
选择文件夹
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Upload
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/bmp"
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={handleUpload}
|
||||||
|
>
|
||||||
|
<Button icon={<UploadOutlined />} block size="small">
|
||||||
|
上传图片
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnail grid */}
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'auto',
|
||||||
|
padding: '12px',
|
||||||
|
}}>
|
||||||
|
{backgrounds.length === 0 ? (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '32px 16px',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontSize: 13,
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 32, marginBottom: 8 }}>📁</div>
|
||||||
|
<div>暂无背景图</div>
|
||||||
|
<div style={{ marginTop: 4, fontSize: 12, opacity: 0.7 }}>
|
||||||
|
选择文件夹或上传图片
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: 8,
|
||||||
|
}}>
|
||||||
|
{backgrounds.map((bg, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
onClick={() => selectBackground(bg)}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 6,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: selectedBackground === bg
|
||||||
|
? '2px solid var(--accent)'
|
||||||
|
: '2px solid transparent',
|
||||||
|
transition: 'border-color 0.2s',
|
||||||
|
aspectRatio: '16 / 10',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={bg.data}
|
||||||
|
alt={bg.name}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
padding: '4px 6px',
|
||||||
|
background: 'linear-gradient(transparent, rgba(0,0,0,0.8))',
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#fff',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{bg.name}
|
||||||
|
</div>
|
||||||
|
{selectedBackground === bg && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--accent)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
}}>
|
||||||
|
✓
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #1a1a2e;
|
||||||
|
--bg-secondary: #16213e;
|
||||||
|
--bg-surface: #0f3460;
|
||||||
|
--bg-panel: #1e1e3a;
|
||||||
|
--bg-canvas: #2a2a4a;
|
||||||
|
--text-primary: #e8e8f0;
|
||||||
|
--text-secondary: #a0a0b8;
|
||||||
|
--border-color: #2a2a4e;
|
||||||
|
--accent: #7c5cfc;
|
||||||
|
--accent-hover: #6a4ae0;
|
||||||
|
--accent-light: rgba(124, 92, 252, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||||
|
Arial, 'Noto Sans SC', sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Scrollbar ─── */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Ant Design Overrides ─── */
|
||||||
|
.ant-btn-primary {
|
||||||
|
background: var(--accent) !important;
|
||||||
|
border-color: var(--accent) !important;
|
||||||
|
}
|
||||||
|
.ant-btn-primary:hover {
|
||||||
|
background: var(--accent-hover) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal-content {
|
||||||
|
background: var(--bg-panel) !important;
|
||||||
|
border: 1px solid var(--border-color) !important;
|
||||||
|
}
|
||||||
|
.ant-modal-header {
|
||||||
|
background: var(--bg-panel) !important;
|
||||||
|
border-bottom-color: var(--border-color) !important;
|
||||||
|
}
|
||||||
|
.ant-modal-title {
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
.ant-modal-close {
|
||||||
|
color: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-slider-track {
|
||||||
|
background: var(--accent) !important;
|
||||||
|
}
|
||||||
|
.ant-slider-handle::after {
|
||||||
|
background: var(--accent) !important;
|
||||||
|
box-shadow: 0 0 0 2px var(--accent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-select-selector {
|
||||||
|
background: var(--bg-secondary) !important;
|
||||||
|
border-color: var(--border-color) !important;
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
.ant-select-arrow {
|
||||||
|
color: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-input-number {
|
||||||
|
background: var(--bg-secondary) !important;
|
||||||
|
border-color: var(--border-color) !important;
|
||||||
|
}
|
||||||
|
.ant-input-number-input {
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-collapse {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
.ant-collapse-header {
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
.ant-collapse-content {
|
||||||
|
background: transparent !important;
|
||||||
|
border-color: var(--border-color) !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
// 捕获渲染错误
|
||||||
|
const rootEl = document.getElementById('root')
|
||||||
|
|
||||||
|
try {
|
||||||
|
ReactDOM.createRoot(rootEl!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[tinylabel] render error:', err)
|
||||||
|
rootEl!.innerHTML = `
|
||||||
|
<div style="display:flex;align-items:center;justify-content:center;height:100vh;background:#1a1a2e;color:#e8e8f0;font-family:sans-serif;flex-direction:column;gap:12px">
|
||||||
|
<div style="font-size:48px">⚠️</div>
|
||||||
|
<div style="font-size:18px">应用加载失败</div>
|
||||||
|
<pre style="color:#ff6b6b;font-size:13px;max-width:600px">${err instanceof Error ? err.message : String(err)}</pre>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { Canvas, FabricImage, FabricText, IText } from 'fabric'
|
||||||
|
|
||||||
|
export interface BackgroundImage {
|
||||||
|
name: string
|
||||||
|
data: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextEffect {
|
||||||
|
type: 'stroke' | 'shadow' | 'none'
|
||||||
|
enabled: boolean
|
||||||
|
color?: string
|
||||||
|
width?: number
|
||||||
|
blur?: number
|
||||||
|
offsetX?: number
|
||||||
|
offsetY?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextConfig {
|
||||||
|
fontFamily: string
|
||||||
|
fontSize: number
|
||||||
|
fontWeight: number
|
||||||
|
fill: string
|
||||||
|
textAlign: 'left' | 'center' | 'right'
|
||||||
|
stroke: string
|
||||||
|
strokeWidth: number
|
||||||
|
shadowColor: string
|
||||||
|
shadowBlur: number
|
||||||
|
shadowOffsetX: number
|
||||||
|
shadowOffsetY: number
|
||||||
|
opacity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportConfig {
|
||||||
|
format: 'png' | 'jpeg' | 'webp'
|
||||||
|
quality: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
scaleFactor: number
|
||||||
|
outputDir: string
|
||||||
|
fileName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorState {
|
||||||
|
// Canvas
|
||||||
|
canvas: Canvas | null
|
||||||
|
setCanvas: (canvas: Canvas) => void
|
||||||
|
|
||||||
|
// Background images
|
||||||
|
backgrounds: BackgroundImage[]
|
||||||
|
selectedBackground: BackgroundImage | null
|
||||||
|
setBackgrounds: (images: BackgroundImage[]) => void
|
||||||
|
selectBackground: (image: BackgroundImage | null) => void
|
||||||
|
|
||||||
|
// Text objects on canvas
|
||||||
|
selectedText: FabricText | null
|
||||||
|
setSelectedText: (text: FabricText | null) => void
|
||||||
|
|
||||||
|
// Current text editing config (synced with selected object)
|
||||||
|
textConfig: TextConfig
|
||||||
|
setTextConfig: (config: Partial<TextConfig>) => void
|
||||||
|
resetTextConfig: () => void
|
||||||
|
|
||||||
|
// Font list
|
||||||
|
fonts: string[]
|
||||||
|
setFonts: (fonts: string[]) => void
|
||||||
|
|
||||||
|
// Export
|
||||||
|
exportConfig: ExportConfig
|
||||||
|
setExportConfig: (config: Partial<ExportConfig>) => void
|
||||||
|
|
||||||
|
// App state
|
||||||
|
backgroundsDir: string
|
||||||
|
setBackgroundsDir: (dir: string) => void
|
||||||
|
initialized: boolean
|
||||||
|
setInitialized: (v: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TEXT_CONFIG: TextConfig = {
|
||||||
|
fontFamily: 'Arial',
|
||||||
|
fontSize: 48,
|
||||||
|
fontWeight: 400,
|
||||||
|
fill: '#ffffff',
|
||||||
|
textAlign: 'center',
|
||||||
|
stroke: '#000000',
|
||||||
|
strokeWidth: 0,
|
||||||
|
shadowColor: '#000000',
|
||||||
|
shadowBlur: 0,
|
||||||
|
shadowOffsetX: 0,
|
||||||
|
shadowOffsetY: 0,
|
||||||
|
opacity: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_EXPORT_CONFIG: ExportConfig = {
|
||||||
|
format: 'png',
|
||||||
|
quality: 92,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
scaleFactor: 1,
|
||||||
|
outputDir: '',
|
||||||
|
fileName: 'output',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEditorStore = create<EditorState>((set) => ({
|
||||||
|
// Canvas
|
||||||
|
canvas: null,
|
||||||
|
setCanvas: (canvas) => set({ canvas }),
|
||||||
|
|
||||||
|
// Background images
|
||||||
|
backgrounds: [],
|
||||||
|
selectedBackground: null,
|
||||||
|
setBackgrounds: (backgrounds) => set({ backgrounds }),
|
||||||
|
selectBackground: (image) => set({ selectedBackground: image }),
|
||||||
|
|
||||||
|
// Text
|
||||||
|
selectedText: null,
|
||||||
|
setSelectedText: (text) => set({ selectedText: text }),
|
||||||
|
|
||||||
|
// Text config
|
||||||
|
textConfig: { ...DEFAULT_TEXT_CONFIG },
|
||||||
|
setTextConfig: (partial) =>
|
||||||
|
set((state) => ({
|
||||||
|
textConfig: { ...state.textConfig, ...partial },
|
||||||
|
})),
|
||||||
|
resetTextConfig: () => set({ textConfig: { ...DEFAULT_TEXT_CONFIG } }),
|
||||||
|
|
||||||
|
// Fonts
|
||||||
|
fonts: [],
|
||||||
|
setFonts: (fonts) => set({ fonts }),
|
||||||
|
|
||||||
|
// Export
|
||||||
|
exportConfig: { ...DEFAULT_EXPORT_CONFIG },
|
||||||
|
setExportConfig: (partial) =>
|
||||||
|
set((state) => ({
|
||||||
|
exportConfig: { ...state.exportConfig, ...partial },
|
||||||
|
})),
|
||||||
|
|
||||||
|
// App
|
||||||
|
backgroundsDir: '',
|
||||||
|
setBackgroundsDir: (dir) => set({ backgroundsDir: dir }),
|
||||||
|
initialized: false,
|
||||||
|
setInitialized: (v) => set({ initialized: v }),
|
||||||
|
}))
|
||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
/** Electron API exposed via preload script */
|
||||||
|
export interface ElectronAPI {
|
||||||
|
readImages(dirPath: string): Promise<{ name: string; data: string }[]>
|
||||||
|
selectDirectory(): Promise<string>
|
||||||
|
saveFile(filePath: string, base64Data: string): Promise<boolean>
|
||||||
|
selectSavePath(defaultName: string): Promise<string>
|
||||||
|
getFonts(): Promise<string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
electronAPI: ElectronAPI
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/** Load system fonts via Electron IPC */
|
||||||
|
export async function loadSystemFonts(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
if (window.electronAPI) {
|
||||||
|
return await window.electronAPI.getFonts()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback for browser dev
|
||||||
|
}
|
||||||
|
return fallbackFonts()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fallback fonts when running in browser (dev mode) */
|
||||||
|
export function fallbackFonts(): string[] {
|
||||||
|
return [
|
||||||
|
'Arial',
|
||||||
|
'Arial Black',
|
||||||
|
'Calibri',
|
||||||
|
'Cambria',
|
||||||
|
'Comic Sans MS',
|
||||||
|
'Courier New',
|
||||||
|
'Georgia',
|
||||||
|
'Helvetica',
|
||||||
|
'Impact',
|
||||||
|
'Segoe UI',
|
||||||
|
'Tahoma',
|
||||||
|
'Times New Roman',
|
||||||
|
'Trebuchet MS',
|
||||||
|
'Verdana',
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type { ExportConfig } from '@/stores/editorStore'
|
||||||
|
import type { Canvas } from 'fabric'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export the canvas as an image with the specified config.
|
||||||
|
* Returns the base64 data URL of the exported image.
|
||||||
|
*/
|
||||||
|
export function exportCanvasImage(
|
||||||
|
canvas: Canvas,
|
||||||
|
config: ExportConfig
|
||||||
|
): string {
|
||||||
|
const { format, quality, scaleFactor } = config
|
||||||
|
|
||||||
|
// Calculate export dimensions based on scale factor
|
||||||
|
const multiplier = scaleFactor
|
||||||
|
|
||||||
|
// Use Fabric's toDataURL for export
|
||||||
|
const mimeType = format === 'jpeg' ? 'image/jpeg' : format === 'webp' ? 'image/webp' : 'image/png'
|
||||||
|
const qualityValue = format === 'png' ? undefined : quality / 100
|
||||||
|
|
||||||
|
return canvas.toDataURL({
|
||||||
|
format: mimeType as any,
|
||||||
|
quality: qualityValue,
|
||||||
|
multiplier,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export and save the canvas to a file using Electron IPC.
|
||||||
|
*/
|
||||||
|
export async function exportAndSave(
|
||||||
|
canvas: Canvas,
|
||||||
|
config: ExportConfig
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const dataUrl = exportCanvasImage(canvas, config)
|
||||||
|
const ext = config.format === 'jpeg' ? 'jpg' : config.format
|
||||||
|
const defaultName = `${config.fileName || 'output'}.${ext}`
|
||||||
|
|
||||||
|
let filePath: string
|
||||||
|
if (window.electronAPI) {
|
||||||
|
filePath = await window.electronAPI.selectSavePath(defaultName)
|
||||||
|
if (!filePath) return false
|
||||||
|
|
||||||
|
const success = await window.electronAPI.saveFile(filePath, dataUrl)
|
||||||
|
return success
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browser fallback: download via anchor
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.download = defaultName
|
||||||
|
link.href = dataUrl
|
||||||
|
link.click()
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Export failed:', err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the file size estimate for the exported image.
|
||||||
|
*/
|
||||||
|
export function estimateFileSize(
|
||||||
|
canvas: Canvas,
|
||||||
|
config: ExportConfig
|
||||||
|
): number {
|
||||||
|
const dataUrl = exportCanvasImage(canvas, config)
|
||||||
|
// Base64 length -> bytes approximation
|
||||||
|
const base64Length = dataUrl.split(',')[1]?.length || 0
|
||||||
|
return Math.round(base64Length * 0.75)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format bytes to human-readable string.
|
||||||
|
*/
|
||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts", "electron"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import electron from 'vite-plugin-electron'
|
||||||
|
import electronRenderer from 'vite-plugin-electron-renderer'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
electron([
|
||||||
|
{
|
||||||
|
entry: 'electron/main.ts',
|
||||||
|
vite: {
|
||||||
|
build: {
|
||||||
|
outDir: 'dist-electron',
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['electron'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entry: 'electron/preload.ts',
|
||||||
|
onstart(args) {
|
||||||
|
args.reload()
|
||||||
|
},
|
||||||
|
vite: {
|
||||||
|
build: {
|
||||||
|
outDir: 'dist-electron',
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['electron'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
electronRenderer(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user