🎉 初始提交:TinyLabel 图像文字编辑桌面工具

- 基于 Electron + React + TypeScript + Vite
- Fabric.js 画布编辑引擎
- Ant Design UI 组件 + Zustand 状态管理
- 支持文字添加、拖拽、字体/颜色/特效编辑
- 支持 PNG / JPEG / WebP 导出
This commit is contained in:
2026-07-15 11:49:12 +08:00
commit 08cfcb50b0
26 changed files with 9057 additions and 0 deletions
+33
View File
@@ -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
+8
View File
@@ -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 }]
}
}
+79
View File
@@ -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
+43
View File
@@ -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
+149
View File
@@ -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',
]
})
+13
View File
@@ -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
View File
@@ -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>
+6859
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -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"
}
}
+4
View File
@@ -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
View File
@@ -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>
)
}
+190
View File
@@ -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>
)
}
+195
View File
@@ -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
View File
@@ -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;
}
+24
View File
@@ -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>
`
}
+143
View File
@@ -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 }),
}))
+14
View File
@@ -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
}
}
+31
View File
@@ -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',
]
}
+81
View File
@@ -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`
}
+31
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -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"]
}
+44
View File
@@ -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'),
},
},
})