import {
BorderOutlined,
DeleteOutlined,
DownloadOutlined,
EditOutlined,
PictureOutlined,
SelectOutlined,
StopOutlined,
} from '@ant-design/icons'
import {Button, Card, Divider, Space, Tag, Typography, message} from 'antd'
import Konva from 'konva'
import {KonvaEventObject} from 'konva/lib/Node'
import {ChangeEvent, useEffect, useRef, useState} from 'react'
import {Layer, Stage, Transformer} from 'react-konva'
import {RenderElement} from './RenderElement'
import {useWhiteboardStore} from './store'
const {Text} = Typography
const Whiteboard = () => {
const {
elementIds,
tool,
selectedId,
setTool,
addElement,
updateLine,
setSelectedId,
clearBoard,
} = useWhiteboardStore()
const stageRef = useRef<Konva.Stage>(null)
const trRef = useRef<Konva.Transformer>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const isDrawing = useRef(false)
const currentLineId = useRef<string | null>(null)
const [isExporting, setIsExporting] = useState(false)
useEffect(() => {
if (trRef.current && stageRef.current) {
const nodes = selectedId
? [stageRef.current.findOne('#' + selectedId)]
: []
trRef.current.nodes(nodes.filter((n): n is Konva.Node => !!n))
trRef.current.getLayer()?.batchDraw()
}
}, [selectedId])
const handleExport = async () => {
if (!stageRef.current) return
setIsExporting(true)
const hideLoading = message.loading(
'Đang chuẩn bị file và upload lên hệ thống...',
0
)
try {
if (trRef.current) trRef.current.hide()
const stageJson = stageRef.current.toJSON()
if (trRef.current) trRef.current.show()
const response = await fetch('/api/export', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
stageJson,
fileName: `design-${Date.now()}`,
format: 'png',
}),
})
const data = await response.json()
if (data.url) {
message.success('Export successful!')
window.location.href = data.url
} else {
throw new Error(data.error || 'Export failed')
}
} catch (error) {
console.error('Export Error:', error)
message.error('Export error')
} finally {
setIsExporting(false)
hideLoading()
}
}
const handleMouseDown = (e: KonvaEventObject<MouseEvent | TouchEvent>) => {
if (tool !== 'pen') return
const stage = e.target.getStage()
const pos = stage?.getPointerPosition()
if (!pos) return
isDrawing.current = true
const id = `line-${Date.now()}`
currentLineId.current = id
addElement({
id,
type: 'pen',
x: 0,
y: 0,
points: [pos.x, pos.y],
stroke: '#df4b26',
scaleX: 1,
scaleY: 1,
rotation: 0,
})
}
const handleMouseMove = (e: KonvaEventObject<MouseEvent | TouchEvent>) => {
if (!isDrawing.current || tool !== 'pen' || !currentLineId.current) return
const stage = e.target.getStage()
const pos = stage?.getPointerPosition()
if (pos) updateLine(currentLineId.current, pos)
}
const handleMouseUp = () => {
isDrawing.current = false
currentLineId.current = null
}
const addShape = (type: 'rect' | 'circle') => {
const id = `${type}-${Date.now()}`
addElement({
id,
type,
x: 100 + Math.random() * 50,
y: 100 + Math.random() * 50,
width: 100,
height: 100,
fill: '#3b82f6',
scaleX: 1,
scaleY: 1,
})
setSelectedId(id)
}
const handleFileUpload = (e: ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files) return
Array.from(files).forEach((file, index) => {
const reader = new FileReader()
reader.onload = event => {
const id = `image-${Date.now()}-${index}`
addElement({
id,
type: 'image',
src: event.target?.result as string,
x: 50 + index * 30,
y: 50 + index * 30,
scaleX: 0.5,
scaleY: 0.5,
})
}
reader.readAsDataURL(file)
})
e.target.value = ''
}
return (
<div className="flex flex-col items-center min-h-screen bg-slate-100 p-8 font-sans">
<input
type="file"
ref={fileInputRef}
multiple
accept="image/*"
onChange={handleFileUpload}
className="hidden"
/>
<Card className="shadow-2xl rounded-3xl w-full max-w-[900px] bg-white border-none overflow-hidden">
<div className="flex flex-wrap justify-between items-center mb-6 gap-4 p-2">
<h2 className="text-2xl font-black text-slate-800 !m-0 tracking-tight">
Design Studio
</h2>
<Space className="bg-slate-50 p-1.5 rounded-xl border border-slate-200 shadow-sm">
<Button
type={tool === 'select' ? 'primary' : 'text'}
icon={<SelectOutlined />}
onClick={() => setTool('select')}
>
Select
</Button>
<Button
type={tool === 'pen' ? 'primary' : 'text'}
icon={<EditOutlined />}
onClick={() => setTool('pen')}
>
Pen
</Button>
<Divider orientation="vertical" className="h-6 mx-1" />
<Button icon={<BorderOutlined />} onClick={() => addShape('rect')}>
Rect
</Button>
<Button icon={<StopOutlined />} onClick={() => addShape('circle')}>
Circle
</Button>
<Button
icon={<PictureOutlined />}
onClick={() => fileInputRef.current?.click()}
>
Images
</Button>
<Divider orientation="vertical" className="h-6 mx-1" />
<Button
type="primary"
className="bg-blue-600 hover:bg-blue-700"
icon={<DownloadOutlined />}
onClick={handleExport}
loading={isExporting}
>
Export
</Button>
<Button
danger
type="text"
icon={<DeleteOutlined />}
onClick={clearBoard}
/>
</Space>
</div>
<div className="border-2 border-slate-100 rounded-2xl overflow-hidden bg-white shadow-inner flex justify-center">
<Stage
width={800}
height={500}
ref={stageRef}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onTouchStart={handleMouseDown}
onTouchMove={handleMouseMove}
onTouchEnd={handleMouseUp}
onClick={e =>
e.target === e.target.getStage() && setSelectedId(null)
}
className="cursor-crosshair"
>
<Layer>
{elementIds.map(id => (
<RenderElement key={id} id={id} />
))}
<Transformer
ref={trRef}
anchorSize={8}
keepRatio={false}
enabledAnchors={[
'top-left',
'top-center',
'top-right',
'middle-right',
'middle-left',
'bottom-left',
'bottom-center',
'bottom-right',
]}
rotateEnabled={true}
rotateAnchorOffset={30}
borderStroke="#3b82f6"
anchorCornerRadius={2}
padding={5}
ignoreStroke={false}
boundBoxFunc={(oldBox, newBox) => {
if (
Math.abs(newBox.width) < 5 ||
Math.abs(newBox.height) < 5
) {
return oldBox
}
return newBox
}}
/>
</Layer>
</Stage>
</div>
<div className="mt-6 flex justify-between items-center px-2">
<Space>
<Tag
color={tool === 'pen' ? 'orange' : 'blue'}
className="rounded-full px-3"
>
{tool.toUpperCase()} MODE
</Tag>
<Text type="secondary" className="text-xs font-medium">
Objects: {elementIds.length}
</Text>
</Space>
</div>
</Card>
</div>
)
}
export default Whiteboard
Comments
Post a Comment