🎉 初始提交: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