174 lines
8.8 KiB
TypeScript
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>
|
|
}
|