228 lines
5.9 KiB
TypeScript
228 lines
5.9 KiB
TypeScript
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,
|
|
}
|
|
}
|