初始化模版工程
This commit is contained in:
265
components/image-editor/hooks/use-download-image-group.ts
Normal file
265
components/image-editor/hooks/use-download-image-group.ts
Normal 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 }
|
||||
}
|
||||
227
components/image-editor/hooks/use-element-actions.ts
Normal file
227
components/image-editor/hooks/use-element-actions.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
436
components/image-editor/hooks/use-image-edit-actions.ts
Normal file
436
components/image-editor/hooks/use-image-edit-actions.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
162
components/image-editor/hooks/use-layout.ts
Normal file
162
components/image-editor/hooks/use-layout.ts
Normal 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 }
|
||||
}
|
||||
83
components/image-editor/hooks/use-local-fonts.ts
Normal file
83
components/image-editor/hooks/use-local-fonts.ts
Normal 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 }
|
||||
}
|
||||
80
components/image-editor/hooks/use-paste-handler.ts
Normal file
80
components/image-editor/hooks/use-paste-handler.ts
Normal 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
|
||||
}
|
||||
|
||||
156
components/image-editor/hooks/use-persistence.ts
Normal file
156
components/image-editor/hooks/use-persistence.ts
Normal 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 }
|
||||
}
|
||||
200
components/image-editor/hooks/use-shortcuts.ts
Normal file
200
components/image-editor/hooks/use-shortcuts.ts
Normal 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,
|
||||
])
|
||||
}
|
||||
35
components/image-editor/hooks/use-zoom-actions.ts
Normal file
35
components/image-editor/hooks/use-zoom-actions.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user