初始化模版工程

This commit is contained in:
Cloud Bot
2026-03-20 07:33:46 +00:00
commit 23717e0ecd
386 changed files with 51675 additions and 0 deletions

View File

@@ -0,0 +1,265 @@
import type {
SceneElement,
ImageElement,
TextElement,
} from '../components/canvas'
/**
* 直接加载图片(不做跨域处理)
*/
function loadImageDirectly(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = () => reject(new Error(`Failed to load image: ${src}`))
img.src = src
})
}
/**
* 加载图片(通过 fetch 获取 blob避免跨域污染 canvas
* 对于跨域图片,先 fetch 为 blob再创建 object URL
*/
async function loadImage(src: string): Promise<HTMLImageElement> {
// 检查是否是 data URL 或 blob URL这些不需要特殊处理
if (src.startsWith('data:') || src.startsWith('blob:')) {
return loadImageDirectly(src)
}
// 检查是否是同源
try {
const url = new URL(src, window.location.origin)
if (url.origin === window.location.origin) {
// 同源图片直接加载
return loadImageDirectly(src)
}
} catch {
// URL 解析失败,尝试直接加载
return loadImageDirectly(src)
}
// 跨域图片:通过 fetch 获取 blob
try {
const response = await fetch(src, { mode: 'cors' })
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status}`)
}
const blob = await response.blob()
const blobUrl = URL.createObjectURL(blob)
try {
const img = await loadImageDirectly(blobUrl)
// 注意:这里不立即 revoke因为 canvas 可能还需要使用
// 在 exportFrameAsCanvas 完成后会释放
return img
} catch (err) {
URL.revokeObjectURL(blobUrl)
throw err
}
} catch {
// fetch 失败,回退到直接加载(可能仍会污染 canvas
console.warn(
`[Export] Failed to fetch image via blob, falling back to direct load: ${src}`,
)
return loadImageDirectly(src)
}
}
/**
* 绘制单行文字
*/
function drawTextLine(
ctx: CanvasRenderingContext2D,
line: string,
boxX: number,
y: number,
maxWidth: number,
textAlign: string,
) {
let x = boxX
if (textAlign === 'center') {
x = boxX + maxWidth / 2
ctx.textAlign = 'center'
} else if (textAlign === 'right') {
x = boxX + maxWidth
ctx.textAlign = 'right'
} else {
ctx.textAlign = 'left'
}
ctx.fillText(line, x, y)
}
/**
* 绘制文本元素并处理换行
*/
function drawTextElement(
ctx: CanvasRenderingContext2D,
element: TextElement,
boxX: number,
boxY: number,
) {
const {
content,
fontSize = 32,
fontFamily = 'Inter, sans-serif',
color = '#000000',
textAlign = 'left',
fontWeight = 'normal',
fontStyle = 'normal',
width: maxWidth,
} = element
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`
ctx.fillStyle = color
ctx.textBaseline = 'top'
const lineHeight = fontSize * 1.2
const paragraphs = (content || '').split('\n')
let currentY = boxY
for (const paragraph of paragraphs) {
if (paragraph === '') {
currentY += lineHeight
continue
}
// 字符级换行,匹配 break-words 逻辑
const chars = paragraph.split('')
let currentLine = ''
for (let n = 0; n < chars.length; n++) {
const testLine = currentLine + chars[n]
const metrics = ctx.measureText(testLine)
const testWidth = metrics.width
if (testWidth > maxWidth && n > 0 && currentLine !== '') {
drawTextLine(ctx, currentLine, boxX, currentY, maxWidth, textAlign)
currentLine = chars[n]
currentY += lineHeight
} else {
currentLine = testLine
}
}
drawTextLine(ctx, currentLine, boxX, currentY, maxWidth, textAlign)
currentY += lineHeight
}
}
export function useDownLoadImageGroup(
selectionBounds: {
left: number
top: number
width: number
height: number
} | null,
elements: Array<SceneElement>,
) {
const { left = 0, top = 0, width = 0, height = 0 } = selectionBounds ?? {}
const scale = 2 // 放大倍数,提升图片质量
const drawCanvas = async () => {
// 创建 canvas
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) return
canvas.width = width * scale
canvas.height = height * scale
ctx.scale(scale, scale)
// 填充白色背景
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, width, height)
// 渲染子元素
for (const element of elements) {
if (!element) continue
const { x, y, width: elWidth, height: elHeight, rotation = 0 } = element
const boxX = x - left
const boxY = y - top
ctx.save()
// 旋转处理
if (rotation !== 0) {
ctx.translate(boxX + elWidth / 2, boxY + elHeight / 2)
ctx.rotate((rotation * Math.PI) / 180)
ctx.translate(-(boxX + elWidth / 2), -(boxY + elHeight / 2))
}
if (element.type === 'image') {
const img = await loadImage((element as ImageElement).src)
ctx.drawImage(img, boxX, boxY, elWidth, elHeight)
} else if (element.type === 'text') {
drawTextElement(ctx, element as TextElement, boxX, boxY)
}
ctx.restore()
}
return canvas
}
const downloadCompositeImage = async () => {
const canvas = await drawCanvas()
if (!canvas) return
// 生成图片并下载
canvas.toBlob(
blob => {
if (!blob) return
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `composite-${Date.now()}.jpg`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
},
'image/jpeg',
0.95,
)
}
const downloadImage = async (
src: string,
fileName: string,
useCanvas = false,
) => {
if (useCanvas) {
const img = await loadImage(src)
// 生成图片并下载
const canvas = document.createElement('canvas')
canvas.width = img.width
canvas.height = img.height
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.drawImage(img, 0, 0)
canvas.toBlob(
blob => {
if (!blob) return
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName || 'image.jpeg'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
},
'image/jpeg',
0.95,
)
} else {
// 直接使用a标签下载图片
const link = document.createElement('a')
link.href = src
link.download = fileName || 'image.jpeg'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
}
return { downloadCompositeImage, downloadImage }
}

View File

@@ -0,0 +1,227 @@
import { useCallback, useEffect, useRef } from 'react'
import { v4 as uuidv4 } from 'uuid'
import { toast } from 'sonner'
import {
type ImageElement,
type SceneElement,
type TextStyle,
useDominoStoreInstance,
} from '../components/canvas'
import { MAX_IMAGE_SIZE_BYTES } from '../consts'
import {
getImageDimension,
isValidImageFormat,
uploadImage,
waitForElement,
} from '../utils/helper'
export function useElementActions(
taskId: string,
addElementToFlow: (elementTemplate: SceneElement) => SceneElement,
scrollIntoView?: (elementId: string) => void,
) {
const store = useDominoStoreInstance()
const lastTextStyleRef = useRef<TextStyle>({
fontSize: 48,
color: '#000000',
fontFamily: undefined,
fontWeight: 'normal',
fontStyle: 'normal',
textAlign: 'left',
})
useEffect(() => {
let lastSelection = store.getState().selectedIds
return store.subscribe(state => {
const selectedIds = state.selectedIds
if (selectedIds === lastSelection) return
lastSelection = selectedIds
const lastId = selectedIds[selectedIds.length - 1]
const el = state.elements[lastId]
if (el?.type === 'text') {
const textEl = el
lastTextStyleRef.current = {
fontSize: textEl.fontSize,
fontWeight: textEl.fontWeight,
color: textEl.color,
fontFamily: textEl.fontFamily,
lineHeight: textEl.lineHeight,
textAlign: textEl.textAlign,
fontStyle: textEl.fontStyle,
}
}
})
}, [store])
const handleAddArtboard = useCallback(() => {
const id = uuidv4()
addElementToFlow({
id,
type: 'artboard',
x: 0,
y: 0,
width: 750,
height: 1750,
rotation: 0,
originalWidth: 750,
originalHeight: 1750,
childrenIds: [],
background: '#FFFFFF',
lockAspectRatio: false,
})
scrollIntoView?.(id)
}, [addElementToFlow, scrollIntoView])
const handleAddText = useCallback(
(options?: { content?: string; style?: Partial<TextStyle> }) => {
const id = uuidv4()
const lastTextStyle = lastTextStyleRef.current
const addedElement = addElementToFlow({
id,
type: 'text',
fontSize: 48,
x: 0,
y: 0,
width: 0,
height: 100,
rotation: 0,
originalWidth: 200,
originalHeight: 100,
content: options?.content || 'New Text',
resize: 'horizontal',
...lastTextStyle,
...options?.style,
})
scrollIntoView?.(id)
// 等待 DOM 渲染后再选中,确保能获取到正确的元素尺寸
waitForElement(id).then(() => {
store.getState().setSelectedIds([id])
})
return addedElement
},
[addElementToFlow, scrollIntoView, store],
)
const handleAddImageFromFile = useCallback(
async (file: File) => {
const { name, type } = file
const isImage = type.startsWith('image/')
if (!isImage && !isValidImageFormat(name)) {
toast.error('不合法的图片格式')
return
}
if (file.size >= MAX_IMAGE_SIZE_BYTES) {
toast.error('图片大小不能超过 20MB')
return
}
const objectUrl = URL.createObjectURL(file)
const { removeElement, addElement, updateElementUIState } =
store.getState()
const tempId = uuidv4()
try {
const { width, height } = await getImageDimension(objectUrl)
// Optimistic update
const optimisticImage: ImageElement = {
id: tempId,
type: 'image',
x: 0,
y: 0,
width,
height,
rotation: 0,
src: objectUrl,
fileName: name,
originalWidth: width,
originalHeight: height,
data: {
size: file.size,
},
}
const addedElement = addElementToFlow(optimisticImage) as ImageElement
updateElementUIState(tempId, {
status: 'pending',
statusText: '处理中...',
})
scrollIntoView?.(tempId)
let url = objectUrl
let file_path = ''
let size = file.size
try {
// Upload
const res = await uploadImage(taskId, file)
url = res.url
file_path = res.file_path
size = res.size
} catch (e) {
console.warn('Preload failed', e)
}
// Replace optimistic image with permanent one
removeElement(tempId)
const newImage: ImageElement = {
...addedElement,
id: file_path,
src: url,
data: {
path: file_path,
size,
},
}
addElement(newImage)
updateElementUIState(file_path, {
status: 'idle',
})
store.getState().setFocusedElementId(file_path)
scrollIntoView?.(file_path)
if (url !== objectUrl) {
URL.revokeObjectURL(objectUrl)
}
} catch (error) {
console.error('Failed to add image:', error)
removeElement(tempId)
toast.error('添加失败')
URL.revokeObjectURL(objectUrl)
}
},
[taskId, addElementToFlow, scrollIntoView, store],
)
const handleAddImage = useCallback(() => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = async e => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
handleAddImageFromFile(file)
}
input.click()
}, [handleAddImageFromFile])
const handleDeleteElements = useCallback(
async (ids: string[]) => {
if (ids.length === 0) return
const { removeElement, takeSnapshot } = store.getState()
takeSnapshot()
ids.forEach(id => removeElement(id))
},
[store],
)
return {
handleAddArtboard,
handleAddImage,
handleAddImageFromFile,
handleAddText,
handleDeleteElements,
}
}

View File

@@ -0,0 +1,436 @@
import { useCallback } from 'react'
import { v4 as uuidv4 } from 'uuid'
import { toast } from 'sonner'
import {
imageMatting,
imageOCR,
recognizeImage,
updateImageOCRText,
segmentLayer,
getSegmentLayerResult,
} from '../service/api'
import type {
ImageOCRResponse,
ImageOCRTextUpdateItem,
RecognizedImageElement,
} from '../service/type'
import {
type ImageElement,
type PlaceholderElement,
type SceneElement,
type ArtboardElement,
useDominoStoreInstance,
} from '../components/canvas'
import {
MAX_IMAGE_SIZE_BYTES,
MATTING_MIN_DIM,
MATTING_MAX_DIM,
NEED_COMPRESS_MAX_SIZE,
NEED_COMPRESS_MIN_SIZE,
} from '../consts'
import {
getImageFileSize,
getImageDimension,
getImageUrl,
isValidImageFormat,
isValidImageSize,
manualPersistence,
} from '../utils/helper'
export function useImageEditActions(
taskId: string,
addElementToFlow: (elementTemplate: SceneElement) => SceneElement,
onPartialRedraw?: (
element: ImageElement,
recognizedElements: readonly RecognizedImageElement[],
) => void,
) {
const store = useDominoStoreInstance()
const handleMatting = useCallback(
async (element: ImageElement) => {
const { originalWidth: w, originalHeight: h } = element
// 1. Dimension check
if (w < MATTING_MIN_DIM || h < MATTING_MIN_DIM) {
toast.error('图片尺寸不能小于 32x32px')
return
}
if (w > MATTING_MAX_DIM || h > MATTING_MAX_DIM) {
toast.error('图片尺寸不能超过 4000x4000px')
return
}
// 2. Size check
const { updateElement } = store.getState()
const fileSize = await getImageFileSize(element)
if (!element.data?.size && fileSize > 0) {
updateElement(element.id, { data: { ...element.data, size: fileSize } })
}
if (fileSize > MAX_IMAGE_SIZE_BYTES) {
toast.error('图片大小不能超过 20MB')
return
}
const placeholderId = uuidv4()
const { removePlaceholder, updateElementUIState, addElement } =
store.getState()
// Immediately insert placeholder element for instant feedback
const placeholder: PlaceholderElement = {
id: placeholderId,
type: 'placeholder',
x: 0,
y: 0,
width: element.width,
height: element.height,
rotation: element.rotation || 0,
originalWidth: element.width,
originalHeight: element.height,
label: '生成中',
}
const addedPlaceholder = addElementToFlow(
placeholder,
) as PlaceholderElement
updateElementUIState(placeholderId, { status: 'pending' })
try {
const res = await imageMatting({
image_url: element.data?.path || element.id,
task_id: taskId,
compress: fileSize > NEED_COMPRESS_MIN_SIZE,
})
try {
// Preload image
const url = await getImageUrl(taskId, res.path)
const { width, height } = await getImageDimension(url)
// Replace placeholder with matting result
removePlaceholder(placeholderId)
const newImage: ImageElement = {
...addedPlaceholder,
id: res.path,
type: 'image',
src: url,
width,
height,
fileName: res.path.split('/').pop() || 'image.jpg',
originalWidth: width,
originalHeight: height,
data: {
path: res.path,
},
}
addElement(newImage)
updateElementUIState(res.path, { status: 'idle' })
// Support background task persistence
manualPersistence(taskId, store)
} catch (e) {
console.warn('Preload failed', e)
}
} catch (error) {
console.error(error)
removePlaceholder(placeholderId)
}
},
[taskId, addElementToFlow, store],
)
const handlePartialRedraw = useCallback(
async (element: ImageElement) => {
if (!isValidImageFormat(element.fileName)) {
toast.error('不合法的图片格式')
return
}
if (!isValidImageSize(element.originalWidth, element.originalHeight)) {
toast.error('图片尺寸不能超过 2160x3840px 或 3840x2160px')
return
}
const { updateElement, updateElementUIState } = store.getState()
const fileSize = await getImageFileSize(element)
if (!element.data?.size && fileSize > 0) {
updateElement(element.id, { data: { ...element.data, size: fileSize } })
}
if (fileSize >= MAX_IMAGE_SIZE_BYTES) {
toast.error('图片大小不能超过 20MB')
return
}
updateElementUIState(element.id, {
status: 'pending',
statusText: '识图中...',
})
try {
const res = await recognizeImage({
image_url: element.data?.path || element.id,
task_id: taskId,
width: element.originalWidth,
height: element.originalHeight,
})
onPartialRedraw?.(element, res || [])
} catch (err) {
console.error('Failed to recognize image:', err)
} finally {
updateElementUIState(element.id, {
status: 'idle',
})
}
},
[taskId, onPartialRedraw, store],
)
const handleEditText = useCallback(
async (
element: ImageElement,
onOCRSuccess: (data: ImageOCRResponse) => void,
) => {
if (!isValidImageFormat(element.fileName)) {
toast.error('不合法的图片格式')
return
}
if (!isValidImageSize(element.originalWidth, element.originalHeight)) {
toast.error('图片尺寸不能超过 2160x3840px 或 3840x2160px')
return
}
const { updateElement, updateElementUIState } = store.getState()
const fileSize = await getImageFileSize(element)
if (!element.data?.size && fileSize > 0) {
updateElement(element.id, { data: { ...element.data, size: fileSize } })
}
if (fileSize >= MAX_IMAGE_SIZE_BYTES) {
toast.error('图片大小不能超过 20MB')
return
}
updateElementUIState(element.id, {
status: 'pending',
statusText: '正在提取文字',
})
const errorText = '没有提取到文字'
try {
const data = await imageOCR({
image_url: element.data?.path || element.id,
task_id: taskId,
})
if (data.length > 0) {
onOCRSuccess(data)
} else {
toast.error(errorText)
}
} catch (error) {
console.error(error)
toast.error(errorText)
} finally {
updateElementUIState(element.id, { status: 'idle' })
}
},
[taskId, store],
)
const handleConfirmEditText = useCallback(
async (element: ImageElement, updatedList: ImageOCRTextUpdateItem[]) => {
const placeholderId = uuidv4()
const { removePlaceholder, updateElementUIState, addElement } =
store.getState()
// Insert new placeholder element into the flow
const placeholder: PlaceholderElement = {
id: placeholderId,
type: 'placeholder',
x: 0,
y: 0,
width: element.width,
height: element.height,
rotation: element.rotation || 0,
originalWidth: element.width,
originalHeight: element.height,
label: '处理中...',
}
const addedPlaceholder = addElementToFlow(
placeholder,
) as PlaceholderElement
updateElementUIState(placeholderId, { status: 'pending' })
try {
const res = await updateImageOCRText({
image_url: element.data?.path || element.id,
texts: updatedList,
task_id: taskId,
compress: (element.data?.size ?? 0) > NEED_COMPRESS_MAX_SIZE,
})
// Preload next image
try {
const url = await getImageUrl(taskId, res.path)
const { width, height } = await getImageDimension(url)
// Replace placeholder with result
removePlaceholder(placeholderId)
const newImage: ImageElement = {
...addedPlaceholder,
id: res.path,
type: 'image',
src: url,
width,
height,
fileName: res.path.split('/').pop() || 'image.jpg',
originalWidth: width,
originalHeight: height,
data: {
path: res.path,
},
}
addElement(newImage)
updateElementUIState(res.path, { status: 'idle' })
// Support background task persistence
manualPersistence(taskId, store)
} catch (e) {
console.warn('Preload failed', e)
}
} catch (error) {
console.error(error)
removePlaceholder(placeholderId)
toast.error('修改失败')
}
},
[taskId, addElementToFlow, store],
)
const handleEditElements = useCallback(
async (element: ImageElement) => {
const { updateElementUIState, addElement, setSelectedIds } =
store.getState()
updateElementUIState(element.id, {
status: 'pending',
statusText: '正在分层...',
})
try {
const taskRecordId = await segmentLayer({
image_url: element.data?.path || element.id,
task_id: taskId,
width: element.originalWidth,
height: element.originalHeight,
})
// Polling loop
let task = await getSegmentLayerResult(taskRecordId)
while (task.status === 'PROCESSING') {
await new Promise(resolve => setTimeout(resolve, 2000))
task = await getSegmentLayerResult(taskRecordId)
}
if (
task.status === 'TIMEOUT' ||
task.status === 'FAILED' ||
!task.layers
) {
throw new Error('分层任务执行失败')
}
const layersData = task.layers
// 1. Prepare all layers (Sorted by layer_order from server)
const allLayersData = layersData.map(layer => ({
image: layer.image,
line_rect: {
x: layer.x,
y: layer.y,
width: layer.width,
height: layer.height,
},
line_text: layer.desc,
hideMetadata: layer.desc === 'background',
}))
// 2. Preload all layers and prepare IDs
const layers = await Promise.all(
allLayersData.map(async item => {
const itemUrl = await getImageUrl(taskId, item.image.path)
const childId = uuidv4()
return { item, itemUrl, childId }
}),
)
// 3. Create Artboard Template
const artboardId = uuidv4()
const artboardTemplate: ArtboardElement = {
id: artboardId,
type: 'artboard',
name: `画板 - ${element.fileName}`,
x: 0,
y: 0,
width: element.width,
height: element.height,
rotation: element.rotation || 0,
originalWidth: element.originalWidth,
originalHeight: element.originalHeight,
background: '#FFFFFF',
childrenIds: layers.map(l => l.childId),
}
// Add artboard to flow
const addedArtboard = addElementToFlow(
artboardTemplate,
) as ArtboardElement
// 4. Add all child elements to store
for (const layer of layers) {
const { item, itemUrl, childId } = layer
const childImage: ImageElement = {
id: childId,
type: 'image',
x: item.line_rect.x,
y: item.line_rect.y,
width: item.line_rect.width,
height: item.line_rect.height,
rotation: 0,
originalWidth: item.line_rect.width,
originalHeight: item.line_rect.height,
src: itemUrl,
fileName: item.image.path.split('/').pop() || 'layer.png',
parentId: artboardId,
selectable: true,
hideMetadata: item.hideMetadata,
data: {
path: item.image.path,
text: item.line_text,
},
}
addElement(childImage)
}
setSelectedIds([addedArtboard.id])
// Support background task persistence
manualPersistence(taskId, store)
} catch (err) {
console.error('Failed to segment text layer:', err)
toast.error('修改失败')
} finally {
updateElementUIState(element.id, {
status: 'idle',
statusText: undefined,
})
}
},
[taskId, store, addElementToFlow],
)
return {
handleMatting,
handlePartialRedraw,
handleEditText,
handleConfirmEditText,
handleEditElements,
}
}

View File

@@ -0,0 +1,162 @@
import { useCallback } from 'react'
import { useDominoStoreInstance } from '../components/canvas'
import type {
SceneElement,
PlaceholderElement,
Padding,
} from '../components/canvas'
export interface LayoutOptions {
maxWidth?: number
padding?: Padding
colGap?: number
rowGap?: number
}
export function useLayout(options: LayoutOptions = {}) {
const {
maxWidth = 7000,
padding = { top: 100, left: 100, bottom: 100, right: 100 },
colGap = 20,
rowGap = 80,
} = options
const store = useDominoStoreInstance()
const topPadding = padding.top ?? 0
const leftPadding = padding.left ?? 0
/**
* Rearrange all existing top-level elements
*/
const arrangeElements = useCallback(
(overrideOptions?: LayoutOptions) => {
const config = {
maxWidth,
padding,
colGap,
rowGap,
...overrideOptions,
}
const state = store.getState()
const { elements, placeholders, elementOrder } = state
const configTop = config.padding?.top ?? topPadding
const configLeft = config.padding?.left ?? leftPadding
// Record history
store.getState().takeSnapshot()
const newPositions: Record<
string,
{ x: number; y: number; rotation: number }
> = {}
let currentX = configLeft
let currentY = configTop
let maxHeightInRow = 0
const elementsToArrange = elementOrder
.map((id: string) => elements[id] || placeholders[id])
.filter(
(el: SceneElement | undefined): el is SceneElement =>
el !== undefined,
)
elementsToArrange.forEach((el: SceneElement) => {
if (currentX + el.width > config.maxWidth) {
currentX = configLeft
currentY += maxHeightInRow + config.rowGap
maxHeightInRow = 0
}
newPositions[el.id] = {
x: currentX,
y: currentY,
rotation: 0,
}
maxHeightInRow = Math.max(maxHeightInRow, el.height)
currentX += el.width + config.colGap
})
store.setState(
(state: {
elements: Record<string, SceneElement>
placeholders: Record<string, PlaceholderElement>
}) => {
Object.entries(newPositions).forEach(([id, pos]) => {
if (state.elements[id]) {
state.elements[id] = { ...state.elements[id], ...pos }
} else if (state.placeholders[id]) {
state.placeholders[id] = { ...state.placeholders[id], ...pos }
}
})
},
)
},
[maxWidth, topPadding, leftPadding, colGap, rowGap, store, padding],
)
/**
* Add a new element to the end of the flow
*/
const addElementToFlow = useCallback(
<T extends SceneElement>(elementTemplate: T): T => {
const state = store.getState()
const { elements, placeholders, elementOrder } = state
const getAnyElement = (id: string) => elements[id] || placeholders[id]
// Get all top-level elements to find the current "flow" tail
const existingElements = elementOrder
.map((id: string) => getAnyElement(id))
.filter(
(el: SceneElement | undefined): el is SceneElement =>
!!el &&
(el.type !== 'artboard' ||
(el.type === 'artboard' && !el.parentId)),
)
const lastElement = existingElements[existingElements.length - 1]
let currentX = lastElement
? lastElement.x + lastElement.width + colGap
: leftPadding
let currentY = lastElement ? lastElement.y : topPadding
// To find the current row's max height, we look at elements on the same Y
const currentRowElements = existingElements.filter(
(el: SceneElement) => el.y === currentY,
)
const maxHeightInRow =
currentRowElements.length > 0
? Math.max(...currentRowElements.map((el: SceneElement) => el.height))
: 0
// Wrap check
if (currentX + elementTemplate.width > maxWidth) {
currentX = leftPadding
currentY += maxHeightInRow + rowGap
}
const newElement: SceneElement = {
...elementTemplate,
x: currentX,
y: currentY,
}
if (newElement.type === 'placeholder') {
state.addPlaceholder(newElement as PlaceholderElement)
} else {
state.addElement(newElement)
}
state.setFocusedElementId(newElement.id)
state.setSelectedIds([])
return newElement as T
},
[colGap, rowGap, maxWidth, topPadding, leftPadding, store],
)
return { arrangeElements, addElementToFlow }
}

View File

@@ -0,0 +1,83 @@
import { useState, useEffect } from 'react'
export interface FontOption {
label: string
value: string
}
const DEFAULT_FONTS: FontOption[] = [
{ label: '黑体', value: 'SimHei, sans-serif' },
{ label: '宋体', value: 'SimSun, serif' },
{
label: '微软雅黑',
value: 'Microsoft YaHei, sans-serif',
},
{ label: 'PingFang SC', value: 'PingFang SC, sans-serif' },
{ label: 'Inter', value: 'Inter, sans-serif' },
]
let cachedFonts: FontOption[] | null = null
export function useLocalFonts() {
const [fonts, setFonts] = useState<FontOption[]>(cachedFonts || DEFAULT_FONTS)
const [loading, setLoading] = useState(false)
useEffect(() => {
if (cachedFonts && cachedFonts.length > DEFAULT_FONTS.length) {
return
}
async function loadLocalFonts() {
// Check if the API is supported
if (!('queryLocalFonts' in window)) {
return
}
try {
setLoading(true)
// @ts-expect-error - queryLocalFonts is a new API
const localFonts = await window.queryLocalFonts()
// Group by family and take the first one (usually the regular style)
// or just use unique families
const families = new Set<string>()
const dynamicFonts: FontOption[] = []
localFonts.forEach((font: any) => {
if (!families.has(font.family)) {
families.add(font.family)
dynamicFonts.push({
label: font.family,
value: font.family,
})
}
})
// Sort alphabetically
dynamicFonts.sort((a, b) => a.label.localeCompare(b.label))
// Combine with defaults, ensuring no duplicates by family name
const combined = [...DEFAULT_FONTS]
const defaultFamilies = new Set(DEFAULT_FONTS.map(f => f.label))
dynamicFonts.forEach(df => {
if (!defaultFamilies.has(df.label)) {
combined.push(df)
}
})
cachedFonts = combined
setFonts(combined)
} catch (err) {
console.error('Failed to query local fonts:', err)
// Fallback is already set as initial state
} finally {
setLoading(false)
}
}
loadLocalFonts()
}, [])
return { fonts, loading }
}

View File

@@ -0,0 +1,80 @@
import { useCallback } from 'react'
interface UsePasteHandlerProps {
readOnly: boolean
handleAddImageFromFile: (file: File) => Promise<void>
handleAddText: (options?: {
content?: string
style?: Record<string, any>
}) => void
}
export function usePasteHandler({
readOnly,
handleAddImageFromFile,
handleAddText,
}: UsePasteHandlerProps) {
const handlePaste = useCallback(
async (e: ClipboardEvent) => {
if (readOnly) return
// 1. 尝试解析图片
const items = e.clipboardData?.items
if (items) {
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item.kind === 'file' && item.type.startsWith('image/')) {
const file = item.getAsFile()
if (file) {
e.preventDefault()
await handleAddImageFromFile(file)
return
}
}
}
}
// 2. 尝试解析带有样式的文本元素或纯文本
const textHtml = e.clipboardData?.getData('text/html')
const textPlain = e.clipboardData?.getData('text/plain')
if (textHtml) {
const match = textHtml.match(/<!--DOMINO_TEXT_ELEMENT:(.*?)-->/)
if (match) {
try {
const data = JSON.parse(match[1])
if (data.type === 'domino-text-element') {
e.preventDefault()
handleAddText({
content: data.content,
style: {
fontSize: data.fontSize,
fontWeight: data.fontWeight,
color: data.color,
fontFamily: data.fontFamily,
lineHeight: data.lineHeight,
textAlign: data.textAlign,
fontStyle: data.fontStyle,
width: data.width,
height: data.height,
},
})
return
}
} catch (err) {
console.error('Failed to parse pasted text element', err)
}
}
}
if (textPlain && textPlain.trim()) {
e.preventDefault()
handleAddText({ content: textPlain })
}
},
[readOnly, handleAddImageFromFile, handleAddText],
)
return handlePaste
}

View File

@@ -0,0 +1,156 @@
import { useEffect, useCallback, useRef, useState } from 'react'
import { shallow } from 'zustand/shallow'
import {
saveDomiBoardContent,
getDomiBoardContent,
} from '../service/api'
import { useDominoStoreInstance } from '../components/canvas'
import type { SceneElement, DominoCanvasData } from '../components/canvas'
export function usePersistence(options: {
taskId: string
setLoading: (loading: boolean) => void
}) {
const { taskId, setLoading } = options
const store = useDominoStoreInstance()
const isFirstLoad = useRef(true)
const [initialized, setInitialized] = useState(false)
// 任务切换时重置状态
useEffect(() => {
isFirstLoad.current = true
setInitialized(false)
}, [taskId])
const loadBoard = useCallback(async () => {
if (!taskId) {
setLoading(false)
setInitialized(true)
return
}
const currentTaskId = taskId
setLoading(true)
try {
const res = (await getDomiBoardContent(taskId)) as
| DominoCanvasData
| string
if (taskId !== currentTaskId) return
if (res) {
try {
const parsed = (
typeof res === 'string' ? JSON.parse(res) : res
) as DominoCanvasData
if (parsed && typeof parsed === 'object') {
const { elements, elementOrder, createdAt, updatedAt } = parsed
const state = store.getState()
state.clearElements()
Object.values(elements).forEach(el => {
state.addElement(el)
})
// addElement might have added them in different order, so we overwrite it.
store.setState({
elementOrder,
metadata: {
createdAt: createdAt || Date.now(),
updatedAt: updatedAt || Date.now(),
},
})
state.resetHistory()
setLoading(false)
isFirstLoad.current = false
setInitialized(true)
}
} catch (e) {
console.error('Failed to parse saved DomiBoard content', e)
}
}
} catch (e) {
console.error('Failed to load DomiBoard content', e)
} finally {
if (taskId === currentTaskId) {
if (isFirstLoad.current) {
isFirstLoad.current = false
}
setLoading(false)
setInitialized(true)
store.getState().resetHistory()
}
}
}, [taskId, setLoading])
const saveTimerRef = useRef<NodeJS.Timeout | null>(null)
// Auto-save logic
useEffect(() => {
let lastState = {
elements: store.getState().elements,
elementOrder: store.getState().elementOrder,
}
const unsubscribe = store.subscribe(state => {
const curr = {
elements: state.elements,
elementOrder: state.elementOrder,
}
// Check if relevant state changed using shallow comparison
if (shallow(lastState, curr)) return
lastState = curr
if (isFirstLoad.current || !taskId) return
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
}
saveTimerRef.current = setTimeout(async () => {
try {
const currentMetadata = store.getState().metadata || {}
const createdAt = currentMetadata.createdAt || Date.now()
const persistentElements: Record<string, SceneElement> = {}
const persistentOrder: string[] = []
Object.values(curr.elements).forEach(el => {
if (el.type !== 'placeholder') {
persistentElements[el.id] = el
}
})
curr.elementOrder.forEach((id: string) => {
if (curr.elements[id] && curr.elements[id].type !== 'placeholder') {
persistentOrder.push(id)
}
})
const persistenceData: DominoCanvasData = {
elements: persistentElements,
elementOrder: persistentOrder,
createdAt,
updatedAt: Date.now(),
}
await saveDomiBoardContent({
task_id: taskId,
data: persistenceData,
})
} catch (e) {
console.error('Failed to auto-save DomiBoard content', e)
}
}, 2000) // 2 second debounce
})
return () => {
unsubscribe()
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
}
}
}, [taskId, store])
return { loadBoard, initialized }
}

View File

@@ -0,0 +1,200 @@
import { useEffect } from 'react'
import type React from 'react'
import { toast } from 'sonner'
import {
useDominoStore,
useDominoStoreInstance,
type ImageElement,
type TextElement,
} from '../components/canvas'
import {
copyImageToClipboard,
copyTextElementToClipboard,
} from '../utils/helper'
import { useZoomActions } from './use-zoom-actions'
export interface UseShortcutsProps {
rootRef: React.RefObject<HTMLDivElement | null>
onDelete?: (ids: string[]) => void
onPaste?: (e: ClipboardEvent) => void
}
export function useShortcuts({ rootRef, onDelete, onPaste }: UseShortcutsProps) {
const store = useDominoStoreInstance()
const { stepZoom } = useZoomActions(rootRef)
const mode = useDominoStore(s => s.mode)
const setMode = useDominoStore(s => s.setMode)
const setViewport = useDominoStore(s => s.setViewport)
const undo = useDominoStore(s => s.undo)
const redo = useDominoStore(s => s.redo)
const selectedIds = useDominoStore(s => s.selectedIds)
const focusedElementId = useDominoStore(s => s.focusedElementId)
const readOnly = useDominoStore(s => s.readOnly)
useEffect(() => {
const isInputActive = () => {
// 1. 如果有元素正在被聚焦(比如正在编辑文字),禁用快捷键
if (focusedElementId) {
const { elements } = store.getState()
const focusedEl = elements[focusedElementId]
if (focusedEl?.type === 'text') return true
}
// 2. 如果原生 DOM 的输入框处于激活状态,禁用快捷键
const activeElement = document.activeElement
if (!activeElement) return false
return (
activeElement.tagName.toLowerCase() === 'input' ||
activeElement.tagName.toLowerCase() === 'textarea' ||
(activeElement as HTMLElement).isContentEditable
)
}
const handleKeyDown = (e: KeyboardEvent) => {
if (isInputActive()) return
const isMod = e.metaKey || e.ctrlKey
const isShift = e.shiftKey
// 1. Space -> Pan Mode
if (e.code === 'Space' && mode !== 'pan') {
e.preventDefault()
setMode('pan')
}
// 2. Undo/Redo
if (isMod && e.key.toLowerCase() === 'z') {
e.preventDefault()
if (isShift) redo()
else undo()
} else if (isMod && e.key.toLowerCase() === 'y') {
e.preventDefault()
redo()
}
// 3. Zoom Shortcuts
if (isMod && (e.key === '=' || e.key === '+')) {
e.preventDefault()
stepZoom(1)
}
if (isMod && (e.key === '-' || e.key === '_')) {
e.preventDefault()
stepZoom(-1)
}
if (e.key === '0') {
e.preventDefault()
setViewport({ scale: 1 })
}
// 4. Delete
if (e.key === 'Delete' || e.key === 'Backspace') {
if (selectedIds.length > 0 && !readOnly) {
e.preventDefault()
onDelete?.(selectedIds)
}
}
// 5. Select All
if (isMod && e.key.toLowerCase() === 'a') {
e.preventDefault()
const { elements, setSelectedIds } = store.getState()
setSelectedIds(Object.keys(elements))
}
// 6. Escape
if (e.key === 'Escape') {
const { setSelectedIds, setFocusedElementId } = store.getState()
setSelectedIds([])
setFocusedElementId(null)
if (mode !== 'select') setMode('select')
}
// 7. Arrow Keys
const ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']
if (ARROW_KEYS.includes(e.key) && selectedIds.length > 0 && !readOnly) {
e.preventDefault()
const step = isShift ? 10 : 1
const dx =
e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0
const dy =
e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0
const { moveElements } = store.getState()
moveElements(selectedIds, dx, dy)
}
// 8. Copy
if (isMod && e.key.toLowerCase() === 'c') {
const { elements } = store.getState()
const selectedElements = selectedIds
.map(id => elements[id])
.filter(Boolean)
if (selectedElements.length === 0) return
e.preventDefault()
if (selectedElements.length === 1) {
const el = selectedElements[0]
if (el.type === 'text') {
const textEl = el as TextElement
copyTextElementToClipboard(textEl).then(success => {
if (success) toast.success('已复制文本')
})
} else if (el.type === 'image') {
const imageEl = el as ImageElement
copyImageToClipboard(imageEl.id).then(success => {
if (success) toast.success('已复制图片')
else toast.error('复制失败')
})
}
return
}
const texts = selectedElements
.filter((el): el is TextElement => el.type === 'text')
.map(el => el.content)
if (texts.length > 0) {
navigator.clipboard
.writeText(texts.join('\n'))
.then(() => toast.success(`已复制 ${texts.length} 条内容`))
}
}
}
const handleKeyUp = (e: KeyboardEvent) => {
if (e.code === 'Space' && !isInputActive()) {
setMode('select')
}
}
const handlePaste = (e: ClipboardEvent) => {
if (isInputActive()) return
if (!onPaste) return
onPaste(e)
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
window.addEventListener('paste', handlePaste)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
window.removeEventListener('paste', handlePaste)
}
}, [
stepZoom,
mode,
setMode,
setViewport,
undo,
redo,
selectedIds,
focusedElementId,
readOnly,
store,
rootRef,
onDelete,
onPaste,
])
}

View File

@@ -0,0 +1,35 @@
import { useCallback } from 'react'
import { useDominoStore, useDominoStoreInstance } from '../components/canvas'
export function useZoomActions(rootRef: React.RefObject<HTMLElement | null>) {
const store = useDominoStoreInstance()
const scale = useDominoStore(state => state.viewport.scale)
const handleZoom = useCallback(
(targetScale: number) => {
const { viewport, zoomViewport } = store.getState()
const multiplier = targetScale / viewport.scale
const container = rootRef.current
const centerX = (container?.offsetWidth || window.innerWidth) / 2
const centerY = (container?.offsetHeight || window.innerHeight) / 2
zoomViewport(multiplier, centerX, centerY, 0.02, 4)
},
[store, rootRef],
)
const stepZoom = useCallback(
(delta: number) => {
const { viewport } = store.getState()
const step = Math.sign(delta) * 0.02
const targetScale = Math.max(0.02, Math.min(4, viewport.scale + step))
handleZoom(targetScale)
},
[store, handleZoom],
)
return {
handleZoom,
stepZoom,
scale,
}
}