@leviosa-ai/detail-page-editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/components/cardnews/color-input.tsx +380 -0
  2. package/components/detail-page/ai-generate-panel.tsx +763 -0
  3. package/components/detail-page/annotation-canvas.tsx +860 -0
  4. package/components/detail-page/annotation-dialog.tsx +397 -0
  5. package/components/detail-page/bubble-tail-overlay.tsx +209 -0
  6. package/components/detail-page/canvas-context-menu.tsx +234 -0
  7. package/components/detail-page/canvas-observer.tsx +68 -0
  8. package/components/detail-page/canvas-overlay-host.tsx +44 -0
  9. package/components/detail-page/canvas-store-facade.ts +51 -0
  10. package/components/detail-page/chart-inspector.tsx +531 -0
  11. package/components/detail-page/design-reference-picker.tsx +383 -0
  12. package/components/detail-page/detail-page-basic-shapes.tsx +180 -0
  13. package/components/detail-page/detail-page-brand-gifs-panel.tsx +261 -0
  14. package/components/detail-page/detail-page-brand-kit-panel.tsx +77 -0
  15. package/components/detail-page/detail-page-brand-panel-header.tsx +44 -0
  16. package/components/detail-page/detail-page-charts-panel.tsx +104 -0
  17. package/components/detail-page/detail-page-decorations-panel.tsx +125 -0
  18. package/components/detail-page/detail-page-download-dialog.tsx +524 -0
  19. package/components/detail-page/detail-page-editor.tsx +974 -0
  20. package/components/detail-page/detail-page-elements-panel.tsx +117 -0
  21. package/components/detail-page/detail-page-font-picker.tsx +231 -0
  22. package/components/detail-page/detail-page-history-buttons.tsx +87 -0
  23. package/components/detail-page/detail-page-host-context.tsx +465 -0
  24. package/components/detail-page/detail-page-icons-panel.tsx +251 -0
  25. package/components/detail-page/detail-page-konva-json-preview.tsx +474 -0
  26. package/components/detail-page/detail-page-layers-panel.tsx +618 -0
  27. package/components/detail-page/detail-page-my-images-panel.tsx +179 -0
  28. package/components/detail-page/detail-page-my-shapes-panel.tsx +174 -0
  29. package/components/detail-page/detail-page-page-toolbar.tsx +171 -0
  30. package/components/detail-page/detail-page-pages-panel.tsx +299 -0
  31. package/components/detail-page/detail-page-pages-timeline.tsx +79 -0
  32. package/components/detail-page/detail-page-photos-panel.tsx +257 -0
  33. package/components/detail-page/detail-page-properties-panel.tsx +2680 -0
  34. package/components/detail-page/detail-page-qr-panel.tsx +137 -0
  35. package/components/detail-page/detail-page-references-panel.tsx +166 -0
  36. package/components/detail-page/detail-page-selection.ts +44 -0
  37. package/components/detail-page/detail-page-shapes-panel.tsx +16 -0
  38. package/components/detail-page/detail-page-sidebar-sections.tsx +324 -0
  39. package/components/detail-page/detail-page-tables-panel.tsx +106 -0
  40. package/components/detail-page/detail-page-text-panel.tsx +179 -0
  41. package/components/detail-page/detail-page-thumbnail-bus.ts +42 -0
  42. package/components/detail-page/edit-quota-ui.tsx +150 -0
  43. package/components/detail-page/editor-history.ts +56 -0
  44. package/components/detail-page/editor-hotkeys.tsx +165 -0
  45. package/components/detail-page/element-recents-strip.tsx +105 -0
  46. package/components/detail-page/element-rects.ts +82 -0
  47. package/components/detail-page/fill-control.tsx +261 -0
  48. package/components/detail-page/find-replace-panel.tsx +252 -0
  49. package/components/detail-page/generation-progress-fill.tsx +57 -0
  50. package/components/detail-page/gif-animator.tsx +184 -0
  51. package/components/detail-page/gif-effect-picker.tsx +136 -0
  52. package/components/detail-page/group-drill-in.tsx +219 -0
  53. package/components/detail-page/group-prompt-edit-panel.tsx +154 -0
  54. package/components/detail-page/hover-highlight-overlay.tsx +111 -0
  55. package/components/detail-page/hovered-layer.ts +34 -0
  56. package/components/detail-page/inspector-controls.tsx +175 -0
  57. package/components/detail-page/leviosa-canvas-workspace.tsx +313 -0
  58. package/components/detail-page/nested-selection-overlay.tsx +364 -0
  59. package/components/detail-page/panel-search-input.tsx +49 -0
  60. package/components/detail-page/prompt-edit-panel.tsx +146 -0
  61. package/components/detail-page/section-height-handle.tsx +276 -0
  62. package/components/detail-page/section-reauthor-controller.tsx +210 -0
  63. package/components/detail-page/spec-prompt-edit-panel.tsx +178 -0
  64. package/components/detail-page/spec-resize-absorber.tsx +149 -0
  65. package/components/detail-page/svg-prompt-edit-panel.tsx +139 -0
  66. package/components/detail-page/table-canvas-overlay.tsx +707 -0
  67. package/components/detail-page/table-inspector.tsx +586 -0
  68. package/components/ui/button.tsx +64 -0
  69. package/components/ui/command.tsx +175 -0
  70. package/components/ui/dialog.tsx +167 -0
  71. package/components/ui/label.tsx +24 -0
  72. package/components/ui/select.tsx +190 -0
  73. package/components/ui/slider.tsx +63 -0
  74. package/components/ui/switch.tsx +35 -0
  75. package/components/ui/tooltip.tsx +57 -0
  76. package/config/detail-page-bundle-font-tags.json +39 -0
  77. package/config/detail-page-fonts.json +373 -0
  78. package/index.ts +24 -0
  79. package/lib/cardnews/font-loader.ts +306 -0
  80. package/lib/detail-page/animation-sniff.ts +101 -0
  81. package/lib/detail-page/asset-bytes-url.ts +70 -0
  82. package/lib/detail-page/basic-shapes.ts +603 -0
  83. package/lib/detail-page/canvas-menu.ts +182 -0
  84. package/lib/detail-page/chart/defaults.ts +283 -0
  85. package/lib/detail-page/chart/format.ts +66 -0
  86. package/lib/detail-page/chart/normalize.ts +149 -0
  87. package/lib/detail-page/chart/palette.ts +85 -0
  88. package/lib/detail-page/chart/paste.ts +82 -0
  89. package/lib/detail-page/chart/preview.ts +78 -0
  90. package/lib/detail-page/chart/render.ts +188 -0
  91. package/lib/detail-page/chart/renderers/bar-h.ts +103 -0
  92. package/lib/detail-page/chart/renderers/bar-v.ts +213 -0
  93. package/lib/detail-page/chart/renderers/donut.ts +186 -0
  94. package/lib/detail-page/chart/renderers/gauge.ts +96 -0
  95. package/lib/detail-page/chart/renderers/line.ts +143 -0
  96. package/lib/detail-page/chart/renderers/shared.ts +263 -0
  97. package/lib/detail-page/chart/renderers/stack.ts +121 -0
  98. package/lib/detail-page/chart/sync.ts +92 -0
  99. package/lib/detail-page/chart/types.ts +145 -0
  100. package/lib/detail-page/css-color.ts +34 -0
  101. package/lib/detail-page/data-gif-payload.ts +167 -0
  102. package/lib/detail-page/design-reference.ts +244 -0
  103. package/lib/detail-page/distribute.ts +89 -0
  104. package/lib/detail-page/editor-routes.ts +10 -0
  105. package/lib/detail-page/element-recents.ts +167 -0
  106. package/lib/detail-page/find-replace.ts +120 -0
  107. package/lib/detail-page/format-painter.ts +159 -0
  108. package/lib/detail-page/gif-frames.ts +216 -0
  109. package/lib/detail-page/gif-timeline.ts +65 -0
  110. package/lib/detail-page/group-action.ts +62 -0
  111. package/lib/detail-page/hangul-roman.ts +58 -0
  112. package/lib/detail-page/icon-curated.ts +290 -0
  113. package/lib/detail-page/icon-fuzzy.ts +250 -0
  114. package/lib/detail-page/icon-keywords.generated.ts +3766 -0
  115. package/lib/detail-page/icon-keywords.ko.ts +524 -0
  116. package/lib/detail-page/icon-search.ts +257 -0
  117. package/lib/detail-page/icons.ts +67 -0
  118. package/lib/detail-page/image-credit.ts +121 -0
  119. package/lib/detail-page/image-data-uri.ts +29 -0
  120. package/lib/detail-page/insert-image.ts +75 -0
  121. package/lib/detail-page/insert-shape.ts +49 -0
  122. package/lib/detail-page/korean-dictionary.ts +187 -0
  123. package/lib/detail-page/layer-move.ts +257 -0
  124. package/lib/detail-page/new-product-draft-store.ts +105 -0
  125. package/lib/detail-page/new-product-draft.ts +283 -0
  126. package/lib/detail-page/personal-images-refresh.ts +30 -0
  127. package/lib/detail-page/product-source.ts +587 -0
  128. package/lib/detail-page/qr-code.ts +214 -0
  129. package/lib/detail-page/reference-image.ts +103 -0
  130. package/lib/detail-page/reference-upload.ts +181 -0
  131. package/lib/detail-page/replace-page.ts +35 -0
  132. package/lib/detail-page/replace-with-gif.ts +251 -0
  133. package/lib/detail-page/section-height.ts +131 -0
  134. package/lib/detail-page/section-reauthor-bus.ts +61 -0
  135. package/lib/detail-page/shape-to-image.ts +219 -0
  136. package/lib/detail-page/spec-group/clipboard.ts +35 -0
  137. package/lib/detail-page/spec-group/sync.ts +377 -0
  138. package/lib/detail-page/spec-group/text-metrics.ts +48 -0
  139. package/lib/detail-page/stock-photos.ts +88 -0
  140. package/lib/detail-page/svg-colors.ts +74 -0
  141. package/lib/detail-page/table/defaults.ts +344 -0
  142. package/lib/detail-page/table/edit.ts +312 -0
  143. package/lib/detail-page/table/harvest.ts +103 -0
  144. package/lib/detail-page/table/normalize.ts +125 -0
  145. package/lib/detail-page/table/paste.ts +49 -0
  146. package/lib/detail-page/table/preview.ts +66 -0
  147. package/lib/detail-page/table/render.ts +388 -0
  148. package/lib/detail-page/table/sync.ts +107 -0
  149. package/lib/detail-page/table/types.ts +102 -0
  150. package/lib/detail-page/text-gif-layout.ts +245 -0
  151. package/lib/detail-page/text-gif-payload.ts +67 -0
  152. package/lib/detail-page/text-presets.ts +230 -0
  153. package/lib/detail-page/use-fake-progress.ts +67 -0
  154. package/lib/detail-page/z-order.ts +82 -0
  155. package/lib/detail-page-canvas/bubble-path.ts +190 -0
  156. package/lib/detail-page-canvas/canvas-key.ts +22 -0
  157. package/lib/detail-page-canvas/clip-rect.ts +65 -0
  158. package/lib/detail-page-canvas/custom-props-adapter.ts +219 -0
  159. package/lib/detail-page-canvas/editor-fonts.ts +161 -0
  160. package/lib/detail-page-canvas/export/ai.ts +413 -0
  161. package/lib/detail-page-canvas/export/color.ts +35 -0
  162. package/lib/detail-page-canvas/export/document-model.ts +78 -0
  163. package/lib/detail-page-canvas/export/export-files.ts +290 -0
  164. package/lib/detail-page-canvas/export/font-subset.ts +264 -0
  165. package/lib/detail-page-canvas/export/gif-export.ts +310 -0
  166. package/lib/detail-page-canvas/export/gif-plan.ts +95 -0
  167. package/lib/detail-page-canvas/export/mp4-encode.ts +158 -0
  168. package/lib/detail-page-canvas/export/mp4-support.ts +22 -0
  169. package/lib/detail-page-canvas/export/pdf/geometry.ts +309 -0
  170. package/lib/detail-page-canvas/export/pdf/inline-svg.ts +399 -0
  171. package/lib/detail-page-canvas/export/pdf/resources.ts +443 -0
  172. package/lib/detail-page-canvas/export/pdf/text-ops.ts +81 -0
  173. package/lib/detail-page-canvas/export/pdf/writer.ts +109 -0
  174. package/lib/detail-page-canvas/export/psd.ts +354 -0
  175. package/lib/detail-page-canvas/export/raster.ts +255 -0
  176. package/lib/detail-page-canvas/export/svg.ts +479 -0
  177. package/lib/detail-page-canvas/export/text-layout.ts +142 -0
  178. package/lib/detail-page-canvas/font-catalog.ts +177 -0
  179. package/lib/detail-page-canvas/font-tags.ts +47 -0
  180. package/lib/detail-page-canvas/gif-web-fonts.ts +90 -0
  181. package/lib/detail-page-canvas/konva-fallback.ts +477 -0
  182. package/lib/detail-page-canvas/text-background-plan.ts +124 -0
  183. package/lib/detail-page-canvas/text-highlight-bands.ts +127 -0
  184. package/lib/utils.ts +10 -0
  185. package/package.json +68 -0
  186. package/types/commerce.ts +171 -0
  187. package/types/detail-page-api.ts +200 -0
  188. package/types/detail-page-canvas.ts +20 -0
  189. package/types/fontkit.d.ts +42 -0
  190. package/types/gifenc.d.ts +51 -0
@@ -0,0 +1,860 @@
1
+ "use client";
2
+
3
+ import {
4
+ forwardRef,
5
+ useCallback,
6
+ useEffect,
7
+ useImperativeHandle,
8
+ useRef,
9
+ useState,
10
+ } from "react";
11
+ import {
12
+ ArrowUpRight,
13
+ Eraser,
14
+ MousePointer2,
15
+ Pencil,
16
+ Redo2,
17
+ Square,
18
+ Type,
19
+ Undo2,
20
+ } from "lucide-react";
21
+
22
+ /**
23
+ * 그림으로 가리키는 편집 — 이미지·화면 위에 덧그리는 주석 캔버스.
24
+ *
25
+ * 그림은 **어디를**(WHERE), 글은 **무엇을**(WHAT) 이다. 둘 중 하나만으로는 성립하지
26
+ * 않는 요청이 많다("여기만 비워 주세요"). 그래서 이 캔버스가 만드는 것은 그림이 아니라
27
+ * **지시의 절반**이고, 나머지 절반은 프롬프트가 채운다.
28
+ *
29
+ * 좌표는 **원본 자연 픽셀**로 산다. 화면 크기로 저장하면 창을 줄였다 키우는 것만으로
30
+ * 마킹이 어긋난다 — 유저가 가리킨 자리가 곧 계약이라 어긋나면 편집 자체가 틀린다.
31
+ *
32
+ * ``flatten()`` 은 원본 위에 마킹을 합성한 PNG data URI 를 낸다. 그린 것이 없으면
33
+ * ``null`` — 백엔드는 마킹본만으로 원본을 대체하지 않고, 원본이 없는 마킹은 버린다.
34
+ *
35
+ * ⚠️ ``imageUrl`` 은 **오염되지 않는 출처**여야 한다(data URI 또는 동일 출처). 교차
36
+ * 출처 이미지를 캔버스에 그리면 ``toDataURL`` 이 SecurityError 로 터진다. 호출부가
37
+ * 먼저 data URI 로 바꿔서 넘긴다.
38
+ */
39
+
40
+ const COLORS = ["#ff2d2d", "#ffffff", "#111111", "#2563eb"] as const;
41
+
42
+ export type PenAnnotation = {
43
+ id: string;
44
+ type: "pen";
45
+ points: [number, number][];
46
+ color: string;
47
+ };
48
+ type RectAnnotation = {
49
+ id: string;
50
+ type: "rect";
51
+ x: number;
52
+ y: number;
53
+ w: number;
54
+ h: number;
55
+ color: string;
56
+ };
57
+ type ArrowAnnotation = {
58
+ id: string;
59
+ type: "arrow";
60
+ x1: number;
61
+ y1: number;
62
+ x2: number;
63
+ y2: number;
64
+ color: string;
65
+ };
66
+ type TextAnnotation = {
67
+ id: string;
68
+ type: "text";
69
+ x: number;
70
+ y: number;
71
+ text: string;
72
+ size: number;
73
+ color: string;
74
+ };
75
+ export type Annotation =
76
+ | PenAnnotation
77
+ | RectAnnotation
78
+ | ArrowAnnotation
79
+ | TextAnnotation;
80
+
81
+ type Tool = "select" | "pen" | "eraser" | "rect" | "arrow" | "text";
82
+
83
+ type Gesture =
84
+ | { kind: "draw"; start: { x: number; y: number } }
85
+ | {
86
+ kind: "move";
87
+ id: string;
88
+ start: { x: number; y: number };
89
+ snapshot: Annotation[];
90
+ moved: boolean;
91
+ }
92
+ | { kind: "erase"; snapshot: Annotation[]; removed: boolean };
93
+
94
+ export interface AnnotationCanvasHandle {
95
+ /** 원본+마킹 합성 PNG data URI. 그린 것이 없으면 null. */
96
+ flatten(): Promise<string | null>;
97
+ hasAnnotations(): boolean;
98
+ }
99
+
100
+ export interface AnnotationCanvasProps {
101
+ /** 밑그림. data URI 또는 동일 출처 URL(교차 출처면 합성이 SecurityError). */
102
+ imageUrl: string;
103
+ /** 합성 결과의 긴 변 상한(px). 모델 입력 비용과 정밀도의 절충. */
104
+ maxEdge?: number;
105
+ /** 그린 것이 생기거나 사라질 때(제출 버튼 활성화용). */
106
+ onChange?: (hasAnnotations: boolean) => void;
107
+ labels?: Partial<Record<Tool | "color" | "undo" | "redo" | "note", string>>;
108
+ }
109
+
110
+ let seq = 0;
111
+ function uid(): string {
112
+ seq += 1;
113
+ return `ann-${seq}`;
114
+ }
115
+
116
+ function loadImage(src: string): Promise<HTMLImageElement> {
117
+ return new Promise((resolve, reject) => {
118
+ const img = new Image();
119
+ // 동일 출처 프록시를 타는 경우에도 캔버스 오염을 막으려면 명시해야 한다.
120
+ img.crossOrigin = "anonymous";
121
+ img.onload = () => resolve(img);
122
+ img.onerror = () => reject(new Error("이미지를 불러오지 못했습니다."));
123
+ img.src = src;
124
+ });
125
+ }
126
+
127
+ /** 주석 하나를 감싸는 사각형(선택 표시·히트 판정용). */
128
+ export function annotationBox(a: Annotation): {
129
+ x: number;
130
+ y: number;
131
+ w: number;
132
+ h: number;
133
+ } {
134
+ switch (a.type) {
135
+ case "rect":
136
+ return { x: a.x, y: a.y, w: a.w, h: a.h };
137
+ case "text":
138
+ // 폰트 실측 없이 어림잡는다 — 선택 테두리 크기일 뿐 합성에는 안 쓰인다.
139
+ return {
140
+ x: a.x,
141
+ y: a.y - a.size,
142
+ w: a.size * 0.6 * a.text.length,
143
+ h: a.size * 1.2,
144
+ };
145
+ case "arrow": {
146
+ const x = Math.min(a.x1, a.x2);
147
+ const y = Math.min(a.y1, a.y2);
148
+ return { x, y, w: Math.abs(a.x2 - a.x1), h: Math.abs(a.y2 - a.y1) };
149
+ }
150
+ case "pen": {
151
+ const xs = a.points.map((p) => p[0]);
152
+ const ys = a.points.map((p) => p[1]);
153
+ const x = Math.min(...xs);
154
+ const y = Math.min(...ys);
155
+ return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
156
+ }
157
+ }
158
+ }
159
+
160
+ function pointSegmentDistance(
161
+ px: number,
162
+ py: number,
163
+ x1: number,
164
+ y1: number,
165
+ x2: number,
166
+ y2: number,
167
+ ): number {
168
+ const dx = x2 - x1;
169
+ const dy = y2 - y1;
170
+ const len2 = dx * dx + dy * dy;
171
+ const t =
172
+ len2 === 0
173
+ ? 0
174
+ : Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / len2));
175
+ return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
176
+ }
177
+
178
+ export function hitAnnotation(
179
+ a: Annotation,
180
+ p: { x: number; y: number },
181
+ threshold: number,
182
+ ): boolean {
183
+ if (a.type === "pen") {
184
+ for (let i = 0; i < a.points.length - 1; i += 1) {
185
+ const [x1, y1] = a.points[i];
186
+ const [x2, y2] = a.points[i + 1];
187
+ if (pointSegmentDistance(p.x, p.y, x1, y1, x2, y2) < threshold)
188
+ return true;
189
+ }
190
+ return (
191
+ a.points.length === 1 &&
192
+ Math.hypot(p.x - a.points[0][0], p.y - a.points[0][1]) < threshold
193
+ );
194
+ }
195
+ if (a.type === "arrow")
196
+ return pointSegmentDistance(p.x, p.y, a.x1, a.y1, a.x2, a.y2) < threshold;
197
+ const b = annotationBox(a);
198
+ return (
199
+ p.x >= b.x - threshold &&
200
+ p.x <= b.x + b.w + threshold &&
201
+ p.y >= b.y - threshold &&
202
+ p.y <= b.y + b.h + threshold
203
+ );
204
+ }
205
+
206
+ function moveAnnotation(a: Annotation, dx: number, dy: number): Annotation {
207
+ switch (a.type) {
208
+ case "pen":
209
+ return {
210
+ ...a,
211
+ points: a.points.map(([x, y]) => [x + dx, y + dy] as [number, number]),
212
+ };
213
+ case "rect":
214
+ case "text":
215
+ return { ...a, x: a.x + dx, y: a.y + dy };
216
+ case "arrow":
217
+ return {
218
+ ...a,
219
+ x1: a.x1 + dx,
220
+ y1: a.y1 + dy,
221
+ x2: a.x2 + dx,
222
+ y2: a.y2 + dy,
223
+ };
224
+ }
225
+ }
226
+
227
+ /**
228
+ * 펜 획을 지우개 지점 기준으로 자른다. 반경 안의 점은 버리고, 살아남은 구간은 각각
229
+ * 별개의 획이 된다(첫 구간이 원래 id 를 물려받는다).
230
+ *
231
+ * 획을 통째로 지우지 않는 이유: 실제 지우개는 닿은 데만 지운다. 긴 획 하나를 그은 뒤
232
+ * 끝만 다듬으려던 유저가 전부 잃으면 다시 그리게 된다.
233
+ */
234
+ export function erasePen(
235
+ a: PenAnnotation,
236
+ p: { x: number; y: number },
237
+ radius: number,
238
+ ): PenAnnotation[] {
239
+ const runs: [number, number][][] = [];
240
+ let current: [number, number][] = [];
241
+ for (const point of a.points) {
242
+ if (Math.hypot(point[0] - p.x, point[1] - p.y) > radius) {
243
+ current.push(point);
244
+ } else if (current.length) {
245
+ runs.push(current);
246
+ current = [];
247
+ }
248
+ }
249
+ if (current.length) runs.push(current);
250
+ return runs
251
+ .filter((points) => points.length > 1)
252
+ .map((points, i) =>
253
+ i === 0 ? { ...a, points } : { ...a, id: uid(), points },
254
+ );
255
+ }
256
+
257
+ export const AnnotationCanvas = forwardRef<
258
+ AnnotationCanvasHandle,
259
+ AnnotationCanvasProps
260
+ >(function AnnotationCanvas(
261
+ { imageUrl, maxEdge = 2048, onChange, labels },
262
+ ref,
263
+ ) {
264
+ const wrapRef = useRef<HTMLDivElement>(null);
265
+ const imgRef = useRef<HTMLImageElement>(null);
266
+ const svgRef = useRef<SVGSVGElement>(null);
267
+ const gestureRef = useRef<Gesture | null>(null);
268
+ const editorOpenAtDownRef = useRef(false);
269
+
270
+ const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
271
+ const [box, setBox] = useState<{
272
+ left: number;
273
+ top: number;
274
+ width: number;
275
+ height: number;
276
+ } | null>(null);
277
+ const [tool, setTool] = useState<Tool>("pen");
278
+ const [color, setColor] = useState<string>(COLORS[0]);
279
+ const [selectedId, setSelectedId] = useState<string | null>(null);
280
+ const [draft, setDraft] = useState<Annotation | null>(null);
281
+ const [textEditor, setTextEditor] = useState<{
282
+ x: number;
283
+ y: number;
284
+ value: string;
285
+ } | null>(null);
286
+ const [eraserCursor, setEraserCursor] = useState<{
287
+ x: number;
288
+ y: number;
289
+ } | null>(null);
290
+ const [history, setHistory] = useState<{
291
+ past: Annotation[][];
292
+ present: Annotation[];
293
+ future: Annotation[][];
294
+ }>({ past: [], present: [], future: [] });
295
+
296
+ const annotations = history.present;
297
+
298
+ useEffect(() => {
299
+ onChange?.(annotations.length > 0);
300
+ }, [annotations.length, onChange]);
301
+
302
+ const commit = useCallback((next: Annotation[]) => {
303
+ setHistory((h) => ({ past: [...h.past, h.present], present: next, future: [] }));
304
+ }, []);
305
+ const replace = useCallback((next: Annotation[]) => {
306
+ setHistory((h) => ({ ...h, present: next }));
307
+ }, []);
308
+ const pushSnapshot = useCallback((snapshot: Annotation[]) => {
309
+ setHistory((h) => ({ past: [...h.past, snapshot], present: h.present, future: [] }));
310
+ }, []);
311
+ const undo = useCallback(() => {
312
+ setHistory((h) =>
313
+ h.past.length
314
+ ? {
315
+ past: h.past.slice(0, -1),
316
+ present: h.past[h.past.length - 1],
317
+ future: [...h.future, h.present],
318
+ }
319
+ : h,
320
+ );
321
+ setSelectedId(null);
322
+ }, []);
323
+ const redo = useCallback(() => {
324
+ setHistory((h) =>
325
+ h.future.length
326
+ ? {
327
+ past: [...h.past, h.present],
328
+ present: h.future[h.future.length - 1],
329
+ future: h.future.slice(0, -1),
330
+ }
331
+ : h,
332
+ );
333
+ setSelectedId(null);
334
+ }, []);
335
+
336
+ const layout = useCallback(() => {
337
+ const wrap = wrapRef.current;
338
+ const img = imgRef.current;
339
+ if (!wrap || !img || !img.naturalWidth) return;
340
+ const cw = wrap.clientWidth;
341
+ const ch = wrap.clientHeight;
342
+ const scale = Math.min(cw / img.naturalWidth, ch / img.naturalHeight);
343
+ const w = img.naturalWidth * scale;
344
+ const h = img.naturalHeight * scale;
345
+ setNatural({ w: img.naturalWidth, h: img.naturalHeight });
346
+ setBox({ left: (cw - w) / 2, top: (ch - h) / 2, width: w, height: h });
347
+ }, []);
348
+
349
+ useEffect(() => {
350
+ const wrap = wrapRef.current;
351
+ if (!wrap) return;
352
+ const observer = new ResizeObserver(layout);
353
+ observer.observe(wrap);
354
+ return () => observer.disconnect();
355
+ }, [layout]);
356
+
357
+ // Delete / undo / redo — 입력 중에는 무시한다(프롬프트 textarea 가 바로 옆에 있다).
358
+ useEffect(() => {
359
+ function onKey(e: KeyboardEvent) {
360
+ const el = e.target as HTMLElement | null;
361
+ if (
362
+ el &&
363
+ (el.tagName === "INPUT" ||
364
+ el.tagName === "TEXTAREA" ||
365
+ el.isContentEditable)
366
+ )
367
+ return;
368
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z") {
369
+ e.preventDefault();
370
+ if (e.shiftKey) redo();
371
+ else undo();
372
+ } else if ((e.key === "Delete" || e.key === "Backspace") && selectedId) {
373
+ e.preventDefault();
374
+ commit(annotations.filter((a) => a.id !== selectedId));
375
+ setSelectedId(null);
376
+ }
377
+ }
378
+ window.addEventListener("keydown", onKey);
379
+ return () => window.removeEventListener("keydown", onKey);
380
+ }, [annotations, selectedId, commit, undo, redo]);
381
+
382
+ // 굵기·글자 크기는 원본 픽셀 기준으로 잡는다 — 화면 배율이 달라도 결과가 같다.
383
+ const strokeWidth = natural ? Math.max(2, natural.w * 0.005) : 2;
384
+ const fontSize = natural ? Math.max(14, natural.w * 0.035) : 24;
385
+
386
+ function toImage(e: { clientX: number; clientY: number }): {
387
+ x: number;
388
+ y: number;
389
+ } {
390
+ const svg = svgRef.current;
391
+ if (!svg || !natural) return { x: 0, y: 0 };
392
+ const r = svg.getBoundingClientRect();
393
+ const x = ((e.clientX - r.left) / r.width) * natural.w;
394
+ const y = ((e.clientY - r.top) / r.height) * natural.h;
395
+ return {
396
+ x: Math.max(0, Math.min(natural.w, x)),
397
+ y: Math.max(0, Math.min(natural.h, y)),
398
+ };
399
+ }
400
+
401
+ function screenThreshold(): number {
402
+ const svg = svgRef.current;
403
+ if (!svg || !natural) return 12;
404
+ const width = svg.getBoundingClientRect().width || 1;
405
+ return (12 * natural.w) / width;
406
+ }
407
+
408
+ function eraseRadius(): number {
409
+ if (!natural || !box || !box.width) return 20;
410
+ return (20 * natural.w) / box.width;
411
+ }
412
+
413
+ function hitTest(p: { x: number; y: number }): Annotation | null {
414
+ const threshold = screenThreshold();
415
+ for (let i = annotations.length - 1; i >= 0; i -= 1) {
416
+ if (hitAnnotation(annotations[i], p, threshold)) return annotations[i];
417
+ }
418
+ return null;
419
+ }
420
+
421
+ function eraseAt(
422
+ p: { x: number; y: number },
423
+ gesture: Extract<Gesture, { kind: "erase" }>,
424
+ ) {
425
+ if (!natural) return;
426
+ const radius = eraseRadius();
427
+ let changed = false;
428
+ const next: Annotation[] = [];
429
+ for (const a of annotations) {
430
+ if (a.type === "pen") {
431
+ if (hitAnnotation(a, p, radius)) {
432
+ const pieces = erasePen(a, p, radius);
433
+ changed =
434
+ changed ||
435
+ pieces.length !== 1 ||
436
+ pieces[0].points.length !== a.points.length;
437
+ next.push(...pieces);
438
+ } else {
439
+ next.push(a);
440
+ }
441
+ } else if (hitAnnotation(a, p, screenThreshold())) {
442
+ // 도형·화살표·글자는 통째로 지운다 — 테두리 일부만 지우려면 path 로 바꿔야 한다.
443
+ changed = true;
444
+ } else {
445
+ next.push(a);
446
+ }
447
+ }
448
+ if (changed) {
449
+ gesture.removed = true;
450
+ replace(next);
451
+ }
452
+ }
453
+
454
+ function onPointerDown(e: React.PointerEvent<SVGSVGElement>) {
455
+ editorOpenAtDownRef.current = Boolean(textEditor);
456
+ if (!natural || textEditor) return;
457
+ e.currentTarget.setPointerCapture(e.pointerId);
458
+ const p = toImage(e);
459
+ if (tool === "select") {
460
+ const hit = hitTest(p);
461
+ setSelectedId(hit?.id ?? null);
462
+ if (hit) {
463
+ gestureRef.current = {
464
+ kind: "move",
465
+ id: hit.id,
466
+ start: p,
467
+ snapshot: annotations,
468
+ moved: false,
469
+ };
470
+ }
471
+ } else if (tool === "eraser") {
472
+ const gesture: Gesture = { kind: "erase", snapshot: annotations, removed: false };
473
+ gestureRef.current = gesture;
474
+ eraseAt(p, gesture);
475
+ } else if (tool === "pen") {
476
+ setDraft({ id: uid(), type: "pen", points: [[p.x, p.y]], color });
477
+ gestureRef.current = { kind: "draw", start: p };
478
+ } else if (tool === "rect") {
479
+ setDraft({ id: uid(), type: "rect", x: p.x, y: p.y, w: 0, h: 0, color });
480
+ gestureRef.current = { kind: "draw", start: p };
481
+ } else if (tool === "arrow") {
482
+ setDraft({
483
+ id: uid(),
484
+ type: "arrow",
485
+ x1: p.x,
486
+ y1: p.y,
487
+ x2: p.x,
488
+ y2: p.y,
489
+ color,
490
+ });
491
+ gestureRef.current = { kind: "draw", start: p };
492
+ }
493
+ }
494
+
495
+ // 글자 입력칸은 pointerdown 이 아니라 click 에서 연다. 클릭 도중에 mount 된 input 은
496
+ // autoFocus 직후 같은 클릭의 마무리로 blur 돼 곧바로 닫힌다.
497
+ function onCanvasClick(e: React.MouseEvent<SVGSVGElement>) {
498
+ if (tool !== "text" || !natural || textEditor || editorOpenAtDownRef.current)
499
+ return;
500
+ const p = toImage(e);
501
+ setTextEditor({ x: p.x, y: p.y, value: "" });
502
+ }
503
+
504
+ function onPointerMove(e: React.PointerEvent<SVGSVGElement>) {
505
+ if (tool === "eraser" && natural) setEraserCursor(toImage(e));
506
+ const gesture = gestureRef.current;
507
+ if (!gesture || !natural) return;
508
+ const p = toImage(e);
509
+ if (gesture.kind === "draw" && draft) {
510
+ if (draft.type === "pen") {
511
+ setDraft({ ...draft, points: [...draft.points, [p.x, p.y]] });
512
+ } else if (draft.type === "rect") {
513
+ setDraft({
514
+ ...draft,
515
+ x: Math.min(gesture.start.x, p.x),
516
+ y: Math.min(gesture.start.y, p.y),
517
+ w: Math.abs(p.x - gesture.start.x),
518
+ h: Math.abs(p.y - gesture.start.y),
519
+ });
520
+ } else if (draft.type === "arrow") {
521
+ setDraft({ ...draft, x2: p.x, y2: p.y });
522
+ }
523
+ } else if (gesture.kind === "move") {
524
+ const dx = p.x - gesture.start.x;
525
+ const dy = p.y - gesture.start.y;
526
+ gesture.moved = gesture.moved || Math.hypot(dx, dy) > 1;
527
+ replace(
528
+ gesture.snapshot.map((a) =>
529
+ a.id === gesture.id ? moveAnnotation(a, dx, dy) : a,
530
+ ),
531
+ );
532
+ } else if (gesture.kind === "erase") {
533
+ eraseAt(p, gesture);
534
+ }
535
+ }
536
+
537
+ function onPointerUp() {
538
+ const gesture = gestureRef.current;
539
+ gestureRef.current = null;
540
+ if (!gesture) return;
541
+ if (gesture.kind === "draw" && draft) {
542
+ // 클릭만 하고 만 자국은 버린다 — 점 하나가 "여기"로 읽히면 편집이 엉뚱해진다.
543
+ const keep =
544
+ (draft.type === "pen" && draft.points.length > 1) ||
545
+ (draft.type === "rect" && (draft.w > 4 || draft.h > 4)) ||
546
+ (draft.type === "arrow" &&
547
+ Math.hypot(draft.x2 - draft.x1, draft.y2 - draft.y1) > 6);
548
+ if (keep) commit([...annotations, draft]);
549
+ setDraft(null);
550
+ } else if (gesture.kind === "move" && gesture.moved) {
551
+ pushSnapshot(gesture.snapshot);
552
+ } else if (gesture.kind === "erase" && gesture.removed) {
553
+ pushSnapshot(gesture.snapshot);
554
+ }
555
+ }
556
+
557
+ function commitText() {
558
+ if (textEditor && textEditor.value.trim()) {
559
+ commit([
560
+ ...annotations,
561
+ {
562
+ id: uid(),
563
+ type: "text",
564
+ x: textEditor.x,
565
+ y: textEditor.y,
566
+ text: textEditor.value.trim(),
567
+ size: fontSize,
568
+ color,
569
+ },
570
+ ]);
571
+ }
572
+ setTextEditor(null);
573
+ }
574
+
575
+ useImperativeHandle(
576
+ ref,
577
+ () => ({
578
+ hasAnnotations: () => annotations.length > 0,
579
+ flatten: async () => {
580
+ const svg = svgRef.current;
581
+ if (!svg || !natural || annotations.length === 0) return null;
582
+ const base = await loadImage(imageUrl);
583
+ const scale = Math.min(1, maxEdge / Math.max(natural.w, natural.h));
584
+ const canvas = document.createElement("canvas");
585
+ canvas.width = Math.round(natural.w * scale);
586
+ canvas.height = Math.round(natural.h * scale);
587
+ const ctx = canvas.getContext("2d");
588
+ if (!ctx) return null;
589
+ ctx.drawImage(base, 0, 0, canvas.width, canvas.height);
590
+ // UI 표식(선택 테두리·지우개 원)은 지시가 아니다. 남기면 모델이 그것까지 읽는다.
591
+ const clone = svg.cloneNode(true) as SVGSVGElement;
592
+ clone.querySelectorAll("[data-ui]").forEach((node) => node.remove());
593
+ clone.removeAttribute("style");
594
+ clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
595
+ clone.setAttribute("width", String(natural.w));
596
+ clone.setAttribute("height", String(natural.h));
597
+ const markup = new XMLSerializer().serializeToString(clone);
598
+ const overlay = await loadImage(
599
+ `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`,
600
+ );
601
+ ctx.drawImage(overlay, 0, 0, canvas.width, canvas.height);
602
+ return canvas.toDataURL("image/png");
603
+ },
604
+ }),
605
+ [annotations, natural, imageUrl, maxEdge],
606
+ );
607
+
608
+ function renderAnnotation(a: Annotation) {
609
+ switch (a.type) {
610
+ case "pen":
611
+ return (
612
+ <polyline
613
+ key={a.id}
614
+ points={a.points.map((p) => p.join(",")).join(" ")}
615
+ fill="none"
616
+ stroke={a.color}
617
+ strokeWidth={strokeWidth}
618
+ strokeLinecap="round"
619
+ strokeLinejoin="round"
620
+ />
621
+ );
622
+ case "rect":
623
+ return (
624
+ <rect
625
+ key={a.id}
626
+ x={a.x}
627
+ y={a.y}
628
+ width={a.w}
629
+ height={a.h}
630
+ fill="none"
631
+ stroke={a.color}
632
+ strokeWidth={strokeWidth}
633
+ />
634
+ );
635
+ case "arrow":
636
+ return (
637
+ <line
638
+ key={a.id}
639
+ x1={a.x1}
640
+ y1={a.y1}
641
+ x2={a.x2}
642
+ y2={a.y2}
643
+ stroke={a.color}
644
+ strokeWidth={strokeWidth}
645
+ markerEnd={`url(#dp-arrow-${a.color.slice(1)})`}
646
+ />
647
+ );
648
+ case "text":
649
+ return (
650
+ <text
651
+ key={a.id}
652
+ x={a.x}
653
+ y={a.y}
654
+ fill={a.color}
655
+ fontSize={a.size}
656
+ fontFamily="Helvetica, Arial, sans-serif"
657
+ fontWeight={700}
658
+ >
659
+ {a.text}
660
+ </text>
661
+ );
662
+ }
663
+ }
664
+
665
+ const selected = annotations.find((a) => a.id === selectedId) ?? null;
666
+ const selectedBox = selected ? annotationBox(selected) : null;
667
+
668
+ const TOOLS: [Tool, string, React.ReactNode][] = [
669
+ ["pen", labels?.pen ?? "그리기", <Pencil key="p" size={16} />],
670
+ ["rect", labels?.rect ?? "박스", <Square key="r" size={16} />],
671
+ ["arrow", labels?.arrow ?? "화살표", <ArrowUpRight key="a" size={16} />],
672
+ ["text", labels?.text ?? "메모", <Type key="t" size={16} />],
673
+ ["eraser", labels?.eraser ?? "지우개", <Eraser key="e" size={16} />],
674
+ ["select", labels?.select ?? "이동", <MousePointer2 key="s" size={16} />],
675
+ ];
676
+
677
+ return (
678
+ <div
679
+ ref={wrapRef}
680
+ className="relative h-full w-full overflow-hidden bg-neutral-100"
681
+ >
682
+ {/* eslint-disable-next-line @next/next/no-img-element */}
683
+ <img
684
+ ref={imgRef}
685
+ src={imageUrl}
686
+ alt=""
687
+ onLoad={layout}
688
+ draggable={false}
689
+ className="absolute inset-0 h-full w-full select-none object-contain"
690
+ />
691
+ {natural && box ? (
692
+ <svg
693
+ ref={svgRef}
694
+ viewBox={`0 0 ${natural.w} ${natural.h}`}
695
+ onPointerDown={onPointerDown}
696
+ onClick={onCanvasClick}
697
+ onPointerMove={onPointerMove}
698
+ onPointerUp={onPointerUp}
699
+ onPointerCancel={onPointerUp}
700
+ onPointerLeave={() => setEraserCursor(null)}
701
+ style={{
702
+ position: "absolute",
703
+ left: box.left,
704
+ top: box.top,
705
+ width: box.width,
706
+ height: box.height,
707
+ touchAction: "none",
708
+ cursor:
709
+ tool === "select" ? "grab" : tool === "eraser" ? "none" : "crosshair",
710
+ }}
711
+ >
712
+ <defs>
713
+ {COLORS.map((c) => (
714
+ <marker
715
+ key={c}
716
+ id={`dp-arrow-${c.slice(1)}`}
717
+ viewBox="0 0 10 10"
718
+ refX="8"
719
+ refY="5"
720
+ markerWidth="5"
721
+ markerHeight="5"
722
+ orient="auto"
723
+ markerUnits="strokeWidth"
724
+ >
725
+ <path d="M0,0 L10,5 L0,10 z" fill={c} />
726
+ </marker>
727
+ ))}
728
+ </defs>
729
+ {annotations.map(renderAnnotation)}
730
+ {draft ? renderAnnotation(draft) : null}
731
+ {tool === "eraser" && eraserCursor ? (
732
+ <circle
733
+ data-ui="1"
734
+ cx={eraserCursor.x}
735
+ cy={eraserCursor.y}
736
+ r={eraseRadius()}
737
+ fill="rgba(255,255,255,0.3)"
738
+ stroke="#111111"
739
+ strokeWidth={Math.max(1, strokeWidth / 4)}
740
+ pointerEvents="none"
741
+ />
742
+ ) : null}
743
+ {selectedBox ? (
744
+ <rect
745
+ data-ui="1"
746
+ x={selectedBox.x - strokeWidth}
747
+ y={selectedBox.y - strokeWidth}
748
+ width={selectedBox.w + strokeWidth * 2}
749
+ height={selectedBox.h + strokeWidth * 2}
750
+ fill="none"
751
+ stroke="#111111"
752
+ strokeWidth={strokeWidth / 2}
753
+ strokeDasharray={`${strokeWidth * 2} ${strokeWidth * 1.5}`}
754
+ pointerEvents="none"
755
+ />
756
+ ) : null}
757
+ {textEditor ? (
758
+ <foreignObject
759
+ data-ui="1"
760
+ x={textEditor.x}
761
+ y={Math.max(0, textEditor.y - fontSize)}
762
+ width={Math.max(fontSize * 4, natural.w - textEditor.x - strokeWidth)}
763
+ height={fontSize * 1.6}
764
+ >
765
+ <input
766
+ autoFocus
767
+ value={textEditor.value}
768
+ placeholder={labels?.note ?? "메모 입력"}
769
+ onChange={(e) =>
770
+ setTextEditor({ ...textEditor, value: e.target.value })
771
+ }
772
+ onKeyDown={(e) => {
773
+ if (e.key === "Enter") commitText();
774
+ if (e.key === "Escape") setTextEditor(null);
775
+ }}
776
+ onBlur={commitText}
777
+ style={{
778
+ width: "100%",
779
+ fontSize,
780
+ fontFamily: "Helvetica, Arial, sans-serif",
781
+ fontWeight: 700,
782
+ color,
783
+ background: "transparent",
784
+ border: `1px dashed ${color}`,
785
+ outline: "none",
786
+ padding: 0,
787
+ }}
788
+ />
789
+ </foreignObject>
790
+ ) : null}
791
+ </svg>
792
+ ) : null}
793
+
794
+ <div className="pointer-events-none absolute inset-x-0 bottom-3 flex justify-center">
795
+ <div className="pointer-events-auto flex items-center gap-0.5 rounded-xl border border-neutral-200 bg-white/95 px-1.5 py-1 shadow-lg backdrop-blur-sm">
796
+ {TOOLS.map(([id, label, icon]) => (
797
+ <button
798
+ key={id}
799
+ type="button"
800
+ title={label}
801
+ aria-label={label}
802
+ aria-pressed={tool === id}
803
+ onClick={() => setTool(id)}
804
+ className={[
805
+ "flex h-8 w-8 items-center justify-center rounded-lg transition-colors",
806
+ tool === id
807
+ ? "bg-neutral-900 text-white"
808
+ : "text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900",
809
+ ].join(" ")}
810
+ >
811
+ {icon}
812
+ </button>
813
+ ))}
814
+ <span className="mx-1 h-5 w-px bg-neutral-200" />
815
+ {COLORS.map((c) => (
816
+ <button
817
+ key={c}
818
+ type="button"
819
+ aria-label={`${labels?.color ?? "색상"} ${c}`}
820
+ aria-pressed={color === c}
821
+ onClick={() => setColor(c)}
822
+ className="flex h-8 w-6 items-center justify-center"
823
+ >
824
+ <span
825
+ className="h-4 w-4 rounded-full border"
826
+ style={{
827
+ background: c,
828
+ borderColor: color === c ? "#111111" : "#d4d4d4",
829
+ borderWidth: color === c ? 2 : 1,
830
+ }}
831
+ />
832
+ </button>
833
+ ))}
834
+ <span className="mx-1 h-5 w-px bg-neutral-200" />
835
+ <button
836
+ type="button"
837
+ title={labels?.undo ?? "실행 취소"}
838
+ aria-label={labels?.undo ?? "실행 취소"}
839
+ disabled={!history.past.length}
840
+ onClick={undo}
841
+ className="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-900 disabled:cursor-not-allowed disabled:opacity-30"
842
+ >
843
+ <Undo2 size={16} />
844
+ </button>
845
+ <button
846
+ type="button"
847
+ title={labels?.redo ?? "다시 실행"}
848
+ aria-label={labels?.redo ?? "다시 실행"}
849
+ disabled={!history.future.length}
850
+ onClick={redo}
851
+ className="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-900 disabled:cursor-not-allowed disabled:opacity-30"
852
+ >
853
+ <Redo2 size={16} />
854
+ </button>
855
+ </div>
856
+ </div>
857
+ </div>
858
+ );
859
+ });
860
+ AnnotationCanvas.displayName = "AnnotationCanvas";