280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
"use client"
|
|
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
|
import type { Frame, WObject } from '@/lib/wysiwyg/schema'
|
|
import { Canvas } from '@/components/wysiwyg/Canvas'
|
|
import { PropsPanel } from '@/components/wysiwyg/PropsPanel'
|
|
import { LayersPanel } from '@/components/wysiwyg/LayersPanel'
|
|
import { exportFrame } from '@/lib/wysiwyg/export'
|
|
import { buildZip } from '@/lib/exporter/zip'
|
|
import { AssetManager } from '@/lib/assets/assetManager'
|
|
import { buildAssetsMetadata } from '@/lib/exporter/assets.meta'
|
|
import { buildAssetsIndex } from '@/lib/exporter/assets.index'
|
|
import { buildAssetsIntegrityIndex } from '@/lib/exporter/assets.integrity.index'
|
|
|
|
function emptyFrame(): Frame {
|
|
return {
|
|
id: 'f-wysiwyg',
|
|
width: 800,
|
|
height: 500,
|
|
background: '#111111',
|
|
layers: [
|
|
{ id: 'base', name: 'Base', visible: true, locked: false, objects: [] },
|
|
],
|
|
}
|
|
}
|
|
|
|
function download(filename: string, data: Uint8Array) {
|
|
const ab = data.slice().buffer as ArrayBuffer
|
|
const blob = new Blob([ab], { type: 'application/zip' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = filename
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
a.remove()
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
let textCounter = 1
|
|
let imageCounter = 1
|
|
let buttonCounter = 1
|
|
let inputCounter = 1
|
|
let selectCounter = 1
|
|
let textareaCounter = 1
|
|
let radioCounter = 1
|
|
let checkboxCounter = 1
|
|
let layerCounter = 1
|
|
|
|
export function WysiwygBuilderPage() {
|
|
const [frame, setFrame] = useState<Frame>(() => emptyFrame())
|
|
const assetManagerRef = useRef<AssetManager | null>(null)
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
|
const [guidesOn, setGuidesOn] = useState(true)
|
|
const selectedId = selectedIds[selectedIds.length - 1] || null
|
|
const selectedObject: WObject | null = useMemo(() => {
|
|
for (const ly of frame.layers) {
|
|
const o = ly.objects.find((x) => x.id === selectedId)
|
|
if (o) return o
|
|
}
|
|
return null
|
|
}, [frame, selectedId])
|
|
|
|
const onChangeFrame = useCallback((next: Frame) => setFrame(next), [])
|
|
const onSelectIds = useCallback((ids: string[]) => setSelectedIds(ids), [])
|
|
|
|
const addText = useCallback(() => {
|
|
const id = `text-${textCounter++}`
|
|
const obj: WObject = { id, type: 'text', x: 60, y: 60, width: 160, height: 40, rotate: 0, zIndex: 0, props: { text: 'Text', fontSize: 18, color: '#ffffff' } }
|
|
setFrame((prev) => ({
|
|
...prev,
|
|
layers: prev.layers.map((l, i) => i === 0 ? { ...l, objects: [...l.objects, obj] } : l),
|
|
}))
|
|
setSelectedIds([id])
|
|
}, [])
|
|
|
|
const addRadio = useCallback(() => {
|
|
const id = `radio-${radioCounter++}`
|
|
const obj: WObject = { id, type: 'radio', x: 60, y: 240, width: 300, height: 96, rotate: 0, zIndex: 0, props: { name: 'choice', legend: 'Legend', options: ['A','B','C'], defaultValue: 'A', required: false } }
|
|
setFrame((prev) => ({
|
|
...prev,
|
|
layers: prev.layers.map((l, i) => i === 0 ? { ...l, objects: [...l.objects, obj] } : l),
|
|
}))
|
|
setSelectedIds([id])
|
|
}, [])
|
|
|
|
const addCheckbox = useCallback(() => {
|
|
const id = `checkbox-${checkboxCounter++}`
|
|
const obj: WObject = { id, type: 'checkbox', x: 60, y: 360, width: 320, height: 96, rotate: 0, zIndex: 0, props: { name: 'agree', legend: 'Legend', options: ['One','Two','Three'], defaultValues: ['One'], required: false } }
|
|
setFrame((prev) => ({
|
|
...prev,
|
|
layers: prev.layers.map((l, i) => i === 0 ? { ...l, objects: [...l.objects, obj] } : l),
|
|
}))
|
|
setSelectedIds([id])
|
|
}, [])
|
|
|
|
const addTextarea = useCallback(() => {
|
|
const id = `textarea-${textareaCounter++}`
|
|
const obj: WObject = { id, type: 'textarea', x: 60, y: 180, width: 320, height: 96, rotate: 0, zIndex: 0, props: { name: 'message', label: 'Message', placeholder: '', rows: 3, required: false } }
|
|
setFrame((prev) => ({
|
|
...prev,
|
|
layers: prev.layers.map((l, i) => i === 0 ? { ...l, objects: [...l.objects, obj] } : l),
|
|
}))
|
|
setSelectedIds([id])
|
|
}, [])
|
|
|
|
const addSelect = useCallback(() => {
|
|
const id = `select-${selectCounter++}`
|
|
const obj: WObject = { id, type: 'select', x: 60, y: 120, width: 260, height: 48, rotate: 0, zIndex: 0, props: { name: 'field', label: 'Label', options: ['S','M','L'], defaultValue: '' } }
|
|
setFrame((prev) => ({
|
|
...prev,
|
|
layers: prev.layers.map((l, i) => i === 0 ? { ...l, objects: [...l.objects, obj] } : l),
|
|
}))
|
|
setSelectedIds([id])
|
|
}, [])
|
|
|
|
const addLayer = useCallback(() => {
|
|
const lid = `layer-${layerCounter++}`
|
|
const tid = `text-${textCounter++}`
|
|
const layer: Frame['layers'][number] = {
|
|
id: lid,
|
|
name: `Layer ${layerCounter - 1}`,
|
|
visible: true,
|
|
locked: false,
|
|
objects: [
|
|
{ id: tid, type: 'text', x: 60, y: 60, width: 160, height: 40, rotate: 0, zIndex: 0, props: { text: `L${layerCounter - 1}`, fontSize: 18, color: '#ffffff' } },
|
|
],
|
|
}
|
|
setFrame((prev) => ({ ...prev, layers: [...prev.layers, layer] }))
|
|
setSelectedIds([tid])
|
|
}, [])
|
|
|
|
const addImage = useCallback(() => {
|
|
const id = `image-${imageCounter++}`
|
|
const obj: WObject = { id, type: 'image', x: 60, y: 120, width: 120, height: 90, rotate: 0, zIndex: 0, props: { src: 'https://via.placeholder.com/120x90', alt: '' } }
|
|
setFrame((prev) => ({
|
|
...prev,
|
|
layers: prev.layers.map((l, i) => i === 0 ? { ...l, objects: [...l.objects, obj] } : l),
|
|
}))
|
|
setSelectedIds([id])
|
|
}, [])
|
|
|
|
const onUploadImage = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files
|
|
if (!files || files.length === 0) return
|
|
const file = files[0]
|
|
const reader = new FileReader()
|
|
reader.onload = () => {
|
|
const result = reader.result
|
|
if (!(result instanceof ArrayBuffer)) return
|
|
const bytes = new Uint8Array(result)
|
|
const am = assetManagerRef.current ?? new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'], pathStrategy: 'grouped' })
|
|
assetManagerRef.current = am
|
|
am.add({ name: file.name, type: file.type, bytes }).then((ref) => {
|
|
const id = `image-${imageCounter++}`
|
|
const obj: WObject = {
|
|
id,
|
|
type: 'image',
|
|
x: 60,
|
|
y: 120,
|
|
width: 120,
|
|
height: 90,
|
|
rotate: 0,
|
|
zIndex: 0,
|
|
props: { src: `/${ref.path}` as string, alt: file.name },
|
|
}
|
|
setFrame((prev) => ({
|
|
...prev,
|
|
layers: prev.layers.map((l, i) => (i === 0 ? { ...l, objects: [...l.objects, obj] } : l)),
|
|
}))
|
|
setSelectedIds([id])
|
|
}).catch(() => {
|
|
// ignore upload errors in UI for now
|
|
})
|
|
}
|
|
reader.readAsArrayBuffer(file)
|
|
}, [])
|
|
|
|
const addButton = useCallback(() => {
|
|
const id = `button-${buttonCounter++}`
|
|
const obj: WObject = { id, type: 'button', x: 60, y: 240, width: 120, height: 36, rotate: 0, zIndex: 0, props: { label: 'Button', href: '#', color: '#ffffff', background: '#2563eb' } }
|
|
setFrame((prev) => ({
|
|
...prev,
|
|
layers: prev.layers.map((l, i) => i === 0 ? { ...l, objects: [...l.objects, obj] } : l),
|
|
}))
|
|
setSelectedIds([id])
|
|
}, [])
|
|
|
|
const addInput = useCallback(() => {
|
|
const id = `input-${inputCounter++}`
|
|
const obj: WObject = { id, type: 'input', x: 60, y: 60, width: 260, height: 48, rotate: 0, zIndex: 0, props: { name: 'field', label: 'Label', placeholder: '', required: false } }
|
|
setFrame((prev) => ({
|
|
...prev,
|
|
layers: prev.layers.map((l, i) => i === 0 ? { ...l, objects: [...l.objects, obj] } : l),
|
|
}))
|
|
setSelectedIds([id])
|
|
}, [])
|
|
|
|
const onChangeObject = useCallback((next: WObject) => {
|
|
setFrame((prev) => ({
|
|
...prev,
|
|
layers: prev.layers.map((l) => ({
|
|
...l,
|
|
objects: l.objects.map((o) => (o.id === next.id ? (next as WObject) : o)),
|
|
})),
|
|
}))
|
|
}, [])
|
|
|
|
const doExport = useCallback(async () => {
|
|
const out = exportFrame(frame)
|
|
const am = assetManagerRef.current ?? new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png','jpg','jpeg','webp','svg','woff2','txt'], pathStrategy: 'grouped' })
|
|
assetManagerRef.current = am
|
|
const extras: Record<string, Uint8Array> = {
|
|
'assets.json': buildAssetsMetadata(am),
|
|
'assets.index.json': buildAssetsIndex(am),
|
|
'assets.integrity.index.json': buildAssetsIntegrityIndex(am),
|
|
}
|
|
const assets = am.toZipStructure()
|
|
const zip = await buildZip({ html: out.html, css: out.css, js: out.js, assets, extras })
|
|
download('landing.zip', zip)
|
|
}, [frame])
|
|
|
|
return (
|
|
<div className="p-4 grid grid-cols-[260px_1fr_320px] gap-4">
|
|
<div className="space-y-3">
|
|
<div className="flex gap-2 items-center">
|
|
<button type="button" className="bg-gray-200 rounded px-3 py-2" onClick={addText}>Add Text</button>
|
|
<button type="button" className="bg-gray-200 rounded px-3 py-2" onClick={addImage}>Add Image</button>
|
|
<button type="button" className="bg-gray-200 rounded px-3 py-2" onClick={addLayer}>Add Layer</button>
|
|
<input
|
|
type="file"
|
|
accept="image/png,image/jpeg,image/jpg,image/webp,image/svg+xml"
|
|
data-testid="wysiwyg-image-input"
|
|
onChange={onUploadImage}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<button type="button" className="bg-gray-200 rounded px-3 py-2 mr-2" onClick={addInput}>Add Input</button>
|
|
<button type="button" className="bg-gray-200 rounded px-3 py-2 mr-2" onClick={addSelect}>Add Select</button>
|
|
<button type="button" className="bg-gray-200 rounded px-3 py-2 mr-2" onClick={addTextarea}>Add Textarea</button>
|
|
<button type="button" className="bg-gray-200 rounded px-3 py-2 mr-2" onClick={addRadio}>Add Radio</button>
|
|
<button type="button" className="bg-gray-200 rounded px-3 py-2 mr-2" onClick={addCheckbox}>Add Checkbox</button>
|
|
<button type="button" className="bg-gray-200 rounded px-3 py-2" onClick={addButton}>Add Button</button>
|
|
</div>
|
|
<div className="border rounded p-2">
|
|
<LayersPanel frame={frame} onChange={setFrame} />
|
|
</div>
|
|
</div>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-xl font-semibold">WYSIWYG Builder</h1>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
aria-pressed={guidesOn ? 'true' : 'false'}
|
|
className="bg-gray-200 rounded px-3 py-2"
|
|
onClick={() => setGuidesOn((v) => !v)}
|
|
>
|
|
Guides
|
|
</button>
|
|
<button type="button" className="bg-emerald-600 text-white rounded px-4 py-2" onClick={doExport}>Export</button>
|
|
</div>
|
|
</div>
|
|
<div className="border rounded p-2 inline-block" style={{ background: '#111111' }}>
|
|
<Canvas key={`guides-${guidesOn ? 'on' : 'off'}`} frame={frame} onChange={onChangeFrame} onSelectIds={onSelectIds} enableGuides={guidesOn} />
|
|
</div>
|
|
</div>
|
|
<div className="border rounded p-2">
|
|
<h2 className="text-sm font-semibold mb-2">Properties</h2>
|
|
{selectedObject ? (
|
|
<PropsPanel object={selectedObject} onChange={onChangeObject} />
|
|
) : (
|
|
<p className="text-sm text-gray-500">Select an object to edit</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default WysiwygBuilderPage
|