@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,397 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { ImagePlus, Loader2, X } from "lucide-react";
5
+ import { useTranslation } from "react-i18next";
6
+
7
+ import { Button } from "../ui/button";
8
+ import {
9
+ AnnotationCanvas,
10
+ type AnnotationCanvasHandle,
11
+ } from "./annotation-canvas";
12
+ import {
13
+ DESIGN_REFERENCE_ASPECTS,
14
+ MAX_DESIGN_REFERENCES,
15
+ REFERENCE_ACCEPT_ATTR,
16
+ finalizeReferenceDataUri,
17
+ readReferenceFile,
18
+ referenceOrdinal,
19
+ type DesignReference,
20
+ type DesignReferenceAspect,
21
+ } from "../../lib/detail-page/design-reference";
22
+
23
+ /**
24
+ * 그림으로 가리켜 고치는 모달 — 밑그림 위에 표시하고, 무엇을 바꿀지 적는다.
25
+ *
26
+ * 이미지 편집(선택 이미지)과 화면 재저작(섹션 하나)이 같은 모달을 쓴다. 유저가 하는
27
+ * 일이 같기 때문이다: **어디를** 그림으로, **무엇을** 글로.
28
+ *
29
+ * 밑그림은 반드시 **오염되지 않는 출처**로 받는다(``imageUrl`` 이 data URI 이거나
30
+ * 동일 출처). 교차 출처 이미지를 캔버스에 그리면 합성이 SecurityError 로 터지는데,
31
+ * 그 실패는 유저가 다 그리고 제출을 누른 **다음에야** 드러난다.
32
+ *
33
+ * ``maxReferences`` 를 주면 "이렇게 생기게" 참고 사진을 붙일 수 있다. 마킹본과는 **다른
34
+ * 역할**이라 화면에서도 갈라 놓는다: 마킹본은 캔버스(고칠 화면 자체)이고 레퍼런스는 그
35
+ * 아래 줄(남의 화면)이다. 같은 자리에 두면 유저도 모델도 헷갈린다.
36
+ *
37
+ * 참고 사진에는 **번호와 축**이 붙는다. 유저가 실제로 쓰는 문장이 "1번 이미지의 색감과
38
+ * 폰트를 참고하고 2번 이미지의 레이아웃을 참고해 줘"이기 때문이다. 번호를 화면에 적어
39
+ * 두지 않으면 그 문장을 쓸 근거가 없고, 축을 못 고르면 모델이 한 장을 통째로 따라가
40
+ * 남의 상세페이지가 된다.
41
+ */
42
+
43
+ export type AnnotationSubmit = {
44
+ /** 프롬프트(비어 있을 수 있다 — 손글씨로만 지시하는 경우). */
45
+ instruction: string;
46
+ /** 원본+마킹 합성 PNG data URI. 그린 것이 없으면 null. */
47
+ annotatedImage: string | null;
48
+ /** "이렇게 생기게" 참고 사진. 붙이지 않았으면 빈 배열. 순서가 곧 번호다. */
49
+ references: DesignReference[];
50
+ };
51
+
52
+ let referenceSeq = 0;
53
+
54
+ /** 썸네일 열쇠. 같은 파일을 두 번 붙여도 갈라 잡아야 한다. */
55
+ function nextReferenceId(): string {
56
+ referenceSeq += 1;
57
+ return `ref-${referenceSeq}`;
58
+ }
59
+
60
+ export function AnnotationDialog({
61
+ open,
62
+ imageUrl,
63
+ title,
64
+ description,
65
+ placeholder,
66
+ submitLabel,
67
+ maxReferences = 0,
68
+ busy = false,
69
+ error,
70
+ onSubmit,
71
+ onClose,
72
+ }: {
73
+ open: boolean;
74
+ /** 밑그림. data URI 또는 동일 출처 URL. 없으면 모달을 열지 않는다. */
75
+ imageUrl: string | null;
76
+ title: string;
77
+ description?: string;
78
+ placeholder?: string;
79
+ submitLabel: string;
80
+ /** 0 이면 레퍼런스 줄을 아예 띄우지 않는다 — 이미지 편집 쪽은 쓰지 않는 기능이다. */
81
+ maxReferences?: number;
82
+ busy?: boolean;
83
+ error?: string | null;
84
+ onSubmit: (input: AnnotationSubmit) => void;
85
+ onClose: () => void;
86
+ }) {
87
+ const { t } = useTranslation("branding");
88
+ const canvasRef = useRef<AnnotationCanvasHandle>(null);
89
+ const [instruction, setInstruction] = useState("");
90
+ const [hasMarks, setHasMarks] = useState(false);
91
+ const [flattenError, setFlattenError] = useState<string | null>(null);
92
+ // 같은 항목을 나중에 갈아 끼워야 해서 자리(index)가 아니라 열쇠로 잡는다 — 그 사이에
93
+ // 유저가 앞의 것을 빼면 자리는 밀린다.
94
+ const [references, setReferences] = useState<
95
+ Array<{ id: string; uri: string; aspects: DesignReferenceAspect[] }>
96
+ >([]);
97
+ const [attachError, setAttachError] = useState<string | null>(null);
98
+ const fileRef = useRef<HTMLInputElement>(null);
99
+
100
+ useEffect(() => {
101
+ if (!open) {
102
+ setInstruction("");
103
+ setHasMarks(false);
104
+ setFlattenError(null);
105
+ setReferences([]);
106
+ setAttachError(null);
107
+ }
108
+ }, [open]);
109
+
110
+ useEffect(() => {
111
+ if (!open) return;
112
+ const onKey = (e: KeyboardEvent) => {
113
+ if (e.key === "Escape" && !busy) onClose();
114
+ };
115
+ window.addEventListener("keydown", onKey);
116
+ return () => window.removeEventListener("keydown", onKey);
117
+ }, [open, busy, onClose]);
118
+
119
+ const submit = useCallback(async () => {
120
+ if (busy) return;
121
+ setFlattenError(null);
122
+ let annotatedImage: string | null = null;
123
+ try {
124
+ annotatedImage = (await canvasRef.current?.flatten()) ?? null;
125
+ } catch {
126
+ // 합성이 실패하면 마킹 없이 보내지 않는다 — 유저가 가리킨 자리가 통째로 사라진
127
+ // 채로 편집이 돌면 "엉뚱한 데를 고쳤다"가 된다. 차라리 멈추고 말한다.
128
+ setFlattenError(
129
+ t("detailPage.annotate.flattenFailed", {
130
+ defaultValue: "표시한 그림을 합치지 못했어요. 다시 시도해 주세요.",
131
+ }),
132
+ );
133
+ return;
134
+ }
135
+ onSubmit({
136
+ instruction: instruction.trim(),
137
+ annotatedImage,
138
+ references: references.map((ref) => ({ url: ref.uri, aspects: ref.aspects })),
139
+ });
140
+ }, [busy, instruction, onSubmit, references, t]);
141
+
142
+ const attach = useCallback(
143
+ async (files: FileList | null) => {
144
+ if (!files?.length || maxReferences <= 0) return;
145
+ setAttachError(null);
146
+ const room = maxReferences - references.length;
147
+ if (room <= 0) return;
148
+ const picked = Array.from(files).slice(0, room);
149
+ // 같은 파일을 지웠다가 다시 고를 수 있어야 한다 — 값이 남아 있으면 change 가 안 뜬다.
150
+ if (fileRef.current) fileRef.current.value = "";
151
+
152
+ const added: Array<{ id: string; uri: string; aspects: DesignReferenceAspect[] }> =
153
+ [];
154
+ for (const file of picked) {
155
+ try {
156
+ added.push({
157
+ id: nextReferenceId(),
158
+ uri: await readReferenceFile(file),
159
+ aspects: [],
160
+ });
161
+ } catch (err) {
162
+ // 형식·원본 크기는 **읽기 전에** 걸러 이 자리에서 말한다. 제출 뒤에 422 로
163
+ // 돌아오면 유저는 그림을 고르고 축을 고르고 지시를 다 적은 다음에야 안다.
164
+ setAttachError(
165
+ err instanceof Error ? err.message : "참고 사진을 붙이지 못했어요.",
166
+ );
167
+ }
168
+ }
169
+ if (!added.length) return;
170
+ // 원본으로 **먼저** 띄운다. 줄이기는 디코드를 기다려야 하는데, 그 사이 아무것도
171
+ // 안 뜨면 유저는 첨부가 먹히지 않은 줄 안다.
172
+ setReferences((prev) => [...prev, ...added].slice(0, maxReferences));
173
+
174
+ for (const item of added) {
175
+ void finalizeReferenceDataUri(item.uri).then((result) => {
176
+ if ("error" in result) {
177
+ // 줄이기로도 상한 아래로 못 내려간 그림은 붙여 둘 수 없다 — 그대로 두면
178
+ // 제출이 서버에서 막힌다.
179
+ setReferences((prev) => prev.filter((ref) => ref.id !== item.id));
180
+ setAttachError(result.error);
181
+ return;
182
+ }
183
+ if (result.uri === item.uri) return;
184
+ setReferences((prev) =>
185
+ prev.map((ref) => (ref.id === item.id ? { ...ref, uri: result.uri } : ref)),
186
+ );
187
+ });
188
+ }
189
+ },
190
+ [maxReferences, references.length],
191
+ );
192
+
193
+ const toggleAspect = useCallback((id: string, aspect: DesignReferenceAspect) => {
194
+ setReferences((prev) =>
195
+ prev.map((ref) =>
196
+ ref.id === id
197
+ ? {
198
+ ...ref,
199
+ aspects: ref.aspects.includes(aspect)
200
+ ? ref.aspects.filter((a) => a !== aspect)
201
+ : [...ref.aspects, aspect],
202
+ }
203
+ : ref,
204
+ ),
205
+ );
206
+ }, []);
207
+
208
+ if (!open || !imageUrl) return null;
209
+
210
+ const canSubmit = !busy && (hasMarks || instruction.trim().length > 0);
211
+
212
+ return (
213
+ <div className="fixed inset-0 z-[100] flex items-center justify-center bg-neutral-900/60 p-4">
214
+ <div
215
+ role="dialog"
216
+ aria-modal="true"
217
+ aria-label={title}
218
+ className="flex h-full max-h-[92vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl"
219
+ >
220
+ <div className="flex items-start justify-between gap-4 border-b border-neutral-200 px-5 py-3">
221
+ <div className="min-w-0">
222
+ <h2 className="text-sm font-semibold text-neutral-900">{title}</h2>
223
+ {description ? (
224
+ <p className="mt-0.5 text-xs text-neutral-500">{description}</p>
225
+ ) : null}
226
+ </div>
227
+ <button
228
+ type="button"
229
+ aria-label={t("detailPage.annotate.close", { defaultValue: "닫기" })}
230
+ onClick={onClose}
231
+ disabled={busy}
232
+ className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-900 disabled:opacity-40"
233
+ >
234
+ <X size={16} />
235
+ </button>
236
+ </div>
237
+
238
+ <div className="min-h-0 flex-1">
239
+ <AnnotationCanvas
240
+ ref={canvasRef}
241
+ imageUrl={imageUrl}
242
+ onChange={setHasMarks}
243
+ labels={{
244
+ pen: t("detailPage.annotate.tools.pen", { defaultValue: "그리기" }),
245
+ rect: t("detailPage.annotate.tools.rect", { defaultValue: "박스" }),
246
+ arrow: t("detailPage.annotate.tools.arrow", { defaultValue: "화살표" }),
247
+ text: t("detailPage.annotate.tools.text", { defaultValue: "메모" }),
248
+ eraser: t("detailPage.annotate.tools.eraser", { defaultValue: "지우개" }),
249
+ select: t("detailPage.annotate.tools.select", { defaultValue: "이동" }),
250
+ color: t("detailPage.annotate.tools.color", { defaultValue: "색상" }),
251
+ undo: t("detailPage.annotate.tools.undo", { defaultValue: "실행 취소" }),
252
+ redo: t("detailPage.annotate.tools.redo", { defaultValue: "다시 실행" }),
253
+ note: t("detailPage.annotate.tools.note", { defaultValue: "메모 입력" }),
254
+ }}
255
+ />
256
+ </div>
257
+
258
+ <div className="border-t border-neutral-200 px-5 py-3">
259
+ <textarea
260
+ value={instruction}
261
+ onChange={(e) => setInstruction(e.target.value)}
262
+ rows={2}
263
+ disabled={busy}
264
+ placeholder={
265
+ placeholder ??
266
+ t("detailPage.annotate.placeholder", {
267
+ defaultValue:
268
+ "표시한 곳을 어떻게 바꿀지 적어주세요. 그림 위에 손글씨로 적어도 돼요.",
269
+ })
270
+ }
271
+ className="w-full resize-none rounded-lg border border-neutral-200 px-3 py-2 text-sm text-neutral-900 outline-none placeholder:text-neutral-400 focus:border-neutral-400 disabled:bg-neutral-50"
272
+ />
273
+ {maxReferences > 0 ? (
274
+ <div className="mt-2 flex flex-wrap items-start gap-2">
275
+ {references.map((ref, index) => (
276
+ <div
277
+ key={ref.id}
278
+ className="flex items-start gap-2 rounded-lg border border-neutral-200 p-1.5"
279
+ >
280
+ <div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-md bg-neutral-100">
281
+ {/* 붙인 사진은 data URI라 next/image 로 최적화할 것이 없다. */}
282
+ {/* eslint-disable-next-line @next/next/no-img-element */}
283
+ <img
284
+ src={ref.uri}
285
+ alt={t("detailPage.reauthor.referenceAlt", {
286
+ defaultValue: "참고 사진",
287
+ })}
288
+ className="h-full w-full object-cover"
289
+ />
290
+ {/* 번호는 프롬프트에 쓰는 이름이다 — 화면에 없으면 "2번 이미지"라고
291
+ 적을 근거가 없다. */}
292
+ <span className="absolute left-0.5 top-0.5 rounded bg-neutral-900/75 px-1 text-[10px] font-semibold leading-4 text-white">
293
+ {referenceOrdinal(index)}
294
+ </span>
295
+ <button
296
+ type="button"
297
+ disabled={busy}
298
+ onClick={() =>
299
+ setReferences((prev) => prev.filter((_, i) => i !== index))
300
+ }
301
+ aria-label={t("detailPage.reauthor.referenceRemove", {
302
+ defaultValue: "참고 사진 빼기",
303
+ })}
304
+ className="absolute right-0.5 top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-neutral-900/70 text-white disabled:opacity-40"
305
+ >
306
+ <X size={10} />
307
+ </button>
308
+ </div>
309
+ <div className="flex max-w-[15rem] flex-wrap gap-1">
310
+ {DESIGN_REFERENCE_ASPECTS.map((aspect) => {
311
+ const on = ref.aspects.includes(aspect.key);
312
+ return (
313
+ <button
314
+ key={aspect.key}
315
+ type="button"
316
+ disabled={busy}
317
+ aria-pressed={on}
318
+ // 축 이름만으로는 "내용 구성"이 문구까지인지 알 수 없다.
319
+ title={aspect.hint}
320
+ onClick={() => toggleAspect(ref.id, aspect.key)}
321
+ className={`rounded-full border px-2 py-0.5 text-[11px] transition-colors disabled:opacity-40 ${
322
+ on
323
+ ? "border-neutral-900 bg-neutral-900 text-white"
324
+ : "border-neutral-200 text-neutral-500 hover:border-neutral-400 hover:text-neutral-700"
325
+ }`}
326
+ >
327
+ {aspect.label}
328
+ </button>
329
+ );
330
+ })}
331
+ </div>
332
+ </div>
333
+ ))}
334
+ {references.length < maxReferences ? (
335
+ <button
336
+ type="button"
337
+ disabled={busy}
338
+ onClick={() => fileRef.current?.click()}
339
+ className="flex h-[4.75rem] items-center gap-1.5 rounded-lg border border-dashed border-neutral-300 px-3 text-xs text-neutral-500 transition-colors hover:border-neutral-400 hover:text-neutral-700 disabled:opacity-40"
340
+ >
341
+ <ImagePlus size={14} />
342
+ {t("detailPage.reauthor.referenceAdd", {
343
+ defaultValue: "이렇게 생기게 (참고 사진)",
344
+ })}
345
+ </button>
346
+ ) : null}
347
+ <input
348
+ ref={fileRef}
349
+ type="file"
350
+ accept={REFERENCE_ACCEPT_ATTR}
351
+ multiple
352
+ hidden
353
+ onChange={(e) => void attach(e.target.files)}
354
+ />
355
+ {references.length ? (
356
+ <p className="w-full text-[11px] text-neutral-400">
357
+ {t("detailPage.reauthor.referenceHint", {
358
+ defaultValue:
359
+ "축을 고르면 그 장에서는 고른 것만 따라가요(안 고르면 배치와 구성만). " +
360
+ '지시에 "1번 이미지의 색감처럼"이라고 적어도 돼요. 참고 사진 자체는 ' +
361
+ "페이지에 들어가지 않아요.",
362
+ })}
363
+ </p>
364
+ ) : null}
365
+ </div>
366
+ ) : null}
367
+ {error || flattenError || attachError ? (
368
+ <p className="mt-2 text-xs text-red-600">
369
+ {error ?? flattenError ?? attachError}
370
+ </p>
371
+ ) : null}
372
+ <div className="mt-3 flex items-center justify-between gap-3">
373
+ <p className="text-xs text-neutral-400">
374
+ {hasMarks
375
+ ? t("detailPage.annotate.marked", {
376
+ defaultValue: "표시한 자리를 기준으로 고쳐요.",
377
+ })
378
+ : t("detailPage.annotate.unmarked", {
379
+ defaultValue: "고칠 자리를 그림으로 표시하면 더 정확해요.",
380
+ })}
381
+ </p>
382
+ <Button type="button" onClick={submit} disabled={!canSubmit}>
383
+ {busy ? (
384
+ <>
385
+ <Loader2 size={14} className="mr-1.5 animate-spin" />
386
+ {t("detailPage.annotate.working", { defaultValue: "고치는 중…" })}
387
+ </>
388
+ ) : (
389
+ submitLabel
390
+ )}
391
+ </Button>
392
+ </div>
393
+ </div>
394
+ </div>
395
+ </div>
396
+ );
397
+ }
@@ -0,0 +1,209 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type RefObject } from "react";
4
+ import { observer } from "./canvas-observer";
5
+ import { useTranslation } from "react-i18next";
6
+
7
+ import {
8
+ selectedElementsDeep,
9
+ type SelectableElement,
10
+ } from "./detail-page-selection";
11
+ import { elementClientRect, type RectElement } from "./element-rects";
12
+ import {
13
+ bubbleSvgDataUrl,
14
+ readBubbleParams,
15
+ type BubbleParams,
16
+ type Pt,
17
+ } from "../../lib/detail-page-canvas/bubble-path";
18
+
19
+ /**
20
+ * 말풍선 꼬리 끝점을 드래그하는 핸들 — Canva/미리캔버스식.
21
+ *
22
+ * 말풍선은 몸통+꼬리가 이어진 하나의 닫힌 path이고(``bubble-path.ts``), 그 생성
23
+ * 파라미터가 ``custom.bubble``에 실려 온다(디컴포저가 ``data-bubble``에서 옮겨 담는다).
24
+ * 그래서 편집기는 말풍선을 굳은 그림이 아니라 **다시 구울 수 있는 도형**으로 다룬다:
25
+ * 끝점을 옮기고 path를 재생성해 ``src``에 다시 넣으면 된다.
26
+ *
27
+ * 꼬리는 각도가 아니라 끝점으로 잡히므로 360도 어디로 끌어도 몸통에 붙어 있고,
28
+ * 모서리에 걸치면 두 변에 걸친 부리가 된다. 끝점을 몸통 안으로 끌면 꼬리가 사라진다.
29
+ *
30
+ * ``NestedSelectionOverlay``와 같은 좌표계 규약을 쓴다 — Konva 노드에서 잰 rect를
31
+ * 컨테이너 기준으로 옮기므로 줌·스크롤·스택 워크스페이스가 그대로 동작한다.
32
+ */
33
+
34
+ const HANDLE = 12;
35
+ const ACCENT = "#2F6FEB";
36
+
37
+ // SelectableElement(선택 모델)과 RectElement(측정 모델)의 교집합 — 두 헬퍼를 다 쓴다.
38
+ type ElementLike = SelectableElement & RectElement;
39
+
40
+ type StoreLike = {
41
+ selectedElements?: SelectableElement[];
42
+ selectedElementsIds?: string[];
43
+ getElementById?: (id: string) => SelectableElement | undefined;
44
+ scale?: number;
45
+ history?: { startTransaction?: () => void; endTransaction?: () => void };
46
+ };
47
+
48
+ /** 몸통 로컬 좌표 → SVG 박스 안의 0..1 비율. 리사이즈돼도 비율은 그대로다. */
49
+ function tipFraction(p: BubbleParams, tip: Pt): { fx: number; fy: number } {
50
+ return { fx: (tip[0] + p.pad) / (p.w + 2 * p.pad), fy: (tip[1] + p.pad) / (p.h + 2 * p.pad) };
51
+ }
52
+
53
+ /** 0..1 비율 → 몸통 로컬 좌표. 꼬리가 잘리지 않도록 pad 안으로 가둔다. */
54
+ function fractionToTip(p: BubbleParams, fx: number, fy: number): Pt {
55
+ const bw = p.w + 2 * p.pad;
56
+ const bh = p.h + 2 * p.pad;
57
+ const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
58
+ return [
59
+ clamp(fx * bw - p.pad, -p.pad, p.w + p.pad),
60
+ clamp(fy * bh - p.pad, -p.pad, p.h + p.pad),
61
+ ];
62
+ }
63
+
64
+ /** 파라미터를 갱신하고 path를 다시 구워 요소에 반영한다. */
65
+ export function applyBubble(el: ElementLike, next: BubbleParams): void {
66
+ const custom = (el.custom && typeof el.custom === "object" ? el.custom : {}) as Record<string, unknown>;
67
+ el.set({ src: bubbleSvgDataUrl(next), custom: { ...custom, bubble: next } });
68
+ }
69
+
70
+ /**
71
+ * 말풍선을 리사이즈하면 몸통 크기를 파라미터에 되먹인다.
72
+ *
73
+ * 이게 없으면 스톡 편집기가 svg를 통째로 스케일해 모서리 라운드와 테두리 굵기까지 늘어난다
74
+ * (가로로만 늘리면 라운드가 타원이 된다). w/h를 다시 못박고 path를 재생성하면
75
+ * 라운드·스트로크는 원래 굵기를 유지한 채 몸통만 커진다.
76
+ */
77
+ export function syncBubbleToBox(el: ElementLike): boolean {
78
+ const p = readBubbleParams(el.custom);
79
+ if (!p) return false;
80
+ const boxW = Number(el.width);
81
+ const boxH = Number(el.height);
82
+ if (!Number.isFinite(boxW) || !Number.isFinite(boxH)) return false;
83
+ const w = boxW - 2 * p.pad;
84
+ const h = boxH - 2 * p.pad;
85
+ if (w <= 0 || h <= 0) return false;
86
+ if (Math.abs(w - p.w) < 0.5 && Math.abs(h - p.h) < 0.5) return false;
87
+
88
+ // 꼬리 끝점은 몸통에 대한 비율을 유지한다 — 늘려도 꼬리가 제자리에 남는다.
89
+ const tip: Pt | null = p.tip ? [(p.tip[0] / p.w) * w, (p.tip[1] / p.h) * h] : null;
90
+ applyBubble(el, { ...p, w, h, tip });
91
+ return true;
92
+ }
93
+
94
+ export const BubbleTailOverlay = observer(function BubbleTailOverlay({
95
+ store,
96
+ containerRef,
97
+ }: {
98
+ store: unknown;
99
+ containerRef: RefObject<HTMLElement | null>;
100
+ }) {
101
+ const { t } = useTranslation("branding");
102
+ const s = store as StoreLike;
103
+ const [handles, setHandles] = useState<Array<{ id: string; left: number; top: number }>>([]);
104
+ const drag = useRef<{ pointerId: number; el: ElementLike; p: BubbleParams } | null>(null);
105
+
106
+ const selected = selectedElementsDeep(s) as ElementLike[];
107
+ // mobx가 위치/크기 변화를 감지하도록 렌더 중에 읽는다.
108
+ const stamp = selected.map((e) => `${e.id}:${e.x},${e.y},${e.width},${e.height}`).join("|");
109
+ const scale = s.scale ?? 1;
110
+
111
+ // selectedElementsDeep은 매 렌더 '새 배열'을 만든다. 그 배열을 의존성에 두면 이펙트가
112
+ // 매 렌더 돌고, setHandles가 또 새 배열을 넣어 리렌더를 부른다 — 무한 루프
113
+ // ("Maximum update depth exceeded"). 그래서 의존성은 내용을 요약한 stamp로만 걸고,
114
+ // 배열 자체는 ref로 읽는다.
115
+ const selectedRef = useRef<ElementLike[]>(selected);
116
+ selectedRef.current = selected;
117
+ // 계산 결과가 같으면 상태를 아예 건드리지 않는다 — 두 번째 안전장치.
118
+ const keyRef = useRef<string | null>(null);
119
+
120
+ useEffect(() => {
121
+ const host = containerRef.current;
122
+ const next: Array<{ id: string; left: number; top: number }> = [];
123
+ if (host) {
124
+ const box = host.getBoundingClientRect();
125
+ for (const el of selectedRef.current) {
126
+ // 리사이즈는 선택된 상태에서만 일어나므로 여기서 되먹이면 충분하다.
127
+ // 멱등하다(크기가 그대로면 no-op) → 루프를 돌지 않는다.
128
+ syncBubbleToBox(el);
129
+ const p = readBubbleParams(el.custom);
130
+ if (!p?.tip) continue;
131
+ const r = elementClientRect(el);
132
+ if (!r) continue;
133
+ const { fx, fy } = tipFraction(p, p.tip);
134
+ next.push({
135
+ id: el.id,
136
+ left: r.left - box.left + fx * (r.right - r.left),
137
+ top: r.top - box.top + fy * (r.bottom - r.top),
138
+ });
139
+ }
140
+ }
141
+ const key = next.map((h) => `${h.id}:${h.left.toFixed(2)},${h.top.toFixed(2)}`).join("|");
142
+ if (key === keyRef.current) return;
143
+ keyRef.current = key;
144
+ setHandles(next);
145
+ }, [stamp, scale, containerRef]);
146
+
147
+ const begin = (e: ReactPointerEvent<HTMLElement>, id: string) => {
148
+ const el = s.getElementById?.(id) as ElementLike | undefined;
149
+ const p = el && readBubbleParams(el.custom);
150
+ if (!el || !p) return;
151
+ e.preventDefault();
152
+ e.stopPropagation();
153
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
154
+ drag.current = { pointerId: e.pointerId, el, p };
155
+ s.history?.startTransaction?.();
156
+ };
157
+
158
+ const move = (e: ReactPointerEvent<HTMLElement>) => {
159
+ const d = drag.current;
160
+ if (!d || e.pointerId !== d.pointerId) return;
161
+ const r = elementClientRect(d.el);
162
+ if (!r) return;
163
+ const w = r.right - r.left;
164
+ const h = r.bottom - r.top;
165
+ if (!w || !h) return;
166
+ const fx = (e.clientX - r.left) / w;
167
+ const fy = (e.clientY - r.top) / h;
168
+ applyBubble(d.el, { ...d.p, tip: fractionToTip(d.p, fx, fy) });
169
+ };
170
+
171
+ const end = (e: ReactPointerEvent<HTMLElement>) => {
172
+ const d = drag.current;
173
+ if (!d || e.pointerId !== d.pointerId) return;
174
+ drag.current = null;
175
+ s.history?.endTransaction?.();
176
+ };
177
+
178
+ if (!handles.length) return null;
179
+
180
+ return (
181
+ <div style={{ position: "absolute", inset: 0, pointerEvents: "none", zIndex: 21 }}>
182
+ {handles.map((h) => (
183
+ <span
184
+ key={h.id}
185
+ title={t("detailPage.bubbleTail.handle")}
186
+ onPointerDown={(e) => begin(e, h.id)}
187
+ onPointerMove={move}
188
+ onPointerUp={end}
189
+ onPointerCancel={end}
190
+ style={{
191
+ position: "absolute",
192
+ left: h.left - HANDLE / 2,
193
+ top: h.top - HANDLE / 2,
194
+ width: HANDLE,
195
+ height: HANDLE,
196
+ borderRadius: "50%",
197
+ background: "#fff",
198
+ border: `2px solid ${ACCENT}`,
199
+ boxSizing: "border-box",
200
+ pointerEvents: "auto",
201
+ cursor: "grab",
202
+ touchAction: "none",
203
+ }}
204
+ />
205
+ ))}
206
+ </div>
207
+ );
208
+ });
209
+ BubbleTailOverlay.displayName = "BubbleTailOverlay";