1"use client"
2
3import { useEffect, useRef, useState, type RefObject } from "react"
4import type { Group as KonvaGroup } from "konva/lib/Group"
5import type { Node as KonvaNode } from "konva/lib/Node"
6import type { Stage as KonvaStage } from "konva/lib/Stage"
7import { Stage, Transformer } from "react-konva"
8
9import { useQrPlate } from "@/features/share/useQrImage"
10import { getLayout, type LayoutId } from "@/lib/layouts"
11import { STICKER_WIDTH_MIN, useBoothStore } from "@/stores/booth"
12import { StripStage, type StripSelection, type StripStageProps } from "./StripStage"
13import { getEffect, getFrame } from "./presets"
14import { resolveTokenColor } from "./tokenColor"
15
16/* Panggung hias. Tiga hal yang diurus di sini dan tidak di StripStage:
17 ukuran tampilan, pilihan node, dan Transformer. Berkas ini hanya boleh
18 dimuat lewat `next/dynamic` (ARCHITECTURE §9). */
19
20const VIEW_WIDTH_MIN = 180
21/* Acuan `customize.html` memakai strip 210 px pada semua lebar (tinggi 630 px).
22 216 px menjaga skala yang sama sambil menahan aksi utama dekat lipatan layar
23 pada 1280 x 900. */
24const VIEW_WIDTH_MAX = 216
25/* Kotak terkecil yang diterima Transformer, dalam satuan ruang strip. */
26const MIN_BOX_STRIP = STICKER_WIDTH_MIN
27const ANCHOR_SIZE = 16
28const ANCHOR_STROKE = 2
29const ANCHOR_RADIUS = 8
30const BORDER_STROKE = 3
31
32interface StripCanvasProps {
33 layoutId: LayoutId
34 stageRef: RefObject<KonvaStage | null>
35 groupRef: RefObject<KonvaGroup | null>
36 /** SH-02: pratinjau Strip Hidup dari halaman Hias. */
37 live?: StripStageProps["live"]
38}
39
40export default function StripCanvas({
41 layoutId,
42 stageRef,
43 groupRef,
44 live,
45}: StripCanvasProps) {
46 const shots = useBoothStore((state) => state.shots)
47 const frameId = useBoothStore((state) => state.frameId)
48 const effectId = useBoothStore((state) => state.effectId)
49 const stickers = useBoothStore((state) => state.stickers)
50 const texts = useBoothStore((state) => state.texts)
51 const showDate = useBoothStore((state) => state.showDate)
52 /* SH-04: kotak QR ikut digambar di kanvas hias supaya pengguna melihat
53 tempatnya sebelum link dibuat. */
54 const qr = useQrPlate(useBoothStore((state) => state.share))
55 const updateSticker = useBoothStore((state) => state.updateSticker)
56 const updateText = useBoothStore((state) => state.updateText)
57 const removeSticker = useBoothStore((state) => state.removeSticker)
58 const removeText = useBoothStore((state) => state.removeText)
59
60 const containerRef = useRef<HTMLDivElement>(null)
61 const [viewWidth, setViewWidth] = useState(0)
62 const [selection, setSelection] = useState<StripSelection | null>(null)
63
64 /* Lebar panggung mengikuti lebar kolom tengah, dijepit 180 sampai 320 px
65 supaya strip tetap terbaca di layar sempit dan tidak memakan tinggi. */
66 useEffect(() => {
67 const element = containerRef.current
68 if (element === null) {
69 return
70 }
71 const observer = new ResizeObserver((entries) => {
72 const width = entries[0]?.contentRect.width ?? 0
73 setViewWidth(Math.min(VIEW_WIDTH_MAX, Math.max(VIEW_WIDTH_MIN, width)))
74 })
75 observer.observe(element)
76 return () => observer.disconnect()
77 }, [])
78
79 /* Pilihan bisa menunjuk item yang sudah dihapus dari daftar "Isi strip":
80 Transformer dilepas saat itu, tanpa perlu membersihkan state. */
81 const selectionLive =
82 selection !== null &&
83 (selection.kind === "sticker"
84 ? stickers.some((item) => item.id === selection.id)
85 : texts.some((item) => item.id === selection.id))
86
87 /* DESIGN-SYSTEM §11: Delete dan Backspace menghapus node terpilih. Tidak
88 aktif saat fokus ada di isian teks. */
89 useEffect(() => {
90 if (selection === null) {
91 return
92 }
93 const { kind, id } = selection
94
95 function onKeyDown(event: KeyboardEvent) {
96 if (event.key !== "Delete" && event.key !== "Backspace") {
97 return
98 }
99 const target = event.target
100 if (
101 target instanceof HTMLElement &&
102 (target.tagName === "INPUT" ||
103 target.tagName === "TEXTAREA" ||
104 target.isContentEditable)
105 ) {
106 return
107 }
108 event.preventDefault()
109 if (kind === "sticker") {
110 removeSticker(id)
111 } else {
112 removeText(id)
113 }
114 setSelection(null)
115 }
116 window.addEventListener("keydown", onKeyDown)
117 return () => window.removeEventListener("keydown", onKeyDown)
118 }, [selection, removeSticker, removeText])
119
120 const layout = getLayout(layoutId)
121 const scale = viewWidth / layout.canvas.width
122
123 function handlePointerDown(event: { target: KonvaNode }) {
124 const target = event.target
125 const id = target.id()
126 const kind = id.startsWith("sticker:")
127 ? "sticker"
128 : id.startsWith("text:")
129 ? "text"
130 : null
131 if (kind !== null) {
132 setSelection({ kind, id: id.slice(id.indexOf(":") + 1), node: target })
133 return
134 }
135 /* Klik pada pegangan atau garis Transformer bukan klik pada latar. */
136 if (
137 target.getClassName() === "Transformer" ||
138 target.getParent()?.getClassName() === "Transformer"
139 ) {
140 return
141 }
142 setSelection(null)
143 }
144
145 return (
146 <div
147 ref={containerRef}
148 className="w-full max-w-[216px] [&>div]:touch-none"
149 >
150 {viewWidth > 0 ? (
151 <Stage
152 ref={stageRef}
153 width={viewWidth}
154 height={layout.canvas.height * scale}
155 onMouseDown={handlePointerDown}
156 onTouchStart={handlePointerDown}
157 >
158 <StripStage
159 layoutId={layoutId}
160 shots={shots}
161 frame={getFrame(frameId)}
162 effect={getEffect(effectId).params}
163 stickers={stickers}
164 texts={texts}
165 showDate={showDate}
166 qr={qr}
167 live={live}
168 scale={scale}
169 groupRef={groupRef}
170 onSelect={setSelection}
171 onChangeSticker={updateSticker}
172 onChangeText={updateText}
173 >
174 {selectionLive && selection !== null ? (
175 <Transformer
176 name="editor-overlay"
177 nodes={[selection.node]}
178 rotateEnabled
179 flipEnabled={false}
180 anchorSize={ANCHOR_SIZE}
181 anchorStrokeWidth={ANCHOR_STROKE}
182 anchorCornerRadius={ANCHOR_RADIUS}
183 borderStrokeWidth={BORDER_STROKE}
184 borderStroke={resolveTokenColor("--color-violet")}
185 anchorStroke={resolveTokenColor("--color-violet")}
186 anchorFill={resolveTokenColor("--color-paper")}
187 boundBoxFunc={(oldBox, nextBox) =>
188 nextBox.width < MIN_BOX_STRIP * scale ? oldBox : nextBox
189 }
190 />
191 ) : null}
192 </StripStage>
193 </Stage>
194 ) : null}
195 </div>
196 )
197}
198