174 lines
8.8 KiB
TypeScript

'use client'
import { useEffect, useRef, useState } from 'react'
import type { Canvas, Textbox } from 'fabric'
import type { DetailScreen } from '@/lib/detail-generator/types'
import { ensureDetailFontsLoaded, getDetailImageContainGeometry, getDetailImageCoverGeometry } from '@/lib/detail-generator/fonts'
import { assetUrl } from './client'
/** Editing is deliberately limited to text. The saved template owns geometry. */
export default function DetailCanvas({ screen, disabled, onTextChange, onUnsavedEdit, onPendingTextChange }: {
screen: DetailScreen
disabled: boolean
onTextChange: (layerId: string, text: string) => void
onUnsavedEdit?: () => void
onPendingTextChange?: (layerId: string, text: string) => void
}) {
const hostRef = useRef<HTMLDivElement>(null)
const wrapperRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<Canvas | null>(null)
const disposalRef = useRef<Promise<unknown>>(Promise.resolve())
const changeRef = useRef(onTextChange)
const screenRef = useRef(screen)
const unsavedRef = useRef(onUnsavedEdit)
const pendingTextRef = useRef(onPendingTextChange)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
changeRef.current = onTextChange
screenRef.current = screen
unsavedRef.current = onUnsavedEdit
pendingTextRef.current = onPendingTextChange
useEffect(() => {
let cancelled = false
let observer: ResizeObserver | undefined
let canvas: Canvas | undefined
let ownedCanvas: HTMLCanvasElement | undefined
let ownedContainer: HTMLElement | undefined
let initialHost: HTMLDivElement | undefined
let outsideClick: ((event: Event) => void) | undefined
let stage = 'wait-disposal'
setLoading(true)
setError('')
async function render() {
await disposalRef.current
stage = 'load-library'
const fabric = await import('fabric')
stage = 'load-fonts'
await ensureDetailFontsLoaded()
if (cancelled || !hostRef.current || !wrapperRef.current) return
stage = 'create-canvas'
initialHost = hostRef.current
ownedCanvas = document.createElement('canvas')
initialHost.appendChild(ownedCanvas)
canvas = new fabric.Canvas(ownedCanvas, { width: screen.width, height: screen.height, backgroundColor: screen.backgroundColor, selection: false, preserveObjectStacking: true })
ownedContainer = ownedCanvas.parentElement || undefined
canvasRef.current = canvas
const resize = () => {
if (!canvas || cancelled || !wrapperRef.current) return
const scale = Math.min(1, Math.max(1, wrapperRef.current.clientWidth) / screen.width)
canvas.setDimensions({ width: screen.width * scale, height: screen.height * scale })
canvas.setZoom(scale)
}
resize()
observer = new ResizeObserver(resize)
observer.observe(wrapperRef.current)
async function addImage(image: NonNullable<DetailScreen['image']>, contain = false) {
stage = contain ? 'load-logo' : 'load-image'
const bitmap = await fabric.FabricImage.fromURL(assetUrl(image.assetId))
if (cancelled) return
const sourceWidth = bitmap.width || 1
const sourceHeight = bitmap.height || 1
const { offsetX = 0, offsetY = 0, ...geometry } = contain
? getDetailImageContainGeometry(sourceWidth, sourceHeight, image.width, image.height)
: { ...getDetailImageCoverGeometry(sourceWidth, sourceHeight, image.width, image.height), offsetX: 0, offsetY: 0 }
bitmap.set({ left: image.x + offsetX, top: image.y + offsetY, originX: 'left', originY: 'top', ...geometry, selectable: false, evented: false })
canvas?.add(bitmap)
}
if (screen.image) await addImage(screen.image)
if (cancelled || !canvas) return
for (const layer of screen.texts) {
stage = 'create-text'
const textbox = new fabric.Textbox(layer.text, {
left: layer.x, top: layer.y, width: layer.width, fontFamily: layer.fontFamily,
originX: 'left', originY: 'top',
fontSize: layer.fontSize, lineHeight: layer.lineHeight, fill: layer.color,
textAlign: layer.align, splitByGrapheme: true, padding: 0, strokeWidth: 0,
lockMovementX: true, lockMovementY: true, lockScalingX: true, lockScalingY: true,
lockRotation: true, hasControls: false, hasBorders: false,
selectable: !disabled, editable: !disabled,
selectionColor: 'rgba(22,119,255,0.18)', editingBorderColor: '#1677ff', cursorColor: layer.color,
})
textbox.on('editing:exited', () => {
if (cancelled) return
const text = textbox.text || ''
if (screenRef.current.texts.find((current) => current.id === layer.id)?.text === text) return
changeRef.current(layer.id, text)
})
textbox.on('changed', () => {
if (!cancelled && screenRef.current.texts.find((current) => current.id === layer.id)?.text !== (textbox.text || '')) {
pendingTextRef.current?.(layer.id, textbox.text || '')
unsavedRef.current?.()
}
})
textbox.on('editing:entered', () => {
// Fabric only stops its cursor on blur; commit before an outside save/link click.
textbox.hiddenTextarea?.addEventListener('blur', () => { if (!cancelled) textbox.exitEditing() }, { once: true })
})
canvas.add(textbox)
}
if (screen.logo) await addImage(screen.logo, true)
if (cancelled || !canvas) return
outsideClick = (event) => {
if (cancelled || !canvas || (event.target instanceof Node && wrapperRef.current?.contains(event.target))) return
for (const object of canvas.getObjects()) {
if (object.type !== 'textbox') continue
const textbox = object as Textbox
if (!textbox.isEditing) continue
const index = canvas.getObjects().filter((item) => item.type === 'textbox').indexOf(textbox)
const layer = screenRef.current.texts[index]
if (layer && layer.text !== (textbox.text || '')) changeRef.current(layer.id, textbox.text || '')
textbox.exitEditing()
}
}
document.addEventListener('pointerdown', outsideClick, true)
document.addEventListener('click', outsideClick, true)
canvas.requestRenderAll()
if (!cancelled) setLoading(false)
}
render().catch((cause: unknown) => {
if (!cancelled) {
const name = cause instanceof Error ? cause.name : 'UnknownError'
const raw = cause instanceof Error ? cause.message : 'Unknown failure'
const message = raw.replace(/https?:\/\/\S+/g, '[resource]').slice(0, 240)
console.error('[DetailCanvas] preview failed', { stage, name, message })
setError('预览加载失败。可重新打开此屏,或使用右侧文案编辑。'); setLoading(false)
}
})
return () => {
cancelled = true
observer?.disconnect()
if (outsideClick) {
document.removeEventListener('pointerdown', outsideClick, true)
document.removeEventListener('click', outsideClick, true)
}
canvasRef.current = null
const disposal = canvas ? canvas.dispose().catch(() => undefined) : Promise.resolve()
disposalRef.current = disposal.then(() => {
ownedCanvas?.remove()
if (ownedContainer && ownedContainer !== initialHost) ownedContainer.remove()
})
}
// Keep local Fabric text edits alive until the template or image actually changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [screen.id, screen.image?.assetId, screen.image?.x, screen.image?.y, screen.image?.width, screen.image?.height, screen.logo?.assetId, screen.logo?.x, screen.logo?.y, screen.logo?.width, screen.logo?.height, screen.width, screen.height, screen.backgroundColor])
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const textboxes = canvas.getObjects().filter((object) => object.type === 'textbox') as Textbox[]
screen.texts.forEach((layer, index) => {
const object = textboxes[index]
if (!object || object.isEditing) return
object.set({ text: layer.text, left: layer.x, top: layer.y, width: layer.width, fill: layer.color, fontSize: layer.fontSize, lineHeight: layer.lineHeight, textAlign: layer.align, selectable: !disabled, editable: !disabled })
})
canvas.requestRenderAll()
}, [screen.texts, disabled, loading])
return <div ref={wrapperRef} className="mx-auto w-full max-w-[750px]" aria-label={`${screen.title || '详情页'}预览`}>
{loading && <p role="status" className="mb-3 text-center text-sm text-[#66758b]">正在载入预览…</p>}
{error && <p role="alert" className="mb-3 text-sm text-[#b42318]">{error}</p>}
<div ref={hostRef} role="img" aria-label={`${screen.title}。文案可通过旁边的编辑框修改。`} className="max-w-full" />
</div>
}