@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,2680 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { observer } from "./canvas-observer";
5
+ import { useTranslation } from "react-i18next";
6
+ import {
7
+ AlignLeft,
8
+ AlignCenter,
9
+ AlignRight,
10
+ AlignJustify,
11
+ BarChart3,
12
+ AlignStartVertical,
13
+ AlignCenterVertical,
14
+ AlignEndVertical,
15
+ AlignStartHorizontal,
16
+ AlignCenterHorizontal,
17
+ AlignEndHorizontal,
18
+ AlignHorizontalDistributeCenter,
19
+ AlignVerticalDistributeCenter,
20
+ Italic,
21
+ Underline,
22
+ Strikethrough,
23
+ Highlighter,
24
+ Sparkles,
25
+ Trash2,
26
+ Ungroup,
27
+ Type as TypeIcon,
28
+ Image as ImageIcon,
29
+ Film,
30
+ Eraser,
31
+ Square,
32
+ Table as TableIcon,
33
+ Shapes,
34
+ Layers,
35
+ ChevronsUp,
36
+ ChevronUp,
37
+ ChevronDown,
38
+ ChevronsDown,
39
+ } from "lucide-react";
40
+
41
+ import { ColorInput } from "../cardnews/color-input";
42
+ import { FillControl } from "./fill-control";
43
+ import { ChartInspector } from "./chart-inspector";
44
+ import { TableInspector } from "./table-inspector";
45
+ import {
46
+ readChartSpec,
47
+ type ElementLike as ChartElementLike,
48
+ type StoreLike as ChartStoreLike,
49
+ } from "../../lib/detail-page/chart/sync";
50
+ import {
51
+ harvestTableGroup,
52
+ readTableSpec,
53
+ type ElementLike as TableElementLike,
54
+ } from "../../lib/detail-page/table/sync";
55
+ import {
56
+ NumberField,
57
+ Section,
58
+ ToggleButton,
59
+ } from "./inspector-controls";
60
+ import {
61
+ MAX_SECTION_HEIGHT,
62
+ MIN_SECTION_HEIGHT,
63
+ applySectionHeight,
64
+ sectionContentBottom,
65
+ } from "../../lib/detail-page/section-height";
66
+ import {
67
+ effectiveColor,
68
+ extractSvgColors,
69
+ } from "../../lib/detail-page/svg-colors";
70
+ import { readColorReplace } from "@leviosa-ai/canvas/render/svg-source";
71
+ import { selectedElementsDeep } from "./detail-page-selection";
72
+ import { PromptEditPanel } from "./prompt-edit-panel";
73
+ import { SvgPromptEditPanel } from "./svg-prompt-edit-panel";
74
+ import { GroupPromptEditPanel } from "./group-prompt-edit-panel";
75
+ import {
76
+ useDetailPageEditUsage,
77
+ type EditUsageState,
78
+ } from "./edit-quota-ui";
79
+ import {
80
+ decodeSvgDataUri,
81
+ encodeSvgDataUri,
82
+ } from "../../lib/detail-page-canvas/export/svg";
83
+ import {
84
+ AiGeneratePanel,
85
+ type GenerateImageFn,
86
+ type GenerateGifFn,
87
+ type GenerateTextGifFn,
88
+ type GenerateImageGifFn,
89
+ type GenerateDataGifFn,
90
+ type RemoveBackgroundFn,
91
+ } from "./ai-generate-panel";
92
+ import { toHexColor } from "../../lib/detail-page/css-color";
93
+ import { setZ as setElementZ, zOrderOf } from "../../lib/detail-page/z-order";
94
+ import {
95
+ canDistribute,
96
+ distributeCoords,
97
+ toItems,
98
+ type DistributeElement,
99
+ } from "../../lib/detail-page/distribute";
100
+ import {
101
+ parseCountUpText,
102
+ parseFilledRows,
103
+ textAnchorOf,
104
+ } from "../../lib/detail-page/data-gif-payload";
105
+ import { insertPersonalImage } from "../../lib/detail-page/insert-image";
106
+ import {
107
+ replaceWithGif,
108
+ unionBox,
109
+ type Box,
110
+ type ElementLike as ReplaceElementLike,
111
+ } from "../../lib/detail-page/replace-with-gif";
112
+ import {
113
+ createCanvasMeasure,
114
+ estimateMeasure,
115
+ gifBleed,
116
+ layoutTextLines,
117
+ toFontWeight,
118
+ type TextElementLike,
119
+ } from "../../lib/detail-page/text-gif-layout";
120
+ import {
121
+ shapeSourceImage,
122
+ type ShapeElementLike,
123
+ } from "../../lib/detail-page/shape-to-image";
124
+ import { useDetailPageHost } from "./detail-page-host-context";
125
+ import type {
126
+ DetailPageHost,
127
+ DetailPageGroupEditItem,
128
+ DetailPageGroupEditResultItem,
129
+ } from "./detail-page-host-context";
130
+ import type { ImageTier } from "../../lib/detail-page/image-credit";
131
+ import { isGifSrc } from "../../lib/detail-page-canvas/export/gif-plan";
132
+ import { DetailPageFontPicker } from "./detail-page-font-picker";
133
+ import {
134
+ normalizeFontWeight,
135
+ type FontCatalogStore,
136
+ } from "../../lib/detail-page-canvas/font-catalog";
137
+ import {
138
+ closestEditorFontWeight,
139
+ getEditorFont,
140
+ loadEditorFont,
141
+ } from "../../lib/detail-page-canvas/editor-fonts";
142
+ import { resolveGifWebFonts } from "../../lib/detail-page-canvas/gif-web-fonts";
143
+ import {
144
+ GifEffectPicker,
145
+ type GifEffectOption,
146
+ } from "./gif-effect-picker";
147
+
148
+ /**
149
+ * Figma-style properties inspector for the detail-page Canvas editor.
150
+ *
151
+ * Replaces the stock editor's top ``<Toolbar>``: instead of a horizontal bar above the
152
+ * canvas, the selected element's formatting lives in the right column and reads
153
+ * straight off ``store.selectedElements`` (mobx — so this observer re-renders as
154
+ * the selection or its props change). Mirrors the cardnews layer-editor layout
155
+ * (``text-layer-editor.tsx``) but targets the stock editor's element model.
156
+ */
157
+
158
+ type ParentLike = {
159
+ /** "group" for a group parent; a page has no type. */
160
+ type?: string;
161
+ children?: ElementLike[];
162
+ setElementZIndex?: (id: string, index: number) => void;
163
+ };
164
+ type ElementLike = {
165
+ id: string;
166
+ type: string;
167
+ set: (props: Record<string, unknown>) => void;
168
+ /** mobx view: 부모(그룹 또는 페이지) 내에서의 인덱스. 0 = 맨 뒤. */
169
+ zIndex?: number;
170
+ /** mobx view: 직접 부모(그룹 또는 페이지). setElementZIndex로 재정렬. */
171
+ parent?: ParentLike;
172
+ [key: string]: unknown;
173
+ };
174
+ type PageLike = {
175
+ id: string;
176
+ background?: string;
177
+ width?: number;
178
+ height?: number;
179
+ computedWidth?: number;
180
+ computedHeight?: number;
181
+ set?: (props: Record<string, unknown>) => void;
182
+ children?: ElementLike[];
183
+ };
184
+ type StoreLike = {
185
+ selectedElements?: ElementLike[];
186
+ selectedElementsIds?: string[];
187
+ getElementById?: (id: string) => ElementLike | undefined;
188
+ activePage?: PageLike;
189
+ pages: PageLike[];
190
+ fonts?: FontCatalogStore["fonts"];
191
+ addFont?: FontCatalogStore["addFont"];
192
+ deleteElements?: (ids: string[]) => void;
193
+ ungroupElements?: (ids: string[]) => void;
194
+ };
195
+
196
+ // ── Helpers ─────────────────────────────────────────────────────────────────
197
+
198
+ function setAll(els: ElementLike[], props: Record<string, unknown>) {
199
+ for (const el of els) el.set(props);
200
+ }
201
+
202
+ /**
203
+ * Turn the marker highlight on (a colour) or off (``null``) on each element.
204
+ * Stored on ``custom.highlightColor`` so the per-line band renderers own it, and
205
+ * the native Canvas ``backgroundEnabled`` box is cleared so the two never double
206
+ * up (legacy solid-background highlights migrate to the band on first edit).
207
+ */
208
+ function setHighlight(els: ElementLike[], color: string | null) {
209
+ for (const el of els) {
210
+ const custom = { ...((el.custom ?? {}) as Record<string, unknown>) };
211
+ if (color) custom.highlightColor = color;
212
+ else delete custom.highlightColor;
213
+ el.set({ custom, backgroundEnabled: false });
214
+ }
215
+ }
216
+
217
+ function documentFonts(store: StoreLike, current?: string): string[] {
218
+ const set = new Set<string>();
219
+ if (current) set.add(current);
220
+ for (const page of store.pages) {
221
+ for (const child of page.children ?? []) {
222
+ if (child.type === "text" && typeof child.fontFamily === "string") {
223
+ set.add(child.fontFamily);
224
+ }
225
+ }
226
+ }
227
+ return [...set].sort((a, b) => a.localeCompare(b));
228
+ }
229
+
230
+ const str = (v: unknown, fallback = ""): string =>
231
+ typeof v === "string" ? v : fallback;
232
+ const num = (v: unknown, fallback = 0): number =>
233
+ typeof v === "number" ? v : fallback;
234
+
235
+ /** GIF로 삽입된 이미지 요소인지(우측 인스펙터를 GIF 전용으로 바꾼다). */
236
+ function isGifElement(el: ElementLike): boolean {
237
+ if (el.custom && (el.custom as { detailPageGif?: unknown }).detailPageGif) return true;
238
+ return (el.type === "image" || el.type === "svg") && isGifSrc(str(el.src));
239
+ }
240
+
241
+ // 편집기 선택 이미지 src를 GIF 참조로 쓸 수 있게 정규화한다. 백엔드는 data:/http(s)를
242
+ // 받으므로: data URI는 그대로, 그 외(상대경로·blob·동일출처 프록시)는 fetch해서 data URI로
243
+ // 변환한다(원본 바이트라 alpha 보존). 교차출처 http(s)라 fetch가 CORS로 막히면 원본 URL을
244
+ // 그대로 넘겨 백엔드가 서버측에서 내려받게 한다. 못 구하면 null.
245
+ async function resolveReferenceSrc(src: string): Promise<string | null> {
246
+ if (!src) return null;
247
+ if (src.startsWith("data:")) return src;
248
+ try {
249
+ const res = await fetch(src);
250
+ if (!res.ok) throw new Error(`fetch ${res.status}`);
251
+ const blob = await res.blob();
252
+ return await new Promise<string | null>((resolve) => {
253
+ const reader = new FileReader();
254
+ reader.onloadend = () =>
255
+ resolve(typeof reader.result === "string" ? reader.result : null);
256
+ reader.onerror = () => resolve(null);
257
+ reader.readAsDataURL(blob);
258
+ });
259
+ } catch {
260
+ // 교차출처 등으로 클라 fetch 실패 → 백엔드가 직접 받을 수 있는 http(s)면 원본 URL.
261
+ return /^https?:\/\//.test(src) ? src : null;
262
+ }
263
+ }
264
+
265
+ // 그룹(중첩 포함) 안의 편집 가능한 요소(텍스트 + SVG 도형)를 문서 순서대로 모은다.
266
+ // 이미지·마크업 없는 figure는 건너뛴다 — 그룹을 통째로 골라도 안의 텍스트·도형을 한
267
+ // 번에 프롬프트로 수정하기 위한 것.
268
+ function collectEditableDescendants(root: ElementLike): ElementLike[] {
269
+ const out: ElementLike[] = [];
270
+ const walk = (node: ElementLike) => {
271
+ for (const child of (node.children as ElementLike[] | undefined) ?? []) {
272
+ if (child.type === "text" || child.type === "svg") out.push(child);
273
+ else if (child.type === "group") walk(child);
274
+ }
275
+ };
276
+ walk(root);
277
+ return out;
278
+ }
279
+
280
+ // 요소가 속한 페이지(섹션)를 찾는다 — 정렬 기준이 되는 폭/높이를 얻기 위해.
281
+ function pageOf(store: StoreLike, el: ElementLike): PageLike | undefined {
282
+ const hit = (children?: ElementLike[]): boolean =>
283
+ (children ?? []).some(
284
+ (child) => child.id === el.id || hit(child.children as ElementLike[]),
285
+ );
286
+ return store.pages.find((p) => hit(p.children)) ?? store.activePage ?? store.pages[0];
287
+ }
288
+
289
+ type AlignAxis = "x" | "y";
290
+ type AlignWhere = "start" | "center" | "end";
291
+ /** The box an element aligns inside, in the SAME coordinate space as its x/y. */
292
+ export type AlignFrame = { start: number; size: number };
293
+
294
+ /**
295
+ * The frame a nested element aligns within: its GROUP, not the page.
296
+ *
297
+ * A group child's x/y live in the group's local space, so aligning it against the
298
+ * page would fling it out of its group. The group's own x/y/width/height are not
299
+ * usable either — the decomposer pins a group to the origin and leaves the
300
+ * children carrying the real coordinates — so derive the frame from the sibling
301
+ * bounding box, which is what the group visually *is*.
302
+ */
303
+ export function groupFrame(
304
+ siblings: ReadonlyArray<Record<string, unknown>>,
305
+ axis: AlignAxis,
306
+ ): AlignFrame | null {
307
+ let lo = Infinity;
308
+ let hi = -Infinity;
309
+ for (const sib of siblings) {
310
+ const start = num(axis === "x" ? sib.x : sib.y);
311
+ const size = num(axis === "x" ? sib.width : sib.height);
312
+ lo = Math.min(lo, start);
313
+ hi = Math.max(hi, start + size);
314
+ }
315
+ if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi <= lo) return null;
316
+ return { start: lo, size: hi - lo };
317
+ }
318
+
319
+ /** Where a box of `size` lands when aligned inside `frame`. */
320
+ export function alignedCoord(
321
+ frame: AlignFrame,
322
+ size: number,
323
+ where: AlignWhere,
324
+ ): number {
325
+ if (where === "start") return frame.start;
326
+ if (where === "end") return frame.start + frame.size - size;
327
+ return frame.start + (frame.size - size) / 2;
328
+ }
329
+
330
+ // 정렬 기준 상자: 그룹 안 요소는 그 **그룹**, 최상위 요소는 자신이 속한 섹션(페이지).
331
+ function frameOf(
332
+ store: StoreLike,
333
+ el: ElementLike,
334
+ axis: AlignAxis,
335
+ ): AlignFrame | null {
336
+ const parent = el.parent;
337
+ if (parent?.type === "group" && parent.children?.length) {
338
+ return groupFrame(parent.children, axis);
339
+ }
340
+ const page = pageOf(store, el);
341
+ if (!page) return null;
342
+ const size = num(
343
+ axis === "x"
344
+ ? (page.computedWidth ?? page.width)
345
+ : (page.computedHeight ?? page.height),
346
+ );
347
+ return { start: 0, size };
348
+ }
349
+
350
+ // 각 선택 요소를 자신의 정렬 기준 상자(그룹 > 섹션) 안에서 정렬한다.
351
+ function alignInFrame(
352
+ store: StoreLike,
353
+ els: ElementLike[],
354
+ axis: AlignAxis,
355
+ where: AlignWhere,
356
+ ) {
357
+ for (const el of els) {
358
+ const frame = frameOf(store, el, axis);
359
+ if (!frame) continue;
360
+ const size = num(axis === "x" ? el.width : el.height);
361
+ const coord = Math.round(alignedCoord(frame, size, where));
362
+ el.set(axis === "x" ? { x: coord } : { y: coord });
363
+ }
364
+ }
365
+
366
+ // 선택 요소가 이미 어느 정렬 상태인지 — 툴바가 현재 상태(눌린/회색 버튼)를 보여줄 수
367
+ // 있도록. 모든 선택 요소가 자기 기준 상자 안에서 같은 정렬일 때만 그 값을, 섞였거나
368
+ // 어느 쪽도 아니거나 요소가 상자를 꽉 채워(start=center=end 구분 불가) 애매하면 null.
369
+ function currentAlign(
370
+ store: StoreLike,
371
+ els: ElementLike[],
372
+ axis: AlignAxis,
373
+ ): AlignWhere | null {
374
+ if (els.length === 0) return null;
375
+ let agreed: AlignWhere | null = null;
376
+ for (const el of els) {
377
+ const frame = frameOf(store, el, axis);
378
+ if (!frame) return null;
379
+ const size = num(axis === "x" ? el.width : el.height);
380
+ if (frame.size - size < 1) return null; // 상자를 꽉 채움: 정렬 구분 무의미
381
+ const coord = num(axis === "x" ? el.x : el.y);
382
+ let where: AlignWhere | null = null;
383
+ for (const w of ["start", "center", "end"] as const) {
384
+ if (Math.abs(alignedCoord(frame, size, w) - coord) <= 1) {
385
+ where = w;
386
+ break;
387
+ }
388
+ }
389
+ if (!where) return null;
390
+ if (agreed === null) agreed = where;
391
+ else if (agreed !== where) return null;
392
+ }
393
+ return agreed;
394
+ }
395
+
396
+ // 선택 요소끼리 간격을 고르게. 양 끝은 그대로 두고 사이 여백만 나눈다(distribute.ts).
397
+ function spreadEvenly(els: ElementLike[], axis: "x" | "y") {
398
+ const items = toItems(els as DistributeElement[], axis);
399
+ const coords = items && distributeCoords(items);
400
+ if (!coords) return;
401
+ for (const el of els) {
402
+ const coord = coords.get(el.id);
403
+ if (coord == null) continue;
404
+ el.set(axis === "x" ? { x: coord } : { y: coord });
405
+ }
406
+ }
407
+
408
+ function AlignButton({
409
+ title,
410
+ onClick,
411
+ active = false,
412
+ disabled = false,
413
+ children,
414
+ }: {
415
+ title: string;
416
+ onClick: () => void;
417
+ active?: boolean;
418
+ disabled?: boolean;
419
+ children: React.ReactNode;
420
+ }) {
421
+ return (
422
+ <button
423
+ type="button"
424
+ title={title}
425
+ aria-label={title}
426
+ aria-pressed={active}
427
+ disabled={disabled}
428
+ onClick={onClick}
429
+ className={`flex h-8 flex-1 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-30 ${
430
+ active
431
+ ? "border-neutral-300 bg-neutral-100 text-neutral-900"
432
+ : "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900"
433
+ }`}
434
+ >
435
+ {children}
436
+ </button>
437
+ );
438
+ }
439
+
440
+ // 정렬. 기준 상자는 요소마다 다르다: 그룹 안 요소는 그 그룹, 최상위 요소는 섹션.
441
+ // 제목도 기준을 그대로 말해줘서, 그룹 자식을 섹션 폭에 맞춰 날려버리는 오해를 막는다.
442
+ const AlignSection = observer(function AlignSection({
443
+ store,
444
+ els,
445
+ }: {
446
+ store: StoreLike;
447
+ els: ElementLike[];
448
+ }) {
449
+ const { t } = useTranslation("branding");
450
+ const inGroup =
451
+ els.length > 0 && els.every((el) => el.parent?.type === "group");
452
+ // 현재 정렬 상태(관찰형이라 요소 이동 시 자동 갱신). 해당 버튼을 회색으로 표시한다.
453
+ const xAlign = currentAlign(store, els, "x");
454
+ const yAlign = currentAlign(store, els, "y");
455
+ const spreadable = canDistribute(els);
456
+ return (
457
+ <Section
458
+ title={t(
459
+ inGroup
460
+ ? "detailPage.properties.alignInGroup"
461
+ : "detailPage.properties.alignInSection",
462
+ )}
463
+ >
464
+ <div className="flex items-center gap-1.5">
465
+ <AlignButton
466
+ title={t("detailPage.properties.alignLeft")}
467
+ active={xAlign === "start"}
468
+ onClick={() => alignInFrame(store, els, "x", "start")}
469
+ >
470
+ <AlignStartVertical size={15} />
471
+ </AlignButton>
472
+ <AlignButton
473
+ title={t("detailPage.properties.alignHCenter")}
474
+ active={xAlign === "center"}
475
+ onClick={() => alignInFrame(store, els, "x", "center")}
476
+ >
477
+ <AlignCenterVertical size={15} />
478
+ </AlignButton>
479
+ <AlignButton
480
+ title={t("detailPage.properties.alignRight")}
481
+ active={xAlign === "end"}
482
+ onClick={() => alignInFrame(store, els, "x", "end")}
483
+ >
484
+ <AlignEndVertical size={15} />
485
+ </AlignButton>
486
+ </div>
487
+ <div className="mt-1.5 flex items-center gap-1.5">
488
+ <AlignButton
489
+ title={t("detailPage.properties.alignTop")}
490
+ active={yAlign === "start"}
491
+ onClick={() => alignInFrame(store, els, "y", "start")}
492
+ >
493
+ <AlignStartHorizontal size={15} />
494
+ </AlignButton>
495
+ <AlignButton
496
+ title={t("detailPage.properties.alignVCenter")}
497
+ active={yAlign === "center"}
498
+ onClick={() => alignInFrame(store, els, "y", "center")}
499
+ >
500
+ <AlignCenterHorizontal size={15} />
501
+ </AlignButton>
502
+ <AlignButton
503
+ title={t("detailPage.properties.alignBottom")}
504
+ active={yAlign === "end"}
505
+ onClick={() => alignInFrame(store, els, "y", "end")}
506
+ >
507
+ <AlignEndHorizontal size={15} />
508
+ </AlignButton>
509
+ </div>
510
+ {/* 간격 고르게. 셋 이상 · 같은 부모일 때만 — 그룹 자식과 최상위가 섞이면
511
+ 좌표계가 달라 뒤섞인 결과가 나온다. */}
512
+ <div className="mt-1.5 flex items-center gap-1.5">
513
+ <AlignButton
514
+ title={t("detailPage.properties.spreadH")}
515
+ disabled={!spreadable}
516
+ onClick={() => spreadEvenly(els, "x")}
517
+ >
518
+ <AlignHorizontalDistributeCenter size={15} />
519
+ </AlignButton>
520
+ <AlignButton
521
+ title={t("detailPage.properties.spreadV")}
522
+ disabled={!spreadable}
523
+ onClick={() => spreadEvenly(els, "y")}
524
+ >
525
+ <AlignVerticalDistributeCenter size={15} />
526
+ </AlignButton>
527
+ </div>
528
+ </Section>
529
+ );
530
+ });
531
+
532
+ // 정렬 순서(z-order). 규칙은 z-order.ts 한 벌 — 캔버스 우클릭 메뉴도 같은 걸 쓴다.
533
+ const OrderSection = observer(function OrderSection({ els }: { els: ElementLike[] }) {
534
+ const { t } = useTranslation("branding");
535
+ const el = els[0];
536
+ const order = zOrderOf(el);
537
+ if (!order) return null;
538
+ const { z, count, atFront, atBack } = order;
539
+ const setZ = (i: number) => setElementZ(el, i);
540
+
541
+ const Btn = ({
542
+ title,
543
+ disabled,
544
+ onClick,
545
+ children,
546
+ }: {
547
+ title: string;
548
+ disabled: boolean;
549
+ onClick: () => void;
550
+ children: React.ReactNode;
551
+ }) => (
552
+ <button
553
+ type="button"
554
+ title={title}
555
+ aria-label={title}
556
+ disabled={disabled}
557
+ onClick={onClick}
558
+ className="flex h-8 flex-1 items-center justify-center rounded-md border border-neutral-200 bg-white text-neutral-600 transition-colors hover:bg-neutral-50 hover:text-neutral-900 disabled:cursor-not-allowed disabled:opacity-30"
559
+ >
560
+ {children}
561
+ </button>
562
+ );
563
+
564
+ return (
565
+ <Section title={t("detailPage.properties.order")}>
566
+ <div className="flex items-center gap-1.5">
567
+ <Btn title={t("detailPage.properties.bringToFront")} disabled={atFront} onClick={() => setZ(count - 1)}>
568
+ <ChevronsUp size={15} />
569
+ </Btn>
570
+ <Btn title={t("detailPage.properties.bringForward")} disabled={atFront} onClick={() => setZ(z + 1)}>
571
+ <ChevronUp size={15} />
572
+ </Btn>
573
+ <Btn title={t("detailPage.properties.sendBackward")} disabled={atBack} onClick={() => setZ(z - 1)}>
574
+ <ChevronDown size={15} />
575
+ </Btn>
576
+ <Btn title={t("detailPage.properties.sendToBack")} disabled={atBack} onClick={() => setZ(0)}>
577
+ <ChevronsDown size={15} />
578
+ </Btn>
579
+ <span className="ml-1 shrink-0 rounded-md bg-neutral-100 px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-neutral-500">
580
+ {z + 1}/{count}
581
+ </span>
582
+ </div>
583
+ </Section>
584
+ );
585
+ });
586
+
587
+ // ── Inspectors ──────────────────────────────────────────────────────────────
588
+
589
+ // observer 필수: 이 컴포넌트가 el.opacity를 읽는 유일한 곳이다. 감싸지 않으면 mobx가
590
+ // 그 읽기를 추적하지 못해 opacity가 바뀌어도 리렌더가 안 되고, controlled input의 value가
591
+ // 옛 값에 고정된다 → 슬라이더가 아예 안 움직이는(= 클릭이 안 먹는) 것처럼 보인다.
592
+ const OpacityRow = observer(function OpacityRow({ els }: { els: ElementLike[] }) {
593
+ const { t } = useTranslation("branding");
594
+ const opacity = num(els[0]?.opacity, 1);
595
+ return (
596
+ <Section title={t("detailPage.properties.opacity")}>
597
+ <div className="flex items-center gap-3">
598
+ <input
599
+ type="range"
600
+ min={0}
601
+ max={100}
602
+ value={Math.round(opacity * 100)}
603
+ onChange={(e) => setAll(els, { opacity: Number(e.target.value) / 100 })}
604
+ className="min-w-0 flex-1 accent-neutral-900"
605
+ />
606
+ <span className="w-10 text-right text-sm tabular-nums text-neutral-700">
607
+ {Math.round(opacity * 100)}%
608
+ </span>
609
+ </div>
610
+ </Section>
611
+ );
612
+ });
613
+
614
+ // 크기·위치. 단일 선택일 때만. 폭(W)을 직접 보고 고칠 수 있어야 "텍스트 박스가 왜
615
+ // 넘치지" 같은 문제를 사용자가 바로 진단·수정한다. 관찰형이라 드래그·정렬로 값이
616
+ // 바뀌면 자동 갱신된다. 기존 NumberField(blur/Enter 커밋, 화살표 스텝)를 재사용.
617
+ const SizeSection = observer(function SizeSection({ els }: { els: ElementLike[] }) {
618
+ const { t } = useTranslation("branding");
619
+ const el = els[0];
620
+ if (!el) return null;
621
+ const field = (label: string, key: "width" | "height" | "x" | "y") => (
622
+ <NumberField
623
+ label={label}
624
+ value={num(el[key])}
625
+ min={key === "width" || key === "height" ? 1 : undefined}
626
+ onChange={(v) => el.set({ [key]: Math.round(v) })}
627
+ />
628
+ );
629
+ return (
630
+ <Section title={t("detailPage.properties.size")}>
631
+ <div className="grid grid-cols-2 gap-1.5">
632
+ {field("W", "width")}
633
+ {field("H", "height")}
634
+ {field("X", "x")}
635
+ {field("Y", "y")}
636
+ </div>
637
+ </Section>
638
+ );
639
+ });
640
+ SizeSection.displayName = "SizeSection";
641
+
642
+ function DeleteRow({ store, els }: { store: StoreLike; els: ElementLike[] }) {
643
+ const { t } = useTranslation("branding");
644
+ // A single selected group can be split back into its elements. Mirrors the
645
+ // built-in Cmd+G ungroup so the action is discoverable without the shortcut.
646
+ const canUngroup = els.length === 1 && els[0].type === "group";
647
+ return (
648
+ <Section title={t("detailPage.properties.actions")}>
649
+ <div className="flex flex-col gap-2">
650
+ {canUngroup && (
651
+ <button
652
+ type="button"
653
+ onClick={() => store.ungroupElements?.([els[0].id])}
654
+ className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-md border border-neutral-200 bg-white text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
655
+ >
656
+ <Ungroup aria-hidden="true" size={15} />
657
+ {t("detailPage.properties.ungroup")}
658
+ </button>
659
+ )}
660
+ <button
661
+ type="button"
662
+ onClick={() => store.deleteElements?.(els.map((e) => e.id))}
663
+ className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-md border border-red-200 bg-red-50 text-sm font-semibold text-red-600 hover:bg-red-100"
664
+ >
665
+ <Trash2 aria-hidden="true" size={15} />
666
+ {t("detailPage.properties.delete")}
667
+ </button>
668
+ </div>
669
+ </Section>
670
+ );
671
+ }
672
+
673
+ const TextInspector = observer(function TextInspector({
674
+ store,
675
+ els,
676
+ generatedId,
677
+ usage,
678
+ onUsage,
679
+ onBuyMore,
680
+ onGenerateTextGif,
681
+ textGifCreditCost,
682
+ onGenerateDataGif,
683
+ dataGifCreditCost,
684
+ }: {
685
+ store: StoreLike;
686
+ els: ElementLike[];
687
+ generatedId?: string;
688
+ usage?: EditUsageState;
689
+ onUsage?: (used: number, limit: number) => void;
690
+ onBuyMore?: () => void;
691
+ onGenerateTextGif?: GenerateTextGifFn;
692
+ textGifCreditCost?: number;
693
+ /** 숫자가 든 텍스트를 카운트업 GIF로. 미지정이면 섹션 숨김. */
694
+ onGenerateDataGif?: GenerateDataGifFn;
695
+ dataGifCreditCost?: number;
696
+ }) {
697
+ const { t } = useTranslation("branding");
698
+ const { toast } = useDetailPageHost();
699
+ const single = els.length === 1 ? els[0] : null;
700
+ const singleCustom = (single?.custom ?? {}) as Record<string, unknown>;
701
+ const slotRole =
702
+ typeof singleCustom.leviosaSlot === "string" ? singleCustom.leviosaSlot : "";
703
+ const slotKind =
704
+ typeof singleCustom.leviosaSlotKind === "string"
705
+ ? singleCustom.leviosaSlotKind
706
+ : undefined;
707
+ const ref = els[0];
708
+ const fontFamily = str(ref.fontFamily, "Roboto");
709
+ const currentFontWeight = normalizeFontWeight(ref.fontWeight);
710
+ const fontSize = num(ref.fontSize, 24);
711
+ const fill = str(ref.fill, "#000000");
712
+ const align = str(ref.align, "left");
713
+ const isItalic = str(ref.fontStyle, "normal") === "italic";
714
+ const deco = str(ref.textDecoration, "");
715
+ const lineHeight = num(ref.lineHeight, 1.2);
716
+ const letterSpacing = num(ref.letterSpacing, 0);
717
+ // 텍스트 하이라이트: 줄바꿈돼도 각 줄 글자 폭에 맞는 "마커 밴드"로 그린다
718
+ // (custom.highlightColor 단일 소스). Canvas 네이티브 background* 박스는 줄높이만큼
719
+ // 부풀어 두 줄을 통짜 블록으로 붙여버리므로 쓰지 않는다. 렌더는 편집기
720
+ // (BackgroundAwareText)·내보내기(konva-json-preview) 두 경로에서 밴드로 그린다.
721
+ const refCustom = (ref.custom ?? {}) as Record<string, unknown>;
722
+ // 예전 방식(backgroundEnabled solid)도 켜짐으로 인식해 색을 노출하되, 조작 시
723
+ // custom.highlightColor로 이전(migrate)하고 네이티브는 끈다.
724
+ const legacyBgOn =
725
+ ref.backgroundEnabled === true &&
726
+ !refCustom.backgroundGradient &&
727
+ typeof ref.backgroundColor === "string" &&
728
+ ref.backgroundColor !== "transparent";
729
+ const highlightOn =
730
+ typeof refCustom.highlightColor === "string" || legacyBgOn;
731
+ const highlightColor =
732
+ (typeof refCustom.highlightColor === "string"
733
+ ? refCustom.highlightColor
734
+ : str(ref.backgroundColor)) || "#FFEB3B";
735
+ const fonts = documentFonts(store, fontFamily);
736
+ const catalogFont = getEditorFont(fontFamily);
737
+ const fontWeights = catalogFont ? catalogFont.weights : [400, 700];
738
+ const displayedWeight = catalogFont
739
+ ? closestEditorFontWeight(catalogFont, currentFontWeight)
740
+ : currentFontWeight >= 600
741
+ ? 700
742
+ : 400;
743
+ const [fontBusy, setFontBusy] = useState(false);
744
+
745
+ const applyFontFamily = async (family: string) => {
746
+ const nextFont = getEditorFont(family);
747
+ if (!nextFont) {
748
+ setAll(els, { fontFamily: family });
749
+ return;
750
+ }
751
+ const nextWeight = closestEditorFontWeight(nextFont, currentFontWeight);
752
+ await loadEditorFont({
753
+ family,
754
+ weight: nextWeight,
755
+ sample: str(ref.text),
756
+ store,
757
+ });
758
+ setAll(els, { fontFamily: family, fontWeight: String(nextWeight) });
759
+ };
760
+
761
+ const applyFontWeight = async (weight: number) => {
762
+ setFontBusy(true);
763
+ try {
764
+ if (catalogFont) {
765
+ await loadEditorFont({
766
+ family: catalogFont.family,
767
+ weight,
768
+ sample: str(ref.text),
769
+ store,
770
+ });
771
+ }
772
+ setAll(els, { fontWeight: String(weight) });
773
+ } catch (fontError) {
774
+ console.error(
775
+ `Failed to load detail-page font weight "${fontFamily} ${weight}"`,
776
+ fontError,
777
+ );
778
+ toast.error(t("detailPage.properties.fontLoadFailed"));
779
+ } finally {
780
+ setFontBusy(false);
781
+ }
782
+ };
783
+
784
+ return (
785
+ <>
786
+ {single ? (
787
+ <Section title={t("detailPage.properties.content")}>
788
+ <textarea
789
+ value={str(single.text)}
790
+ onChange={(e) => single.set({ text: e.target.value })}
791
+ rows={3}
792
+ className="w-full resize-y rounded-md border border-neutral-200 bg-white px-2 py-2 text-sm text-neutral-900 outline-none focus:border-neutral-400"
793
+ />
794
+ {/* 실서비스(생성 ID 존재)면 모든 텍스트에 프롬프트 편집을 노출한다. 예전에는
795
+ custom.leviosaSlot(카피 슬롯)이 있어야만 떴는데, 헤드라인처럼 슬롯 배정을
796
+ 못 받았거나 그룹 해제 과정에서 슬롯이 유실된 텍스트가 편집 불가가 되는
797
+ 문제가 있었다. 백엔드는 slot_role이 비어도 current_text+지시로 재작성하므로
798
+ 슬롯 유무와 무관하게 편집을 허용한다. 픽스처(dev-canvas)는 generatedId가
799
+ 없어 자동으로 숨는다. */}
800
+ {single && generatedId ? (
801
+ <div className="mt-2">
802
+ <PromptEditPanel
803
+ generatedId={generatedId}
804
+ slotRole={slotRole}
805
+ currentText={str(single.text)}
806
+ renderKind={slotKind}
807
+ onApplied={(text) => single.set({ text })}
808
+ editsUsed={usage?.textUsed}
809
+ editLimit={usage?.textLimit}
810
+ unlimited={usage?.unlimited}
811
+ onUsage={onUsage}
812
+ onBuyMore={onBuyMore}
813
+ />
814
+ </div>
815
+ ) : null}
816
+ </Section>
817
+ ) : null}
818
+
819
+ <Section title={t("detailPage.properties.font")}>
820
+ <div className="grid grid-cols-[1fr_84px] gap-2">
821
+ <DetailPageFontPicker
822
+ value={fontFamily}
823
+ documentFamilies={fonts}
824
+ onSelect={applyFontFamily}
825
+ />
826
+ <NumberField
827
+ value={fontSize}
828
+ min={1}
829
+ step={1}
830
+ onChange={(v) => setAll(els, { fontSize: v })}
831
+ />
832
+ </div>
833
+
834
+ <div className="mt-2 flex items-center gap-1.5">
835
+ <select
836
+ aria-label={t("detailPage.properties.fontWeight")}
837
+ value={displayedWeight}
838
+ disabled={fontBusy}
839
+ onChange={(event) => void applyFontWeight(Number(event.target.value))}
840
+ className="h-8 min-w-0 flex-1 rounded-md border border-neutral-200 bg-white px-2 text-xs text-neutral-700 outline-none focus:border-neutral-400 disabled:opacity-50"
841
+ >
842
+ {fontWeights.map((weight) => (
843
+ <option key={weight} value={weight}>
844
+ {weight} {t(`detailPage.properties.weight${weight}`)}
845
+ </option>
846
+ ))}
847
+ </select>
848
+ <ToggleButton
849
+ active={isItalic}
850
+ title={t("detailPage.properties.italic")}
851
+ onClick={() => setAll(els, { fontStyle: isItalic ? "normal" : "italic" })}
852
+ >
853
+ <Italic size={15} />
854
+ </ToggleButton>
855
+ <ToggleButton
856
+ active={deco === "underline"}
857
+ title={t("detailPage.properties.underline")}
858
+ onClick={() =>
859
+ setAll(els, { textDecoration: deco === "underline" ? "" : "underline" })
860
+ }
861
+ >
862
+ <Underline size={15} />
863
+ </ToggleButton>
864
+ <ToggleButton
865
+ active={deco === "line-through"}
866
+ title={t("detailPage.properties.strikethrough")}
867
+ onClick={() =>
868
+ setAll(els, {
869
+ textDecoration: deco === "line-through" ? "" : "line-through",
870
+ })
871
+ }
872
+ >
873
+ <Strikethrough size={15} />
874
+ </ToggleButton>
875
+ </div>
876
+
877
+ <div className="mt-2 flex items-center gap-1.5">
878
+ {(
879
+ [
880
+ { value: "left", icon: <AlignLeft size={15} /> },
881
+ { value: "center", icon: <AlignCenter size={15} /> },
882
+ { value: "right", icon: <AlignRight size={15} /> },
883
+ { value: "justify", icon: <AlignJustify size={15} /> },
884
+ ] as const
885
+ ).map((opt) => (
886
+ <ToggleButton
887
+ key={opt.value}
888
+ active={align === opt.value}
889
+ title={opt.value}
890
+ onClick={() => setAll(els, { align: opt.value })}
891
+ >
892
+ {opt.icon}
893
+ </ToggleButton>
894
+ ))}
895
+ </div>
896
+ </Section>
897
+
898
+ <Section title={t("detailPage.properties.color")}>
899
+ <FillControl value={fill} onChange={(c) => setAll(els, { fill: c })} />
900
+ </Section>
901
+
902
+ <Section title={t("detailPage.properties.highlight")}>
903
+ <div className="flex items-center gap-2">
904
+ <ToggleButton
905
+ active={highlightOn}
906
+ title={t("detailPage.properties.highlight")}
907
+ onClick={() =>
908
+ setHighlight(els, highlightOn ? null : highlightColor)
909
+ }
910
+ >
911
+ <Highlighter size={15} />
912
+ </ToggleButton>
913
+ {highlightOn ? (
914
+ <ColorInput
915
+ value={highlightColor}
916
+ onChange={(c) => setHighlight(els, c)}
917
+ />
918
+ ) : (
919
+ <span className="text-[11px] text-neutral-400">
920
+ {t("detailPage.properties.highlightHint")}
921
+ </span>
922
+ )}
923
+ </div>
924
+ </Section>
925
+
926
+ <Section title={t("detailPage.properties.spacing")}>
927
+ <div className="grid grid-cols-2 gap-2">
928
+ <label className="flex items-center gap-2">
929
+ <span className="w-10 text-xs text-neutral-500">{t("detailPage.properties.lineHeight")}</span>
930
+ <NumberField
931
+ value={lineHeight}
932
+ step={0.1}
933
+ min={0.1}
934
+ onChange={(v) => setAll(els, { lineHeight: v })}
935
+ />
936
+ </label>
937
+ <label className="flex items-center gap-2">
938
+ <span className="w-10 text-xs text-neutral-500">{t("detailPage.properties.letterSpacing")}</span>
939
+ <NumberField
940
+ value={letterSpacing}
941
+ step={0.5}
942
+ onChange={(v) => setAll(els, { letterSpacing: v })}
943
+ />
944
+ </label>
945
+ </div>
946
+ </Section>
947
+
948
+ {single && onGenerateTextGif ? (
949
+ <TextGifSection
950
+ store={store}
951
+ els={[single]}
952
+ onGenerate={onGenerateTextGif}
953
+ creditCost={textGifCreditCost}
954
+ />
955
+ ) : null}
956
+
957
+ {single && onGenerateDataGif ? (
958
+ <CountUpGifSection
959
+ store={store}
960
+ els={[single]}
961
+ onGenerate={onGenerateDataGif}
962
+ creditCost={dataGifCreditCost}
963
+ />
964
+ ) : null}
965
+
966
+ <OpacityRow els={els} />
967
+ <DeleteRow store={store} els={els} />
968
+ </>
969
+ );
970
+ });
971
+
972
+ // 우측 패널 '텍스트를 GIF로': 선택 텍스트를 애니메이션 GIF로 만들어 '내 이미지'에 저장
973
+ // 하고 편집기에 삽입한다. 이펙트 목록은 백엔드 카탈로그와 동일한 정적 상수(작고 안정적).
974
+ // 이펙트 이름·설명은 화면에 그대로 노출되므로 언어를 따라야 한다. 여기서는 id와 순서만
975
+ // 들고, 문구는 `detailPage.gifEffects.*` 에서 꺼낸다.
976
+ // previewSrc 는 소싱 저장소의 scripts/detail_page_gif_effect_previews.py 가 구워 둔 자산.
977
+ const TEXT_GIF_EFFECT_IDS = [
978
+ "shimmer",
979
+ "blur_in",
980
+ "wave",
981
+ "typewriter",
982
+ "bounce",
983
+ "glow_pulse",
984
+ "wobble",
985
+ "fade_up",
986
+ ] as const;
987
+
988
+ /** 번역기 타입 — i18next `t` 를 그대로 받는다. */
989
+ type Translate = (key: string) => string;
990
+
991
+ function textGifEffects(t: Translate): GifEffectOption[] {
992
+ return TEXT_GIF_EFFECT_IDS.map((id) => ({
993
+ id,
994
+ label: t(`detailPage.gifEffects.text.${id}.label`),
995
+ hint: t(`detailPage.gifEffects.text.${id}.hint`),
996
+ previewSrc: `/gif-effect-previews/text-${id}.gif`,
997
+ }));
998
+ }
999
+
1000
+ /**
1001
+ * Canvas 페이지 배경을 GIF 합성 배경색으로 쓴다(엣지 정합).
1002
+ *
1003
+ * 배경이 그라데이션 문자열이면 접을 수 없으니 흰색으로 떨어진다.
1004
+ */
1005
+ function pageBackgroundColor(store: StoreLike): string {
1006
+ const bg = (store as { activePage?: { background?: unknown } }).activePage
1007
+ ?.background;
1008
+ return toHexColor(bg, "#ffffff");
1009
+ }
1010
+
1011
+ // 우측 인스펙터 '이미지를 GIF로'. 백엔드 카탈로그(/images/image-gif-effects)와 같은
1012
+ // 정적 목록 — 작고 안정적이라 부팅 시 네트워크를 태우지 않는다. group이 갈리는 이유는
1013
+ // object 이펙트가 이미지 안의 물체를 찾아(배경제거) 거는 연출이라 성격이 다르기 때문이다.
1014
+ const IMAGE_GIF_GROUP_KEY = {
1015
+ whole: "detailPage.gifEffects.group.whole",
1016
+ object: "detailPage.gifEffects.group.object",
1017
+ } as const;
1018
+
1019
+ const IMAGE_GIF_EFFECT_SOURCE: {
1020
+ id: string;
1021
+ group: keyof typeof IMAGE_GIF_GROUP_KEY;
1022
+ }[] = [
1023
+ { id: "ken_burns", group: "whole" },
1024
+ { id: "pulse_zoom", group: "whole" },
1025
+ { id: "blur_in", group: "whole" },
1026
+ { id: "fade_slide_left", group: "whole" },
1027
+ { id: "fade_slide_right", group: "whole" },
1028
+ { id: "rise_fall", group: "whole" },
1029
+ { id: "shine_sweep", group: "whole" },
1030
+ { id: "wipe_reveal", group: "whole" },
1031
+ { id: "tilt_parallax", group: "whole" },
1032
+ { id: "holo_foil", group: "object" },
1033
+ { id: "holo_foil_silver", group: "object" },
1034
+ { id: "holo_foil_gold", group: "object" },
1035
+ ];
1036
+
1037
+ /**
1038
+ * 카탈로그가 실제로 읽는 번역 키 전부(그룹 · 텍스트 · 이미지).
1039
+ *
1040
+ * 이펙트를 추가하면서 번역을 빠뜨리면 화면에 키가 그대로 노출되므로, 테스트가 ko/en
1041
+ * 양쪽 존재를 이 목록으로 확인한다.
1042
+ */
1043
+ export const GIF_EFFECT_LABEL_KEYS: string[] = [
1044
+ ...Object.values(IMAGE_GIF_GROUP_KEY),
1045
+ ...TEXT_GIF_EFFECT_IDS.flatMap((id) => [
1046
+ `detailPage.gifEffects.text.${id}.label`,
1047
+ `detailPage.gifEffects.text.${id}.hint`,
1048
+ ]),
1049
+ ...IMAGE_GIF_EFFECT_SOURCE.flatMap((effect) => [
1050
+ `detailPage.gifEffects.image.${effect.id}.label`,
1051
+ `detailPage.gifEffects.image.${effect.id}.hint`,
1052
+ ]),
1053
+ ];
1054
+
1055
+ /**
1056
+ * 이미지/도형용 이펙트 목록.
1057
+ *
1058
+ * 도형(``shape``)에서는 물체 검출(배경제거)이 붙는 홀로그램 계열을 뺀다 — 도형은 이미
1059
+ * 배경이 없어서 검출에 크레딧과 시간만 쓰고 얻는 게 없다. 묶음이 하나뿐이라 그룹
1060
+ * 라벨도 떼어 낸다.
1061
+ */
1062
+ function imageGifEffects(t: Translate, shape: boolean): GifEffectOption[] {
1063
+ return IMAGE_GIF_EFFECT_SOURCE.filter(
1064
+ (effect) => !shape || effect.group === "whole",
1065
+ ).map((effect) => ({
1066
+ id: effect.id,
1067
+ label: t(`detailPage.gifEffects.image.${effect.id}.label`),
1068
+ hint: t(`detailPage.gifEffects.image.${effect.id}.hint`),
1069
+ group: shape ? undefined : t(IMAGE_GIF_GROUP_KEY[effect.group]),
1070
+ previewSrc: `/gif-effect-previews/image-${effect.id}.gif`,
1071
+ }));
1072
+ }
1073
+
1074
+ // 백엔드 stage → 버튼에 띄울 문구 키. 홀로그램은 물체 검출 왕복이 붙어 체감이 길어서,
1075
+ // 단순 스피너 대신 지금 뭘 하는지 말해줘야 "멈췄나?" 소리가 안 나온다.
1076
+ const IMAGE_GIF_STAGE_KEY: Record<string, string> = {
1077
+ preparing: "detailPage.properties.gifStagePreparing",
1078
+ detecting: "detailPage.properties.gifStageDetecting",
1079
+ rendering: "detailPage.properties.gifStageRendering",
1080
+ encoding: "detailPage.properties.gifStageEncoding",
1081
+ uploading: "detailPage.properties.gifStageUploading",
1082
+ };
1083
+
1084
+ /**
1085
+ * 우측 인스펙터 '배경 지우기(누끼)'.
1086
+ *
1087
+ * GIF 계열과 달리 새 요소를 삽입하지 않고 **선택 요소의 src를 갈아 끼운다** — 누끼는
1088
+ * 새 소재를 만드는 일이 아니라 지금 놓인 사진을 고치는 일이라, 자리·크기·자르기가
1089
+ * 그대로 유지돼야 한다.
1090
+ */
1091
+ const BgRemoveSection = observer(function BgRemoveSection({
1092
+ el,
1093
+ onRemove,
1094
+ creditCost,
1095
+ }: {
1096
+ el: ElementLike;
1097
+ onRemove: RemoveBackgroundFn;
1098
+ creditCost?: number;
1099
+ }) {
1100
+ const { t } = useTranslation("branding");
1101
+ const { api, brand } = useDetailPageHost();
1102
+ const [busy, setBusy] = useState(false);
1103
+ const [error, setError] = useState<string | null>(null);
1104
+
1105
+ const run = useCallback(async () => {
1106
+ if (busy) return;
1107
+ // 편집기 src는 상대경로·blob·동일출처 프록시일 수 있다. GIF 참조와 같은 방식으로
1108
+ // data URI로 바꾸고, 교차출처 http(s)만 원본 URL 그대로 백엔드가 받게 한다.
1109
+ const source = await resolveReferenceSrc(str(el.src));
1110
+ if (!source) {
1111
+ setError(t("detailPage.properties.gifSourceUnreadable"));
1112
+ return;
1113
+ }
1114
+ setBusy(true);
1115
+ setError(null);
1116
+ try {
1117
+ const url = await onRemove({
1118
+ sourceImage: source,
1119
+ brandId: brand.getStoredActiveBrandId() ?? undefined,
1120
+ });
1121
+ if (url) {
1122
+ el.set({ src: url });
1123
+ } else {
1124
+ setError(
1125
+ t("detailPage.properties.bgRemoveFailed", {
1126
+ defaultValue: "배경을 지우지 못했어요.",
1127
+ }),
1128
+ );
1129
+ }
1130
+ } catch (err) {
1131
+ const short = api.asInsufficientCreditsError(err);
1132
+ setError(
1133
+ short
1134
+ ? short.message
1135
+ : err instanceof Error
1136
+ ? err.message
1137
+ : t("detailPage.properties.bgRemoveFailed", {
1138
+ defaultValue: "배경을 지우지 못했어요.",
1139
+ }),
1140
+ );
1141
+ } finally {
1142
+ setBusy(false);
1143
+ }
1144
+ }, [busy, el, onRemove, t]);
1145
+
1146
+ return (
1147
+ <Section
1148
+ title={t("detailPage.properties.bgRemove", {
1149
+ defaultValue: "배경 지우기",
1150
+ })}
1151
+ >
1152
+ <button
1153
+ type="button"
1154
+ onClick={run}
1155
+ disabled={busy}
1156
+ className="inline-flex w-full items-center justify-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-neutral-700 disabled:cursor-not-allowed disabled:opacity-50"
1157
+ >
1158
+ <Eraser size={14} />
1159
+ {busy
1160
+ ? t("detailPage.properties.bgRemoveBusy", {
1161
+ defaultValue: "배경 지우는 중…",
1162
+ })
1163
+ : t("detailPage.properties.bgRemoveRun", {
1164
+ defaultValue: "배경 지우기",
1165
+ })}
1166
+ {!busy && creditCost ? ` · ${creditCost}` : ""}
1167
+ </button>
1168
+ {error ? (
1169
+ <p className="mt-1.5 text-[11px] text-red-500">{error}</p>
1170
+ ) : (
1171
+ <p className="mt-1.5 text-[11px] text-neutral-400">
1172
+ {t("detailPage.properties.bgRemoveHint", {
1173
+ defaultValue:
1174
+ "피사체만 남기고 배경을 투명하게 만들어요. 자리와 크기는 그대로예요.",
1175
+ })}
1176
+ </p>
1177
+ )}
1178
+ </Section>
1179
+ );
1180
+ });
1181
+
1182
+ const ImageGifSection = observer(function ImageGifSection({
1183
+ store,
1184
+ el,
1185
+ onGenerate,
1186
+ creditCost,
1187
+ assetKind = "image",
1188
+ title,
1189
+ hint,
1190
+ }: {
1191
+ store: StoreLike;
1192
+ el: ElementLike;
1193
+ onGenerate: GenerateImageGifFn;
1194
+ creditCost?: number;
1195
+ /** 도형이면 벡터를 투명 PNG로 구워 보내고, 결과는 브랜드 GIF의 도형 구획으로 간다. */
1196
+ assetKind?: "image" | "shape";
1197
+ title?: string;
1198
+ hint?: string;
1199
+ }) {
1200
+ const { t } = useTranslation("branding");
1201
+ const { api, brand } = useDetailPageHost();
1202
+ const shape = assetKind === "shape";
1203
+ const effects = useMemo(() => imageGifEffects(t, shape), [t, shape]);
1204
+ const [effect, setEffect] = useState(shape ? "wipe_reveal" : "ken_burns");
1205
+ const [busy, setBusy] = useState(false);
1206
+ const [stage, setStage] = useState<{ stage: string; progress: number } | null>(
1207
+ null,
1208
+ );
1209
+ const [error, setError] = useState<string | null>(null);
1210
+ const [notice, setNotice] = useState<string | null>(null);
1211
+ const abortRef = useRef<AbortController | null>(null);
1212
+
1213
+ const selected = effects.find((fx) => fx.id === effect);
1214
+
1215
+ const run = useCallback(async () => {
1216
+ if (busy) return;
1217
+ // 도형은 벡터라 픽셀이 없다 — 편집기와 같은 규칙(색 치환·그라데이션)으로 투명
1218
+ // PNG를 구워 보낸다. 사진은 편집기 src가 상대경로·blob·동일출처 프록시일 수 있어
1219
+ // GIF 참조와 같은 방식으로 data URI로 바꾸고, 교차출처 http(s)만 원본 URL 그대로
1220
+ // 백엔드가 받게 한다.
1221
+ const source = shape
1222
+ ? await shapeSourceImage(el as ShapeElementLike)
1223
+ : await resolveReferenceSrc(str(el.src));
1224
+ if (!source) {
1225
+ setError(
1226
+ t(
1227
+ shape
1228
+ ? "detailPage.properties.gifShapeUnreadable"
1229
+ : "detailPage.properties.gifSourceUnreadable",
1230
+ ),
1231
+ );
1232
+ return;
1233
+ }
1234
+ setBusy(true);
1235
+ setError(null);
1236
+ setNotice(null);
1237
+ setStage({ stage: "preparing", progress: 0 });
1238
+ const controller = new AbortController();
1239
+ abortRef.current = controller;
1240
+ try {
1241
+ const { urls, maskFallback } = await onGenerate({
1242
+ sourceImage: source,
1243
+ effect,
1244
+ // 도형은 투명하게 굽는다 — 페이지 배경색을 주면 그 색이 배경으로 눌러 붙는다.
1245
+ background: shape ? "#00000000" : pageBackgroundColor(store),
1246
+ brandId: brand.getStoredActiveBrandId() ?? undefined,
1247
+ assetKind,
1248
+ onProgress: setStage,
1249
+ });
1250
+ const url = urls[0];
1251
+ if (url) {
1252
+ // 원본을 지우고 그 자리·그 크기로 갈아 끼운다. 사진이었다면 자르기(crop)까지
1253
+ // 물려받아야 프레이밍이 안 바뀐다 — GIF 프레임은 원본 사진 비율 그대로다.
1254
+ replaceWithGif(store, [el as ReplaceElementLike], url, {
1255
+ inheritCrop: true,
1256
+ });
1257
+ if (maskFallback) {
1258
+ setNotice(
1259
+ t("detailPage.properties.imageGifMaskFallback", {
1260
+ defaultValue:
1261
+ "물체를 특정하지 못해 이미지 전체에 적용했어요.",
1262
+ }),
1263
+ );
1264
+ }
1265
+ }
1266
+ } catch (err) {
1267
+ if (controller.signal.aborted) return; // 사용자가 취소 → 조용히 끝낸다.
1268
+ const short = api.asInsufficientCreditsError(err);
1269
+ setError(
1270
+ short
1271
+ ? short.message
1272
+ : err instanceof Error
1273
+ ? err.message
1274
+ : t("detailPage.properties.gifFailed"),
1275
+ );
1276
+ } finally {
1277
+ abortRef.current = null;
1278
+ setBusy(false);
1279
+ setStage(null);
1280
+ }
1281
+ }, [busy, el, effect, onGenerate, shape, assetKind, store, t]);
1282
+
1283
+ const busyFallback = t("detailPage.properties.gifBusy");
1284
+ const busyLabel = stage
1285
+ ? stage.stage === "rendering" && stage.progress > 0
1286
+ ? `${t(IMAGE_GIF_STAGE_KEY.rendering)} ${stage.progress}%`
1287
+ : stage.stage in IMAGE_GIF_STAGE_KEY
1288
+ ? t(IMAGE_GIF_STAGE_KEY[stage.stage])
1289
+ : busyFallback
1290
+ : busyFallback;
1291
+
1292
+ return (
1293
+ <Section
1294
+ title={
1295
+ title ??
1296
+ t("detailPage.properties.imageGif", {
1297
+ defaultValue: "이미지를 GIF로",
1298
+ })
1299
+ }
1300
+ >
1301
+ <div className="grid grid-cols-[1fr_auto] gap-2">
1302
+ <GifEffectPicker
1303
+ value={effect}
1304
+ options={effects}
1305
+ onChange={setEffect}
1306
+ disabled={busy}
1307
+ />
1308
+ <button
1309
+ type="button"
1310
+ onClick={run}
1311
+ disabled={busy}
1312
+ className="inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-neutral-700 disabled:cursor-not-allowed disabled:opacity-50"
1313
+ >
1314
+ <Film size={14} />
1315
+ {busy
1316
+ ? busyLabel
1317
+ : t("detailPage.properties.imageGifMake", {
1318
+ defaultValue: "GIF 만들기",
1319
+ })}
1320
+ {!busy && creditCost ? ` · ${creditCost}` : ""}
1321
+ </button>
1322
+ </div>
1323
+ {busy ? (
1324
+ <button
1325
+ type="button"
1326
+ onClick={() => abortRef.current?.abort()}
1327
+ className="mt-1.5 text-[11px] text-neutral-400 underline hover:text-neutral-600"
1328
+ >
1329
+ {t("detailPage.properties.imageGifCancel", {
1330
+ defaultValue: "취소 (만들던 GIF는 '내 이미지'에 저장돼요)",
1331
+ })}
1332
+ </button>
1333
+ ) : error ? (
1334
+ <p className="mt-1.5 text-[11px] text-red-500">{error}</p>
1335
+ ) : notice ? (
1336
+ <p className="mt-1.5 text-[11px] text-amber-600">{notice}</p>
1337
+ ) : (
1338
+ <p className="mt-1.5 text-[11px] text-neutral-400">
1339
+ {selected?.hint ?? hint ?? ""}
1340
+ </p>
1341
+ )}
1342
+ </Section>
1343
+ );
1344
+ });
1345
+
1346
+ /**
1347
+ * 도형을 GIF로 — 벡터를 투명 PNG로 구워 이미지 이펙트 파이프라인에 태운다.
1348
+ *
1349
+ * 예를 들어 가로 막대에 와이프를 걸면 왼쪽에서 오른쪽으로 차오르는, 수치가 늘어나는
1350
+ * 듯한 연출이 된다. 렌더는 이미지 GIF와 같은 잡을 쓰고, 결과만 브랜드 GIF의 도형
1351
+ * 구획으로 갈린다.
1352
+ */
1353
+ const ShapeGifSection = observer(function ShapeGifSection(props: {
1354
+ store: StoreLike;
1355
+ el: ElementLike;
1356
+ onGenerate: GenerateImageGifFn;
1357
+ creditCost?: number;
1358
+ }) {
1359
+ const { t } = useTranslation("branding");
1360
+ return (
1361
+ <ImageGifSection
1362
+ {...props}
1363
+ assetKind="shape"
1364
+ title={t("detailPage.properties.shapeGif", {
1365
+ defaultValue: "도형을 GIF로",
1366
+ })}
1367
+ hint={t("detailPage.properties.shapeGifHint", {
1368
+ defaultValue: "배경 없는 GIF로 만들어져 브랜드 GIF에 저장돼요.",
1369
+ })}
1370
+ />
1371
+ );
1372
+ });
1373
+
1374
+ /** 편집기가 접어 보여주는 줄 그대로 재는 폭 측정기(브라우저 canvas). */
1375
+ function measureForGif() {
1376
+ return createCanvasMeasure() ?? estimateMeasure;
1377
+ }
1378
+
1379
+ /**
1380
+ * 텍스트 요소들 → GIF 요청의 줄 목록(위→아래), **원본 상자 좌표까지 실측**.
1381
+ *
1382
+ * 요소 하나 안의 줄바꿈은 물론 상자 폭에서 자동으로 접힌 줄까지 쪼갠다 — SVG ``<text>``는
1383
+ * 개행도 접기도 안 하기 때문에, 안 쪼개면 두 줄짜리 헤드라인이 한 줄로 늘어져 나온다.
1384
+ */
1385
+ export function textGifLines(els: ElementLike[], box: Box) {
1386
+ return layoutTextLines(els as TextElementLike[], box, measureForGif()).map(
1387
+ (line) => ({
1388
+ ...line,
1389
+ // el.fill 은 `rgb(23, 21, 15)` 로도 온다. 백엔드는 SVG 속성에 그대로 박으므로 HEX만 받는다.
1390
+ color: toHexColor(line.color, "#26221e"),
1391
+ }),
1392
+ );
1393
+ }
1394
+
1395
+ /** 서버 스키마의 줄 개수 상한. 넘으면 422가 나므로 미리 안내한다. */
1396
+ const TEXT_GIF_MAX_LINES = 24;
1397
+
1398
+ const TextGifSection = observer(function TextGifSection({
1399
+ store,
1400
+ els,
1401
+ targets,
1402
+ onGenerate,
1403
+ creditCost,
1404
+ }: {
1405
+ store: StoreLike;
1406
+ /** 단일 텍스트, 또는 텍스트만 든 그룹의 자식들(통째로 한 장의 GIF가 된다). */
1407
+ els: ElementLike[];
1408
+ /**
1409
+ * GIF가 대체할 요소들. 그룹이면 자식이 아니라 **그룹 자체**를 지워야 빈 껍데기가
1410
+ * 안 남는다. 생략하면 ``els``.
1411
+ */
1412
+ targets?: ElementLike[];
1413
+ onGenerate: GenerateTextGifFn;
1414
+ creditCost?: number;
1415
+ }) {
1416
+ const { t } = useTranslation("branding");
1417
+ const { api, brand } = useDetailPageHost();
1418
+ const effects = useMemo(() => textGifEffects(t), [t]);
1419
+ const [effect, setEffect] = useState("shimmer");
1420
+ const [busy, setBusy] = useState(false);
1421
+ const [error, setError] = useState<string | null>(null);
1422
+ const replaced = targets ?? els;
1423
+ // 상자·줄 실측은 매 렌더 다시 하지 않는다(측정은 canvas를 타고, run 의존성도 흔든다).
1424
+ const box = useMemo(
1425
+ () => unionBox(replaced as ReplaceElementLike[]),
1426
+ [replaced],
1427
+ );
1428
+ const lines = useMemo(() => (box ? textGifLines(els, box) : []), [els, box]);
1429
+ const first = lines[0];
1430
+
1431
+ const run = useCallback(async () => {
1432
+ if (!first || !box || busy) return;
1433
+ if (lines.length > TEXT_GIF_MAX_LINES) {
1434
+ setError(
1435
+ t("detailPage.properties.textGifTooManyLines", {
1436
+ defaultValue: "문구가 너무 길어요. 더 짧은 텍스트를 골라 주세요.",
1437
+ }),
1438
+ );
1439
+ return;
1440
+ }
1441
+ setBusy(true);
1442
+ setError(null);
1443
+ // 상자 밖으로 번지는 이펙트(글로우·물결)가 잘리지 않게 두는 여백. 서버와 편집기가
1444
+ // **같은 값**을 써야 글자가 제자리에 온다.
1445
+ const bleed = gifBleed(lines);
1446
+ try {
1447
+ // brandId 를 주면 서버가 브랜드 자산 버킷에 직접 쓴다. 예전처럼 결과 URL을
1448
+ // 다시 내려받아 재업로드하지 않는다 — 그 왕복이 S3 CORS를 두 번 타서, 한 번만
1449
+ // 막혀도 GIF는 만들어졌는데 브랜드 버킷엔 아무것도 안 남았다.
1450
+ const urls = await onGenerate({
1451
+ // 서버 스키마의 text 는 60자 상한이다(실제로 그려지는 건 lines 쪽).
1452
+ text: first.text.slice(0, 60),
1453
+ effect,
1454
+ color: first.color,
1455
+ background: pageBackgroundColor(store),
1456
+ fontSize: first.fontSize,
1457
+ fontWeight: first.fontWeight,
1458
+ fontFamily: first.fontFamily,
1459
+ lines,
1460
+ // 폰트 파일 주소까지 같이 보낸다 — 서버 컨테이너엔 우리 폰트가 없어서
1461
+ // 이름만 보내면 시스템 폴백(픽셀 폰트)으로 그려진다.
1462
+ fonts: resolveGifWebFonts(
1463
+ lines.map((line) => ({
1464
+ family: line.fontFamily,
1465
+ weight: line.fontWeight,
1466
+ })),
1467
+ ),
1468
+ // 원본 상자를 그대로 넘긴다 — 서버가 캔버스를 글자 수로 추정하면 결과 비율이
1469
+ // 달라져서, 되꽂을 때 글자가 커지고 줄이 밀린다.
1470
+ boxWidth: box.width,
1471
+ boxHeight: box.height,
1472
+ bleed,
1473
+ brandId: brand.getStoredActiveBrandId() ?? undefined,
1474
+ });
1475
+ const url = urls[0];
1476
+ if (url) {
1477
+ // 원본을 지우고 그 자리·그 크기로 갈아 끼운다(여백만큼만 넓게).
1478
+ replaceWithGif(store, replaced as ReplaceElementLike[], url, { bleed });
1479
+ }
1480
+ } catch (err) {
1481
+ const short = api.asInsufficientCreditsError(err);
1482
+ setError(
1483
+ short
1484
+ ? short.message
1485
+ : err instanceof Error
1486
+ ? err.message
1487
+ : t("detailPage.properties.gifFailed"),
1488
+ );
1489
+ } finally {
1490
+ setBusy(false);
1491
+ }
1492
+ }, [first, box, lines, replaced, busy, onGenerate, effect, store, t]);
1493
+
1494
+ return (
1495
+ <Section
1496
+ title={t("detailPage.properties.textGif", { defaultValue: "텍스트를 GIF로" })}
1497
+ >
1498
+ <div className="grid grid-cols-[1fr_auto] gap-2">
1499
+ <GifEffectPicker
1500
+ value={effect}
1501
+ options={effects}
1502
+ onChange={setEffect}
1503
+ disabled={busy}
1504
+ />
1505
+ <button
1506
+ type="button"
1507
+ onClick={run}
1508
+ disabled={busy || !first}
1509
+ className="inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-neutral-700 disabled:cursor-not-allowed disabled:opacity-50"
1510
+ >
1511
+ <Film size={14} />
1512
+ {busy
1513
+ ? t("detailPage.properties.textGifBusy", { defaultValue: "만드는 중…" })
1514
+ : t("detailPage.properties.textGifMake", { defaultValue: "GIF 만들기" })}
1515
+ {creditCost ? ` · ${creditCost}` : ""}
1516
+ </button>
1517
+ </div>
1518
+ {error ? (
1519
+ <p className="mt-1.5 text-[11px] text-red-500">{error}</p>
1520
+ ) : (
1521
+ <p className="mt-1.5 text-[11px] text-neutral-400">
1522
+ {lines.length > 1
1523
+ ? t("detailPage.properties.textGifGroupHint", {
1524
+ defaultValue:
1525
+ "묶인 문구 {{count}}줄이 한 장의 투명 배경 GIF로 만들어져요.",
1526
+ count: lines.length,
1527
+ })
1528
+ : t("detailPage.properties.textGifHint", {
1529
+ defaultValue:
1530
+ "선택한 문구가 배경 없는 GIF로 '내 이미지'에 저장돼요.",
1531
+ })}
1532
+ </p>
1533
+ )}
1534
+ </Section>
1535
+ );
1536
+ });
1537
+
1538
+ /** 칸 모양 선택지. 서버 `GET /images/data-gif-effects` 의 SHAPES 와 같아야 한다. */
1539
+ const CELL_SHAPES: Array<{ id: string; label: string }> = [
1540
+ { id: "circle", label: "동그라미" },
1541
+ { id: "square", label: "네모" },
1542
+ { id: "rounded", label: "둥근 네모" },
1543
+ { id: "diamond", label: "마름모" },
1544
+ { id: "hexagon", label: "육각형" },
1545
+ ];
1546
+
1547
+ /**
1548
+ * 숫자를 카운트업 GIF로 — 선택한 텍스트에서 값을 읽어 **제자리에** 갈아 끼운다.
1549
+ *
1550
+ * 입력 폼을 따로 두지 않는 게 핵심이다. 캔버스에 이미 적어 둔 "279.45%"를 고르면 목표값·
1551
+ * 소수 자릿수·접미사를 거기서 읽는다 — 숫자를 두 번 적게 하면 캔버스 값과 GIF 값이 어긋난
1552
+ * 채로 배포된다. 색·크기·굵기·이탤릭·하이라이트도 요소에서 그대로 가져온다.
1553
+ *
1554
+ * 숫자가 없는 문구면 섹션을 아예 감춘다(눌러봐야 헛것이 나온다).
1555
+ */
1556
+ const CountUpGifSection = observer(function CountUpGifSection({
1557
+ store,
1558
+ els,
1559
+ targets,
1560
+ onGenerate,
1561
+ creditCost,
1562
+ }: {
1563
+ store: StoreLike;
1564
+ els: ElementLike[];
1565
+ /** GIF가 대체할 요소들(그룹이면 그룹 자체). 생략하면 ``els``. */
1566
+ targets?: ElementLike[];
1567
+ onGenerate: GenerateDataGifFn;
1568
+ creditCost?: number;
1569
+ }) {
1570
+ const { t } = useTranslation("branding");
1571
+ const { api, brand } = useDetailPageHost();
1572
+ const [busy, setBusy] = useState(false);
1573
+ const [error, setError] = useState<string | null>(null);
1574
+ const replaced = targets ?? els;
1575
+ const first = els[0];
1576
+ const text = str(first?.text);
1577
+ const parsed = useMemo(() => parseCountUpText(text), [text]);
1578
+ const box = useMemo(
1579
+ () => unionBox(replaced as ReplaceElementLike[]),
1580
+ [replaced],
1581
+ );
1582
+
1583
+ const run = useCallback(async () => {
1584
+ if (!parsed || !first || !box || busy) return;
1585
+ setBusy(true);
1586
+ setError(null);
1587
+ try {
1588
+ const fontFamily = str(first.fontFamily);
1589
+ // Canvas 는 굵기를 `"normal"`·`"bold"`·`"700"` 처럼 **문자열**로 들고 있고, 아예
1590
+ // 안 들고 있으면 굵기가 `fontStyle` 쪽에 실린다. 그냥 Number() 하면 `"normal"` 이
1591
+ // NaN 이라 기본값으로 떨어져, 보통 굵기 숫자가 GIF 에서 전부 ExtraBold 로 굳었다.
1592
+ const fontWeight = toFontWeight(first.fontWeight ?? first.fontStyle);
1593
+ const urls = await onGenerate({
1594
+ kind: "count_up",
1595
+ ...parsed,
1596
+ color: toHexColor(str(first.fill), "#111111"),
1597
+ fontSize: Math.round(num(first.fontSize, 42)),
1598
+ fontWeight,
1599
+ fontFamily,
1600
+ letterSpacing: num(first.letterSpacing, 0),
1601
+ // 상자가 글자보다 넓으면 정렬이 곧 자리다 — 안 넘기면 왼쪽에 적어 둔 숫자가
1602
+ // GIF 안에서 가운데로 옮겨 앉는다.
1603
+ anchor: textAnchorOf(first.align),
1604
+ italic: /italic/i.test(str(first.fontStyle)),
1605
+ // 하이라이트가 걸린 숫자는 그 띠를 GIF 안에 굽는다 — CSS로 두고 글자만 투명
1606
+ // 위에 얹으면 1비트 알파가 글자 윗동을 매트색으로 잘라 먹는다.
1607
+ marker: first.backgroundEnabled
1608
+ ? toHexColor(str(first.backgroundColor), "#f7f14a")
1609
+ : "",
1610
+ // 원본 상자를 그대로 넘긴다 — 서버가 글자 수로 캔버스를 추정하면 되꽂을 때
1611
+ // 글자 크기가 달라진다.
1612
+ width: Math.round(box.width),
1613
+ height: Math.round(box.height),
1614
+ background: pageBackgroundColor(store),
1615
+ transparent: true,
1616
+ // 폰트 파일 주소까지 보낸다. 이름만 보내면 서버 컨테이너에 그 폰트가 없어
1617
+ // 시스템 폴백(픽셀 폰트)으로 그려진다.
1618
+ fonts: resolveGifWebFonts([{ family: fontFamily, weight: fontWeight }]),
1619
+ brandId: brand.getStoredActiveBrandId() ?? undefined,
1620
+ });
1621
+ const url = urls[0];
1622
+ if (url) replaceWithGif(store, replaced as ReplaceElementLike[], url);
1623
+ } catch (err) {
1624
+ const short = api.asInsufficientCreditsError(err);
1625
+ setError(
1626
+ short
1627
+ ? short.message
1628
+ : err instanceof Error
1629
+ ? err.message
1630
+ : t("detailPage.properties.gifFailed"),
1631
+ );
1632
+ } finally {
1633
+ setBusy(false);
1634
+ }
1635
+ }, [parsed, first, box, replaced, busy, onGenerate, store, t]);
1636
+
1637
+ if (!parsed) return null;
1638
+
1639
+ return (
1640
+ <Section
1641
+ title={t("detailPage.properties.countUpGif", {
1642
+ defaultValue: "숫자를 카운트업으로",
1643
+ })}
1644
+ >
1645
+ <button
1646
+ type="button"
1647
+ onClick={run}
1648
+ disabled={busy}
1649
+ className="inline-flex w-full items-center justify-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-neutral-700 disabled:cursor-not-allowed disabled:opacity-50"
1650
+ >
1651
+ <Film size={14} />
1652
+ {busy
1653
+ ? t("detailPage.properties.textGifBusy", { defaultValue: "만드는 중…" })
1654
+ : t("detailPage.properties.countUpGifMake", {
1655
+ defaultValue: "카운트업 GIF 만들기",
1656
+ })}
1657
+ {creditCost ? ` · ${creditCost}` : ""}
1658
+ </button>
1659
+ {error ? (
1660
+ <p className="mt-1.5 text-[11px] text-red-500">{error}</p>
1661
+ ) : (
1662
+ <p className="mt-1.5 text-[11px] text-neutral-400">
1663
+ {t("detailPage.properties.countUpGifHint", {
1664
+ defaultValue:
1665
+ "0에서 {{target}}까지 오른 뒤 2초 멈췄다 반복해요. 이 자리에 그대로 들어갑니다.",
1666
+ target: `${parsed.prefix}${parsed.to.toLocaleString(undefined, {
1667
+ minimumFractionDigits: parsed.decimals,
1668
+ maximumFractionDigits: parsed.decimals,
1669
+ useGrouping: parsed.grouping,
1670
+ })}${parsed.suffix}`,
1671
+ })}
1672
+ </p>
1673
+ )}
1674
+ </Section>
1675
+ );
1676
+ });
1677
+
1678
+ /**
1679
+ * 셀 차오름 GIF(도트 차트·표 셀·매트릭스) — 새 요소로 캔버스 가운데에 넣는다.
1680
+ *
1681
+ * 카운트업과 달리 원본이 될 요소가 없어서 선택 인스펙터에는 놓을 자리가 없다. 아무것도
1682
+ * 안 골랐을 때 뜨는 페이지 인스펙터가 "새로 만든다"는 뜻과 맞다.
1683
+ */
1684
+ const CellGridGifSection = observer(function CellGridGifSection({
1685
+ store,
1686
+ onGenerate,
1687
+ creditCost,
1688
+ }: {
1689
+ store: StoreLike;
1690
+ onGenerate: GenerateDataGifFn;
1691
+ creditCost?: number;
1692
+ }) {
1693
+ const { t } = useTranslation("branding");
1694
+ const { api, brand } = useDetailPageHost();
1695
+ const [rows, setRows] = useState("6,4,2");
1696
+ const [cols, setCols] = useState(8);
1697
+ const [shape, setShape] = useState("circle");
1698
+ const [fill, setFill] = useState("#1b6fd4");
1699
+ const [empty, setEmpty] = useState("#e4e4e4");
1700
+ const [busy, setBusy] = useState(false);
1701
+ const [error, setError] = useState<string | null>(null);
1702
+
1703
+ const filled = useMemo(() => parseFilledRows(rows), [rows]);
1704
+ // 칸 수보다 많이 채우라는 요청은 서버가 422로 되돌린다 — 버튼에서 미리 막는다.
1705
+ const invalid = filled.length === 0 || filled.some((n) => n > cols);
1706
+
1707
+ const run = useCallback(async () => {
1708
+ if (invalid || busy) return;
1709
+ setBusy(true);
1710
+ setError(null);
1711
+ try {
1712
+ const urls = await onGenerate({
1713
+ kind: "cell_grid",
1714
+ filled,
1715
+ cols,
1716
+ shape,
1717
+ fill,
1718
+ empty,
1719
+ background: pageBackgroundColor(store),
1720
+ transparent: true,
1721
+ brandId: brand.getStoredActiveBrandId() ?? undefined,
1722
+ });
1723
+ const url = urls[0];
1724
+ if (url) insertPersonalImage(store, url, { isGif: true });
1725
+ } catch (err) {
1726
+ const short = api.asInsufficientCreditsError(err);
1727
+ setError(
1728
+ short
1729
+ ? short.message
1730
+ : err instanceof Error
1731
+ ? err.message
1732
+ : t("detailPage.properties.gifFailed"),
1733
+ );
1734
+ } finally {
1735
+ setBusy(false);
1736
+ }
1737
+ }, [invalid, busy, onGenerate, filled, cols, shape, fill, empty, store, t]);
1738
+
1739
+ return (
1740
+ <Section
1741
+ title={t("detailPage.properties.cellGridGif", {
1742
+ defaultValue: "셀 차오름 GIF",
1743
+ })}
1744
+ >
1745
+ <div className="grid grid-cols-2 gap-2">
1746
+ <label className="flex flex-col gap-1">
1747
+ <span className="text-[11px] text-neutral-500">
1748
+ {t("detailPage.properties.cellGridRows", {
1749
+ defaultValue: "행별 채울 칸",
1750
+ })}
1751
+ </span>
1752
+ <input
1753
+ value={rows}
1754
+ onChange={(e) => setRows(e.target.value)}
1755
+ placeholder="6,4,2"
1756
+ className="h-8 rounded-md border border-neutral-200 px-2 text-sm"
1757
+ />
1758
+ </label>
1759
+ <label className="flex flex-col gap-1">
1760
+ <span className="text-[11px] text-neutral-500">
1761
+ {t("detailPage.properties.cellGridCols", { defaultValue: "열 수" })}
1762
+ </span>
1763
+ <input
1764
+ type="number"
1765
+ min={1}
1766
+ max={40}
1767
+ value={cols}
1768
+ onChange={(e) => setCols(Math.max(1, Number(e.target.value) || 1))}
1769
+ className="h-8 rounded-md border border-neutral-200 px-2 text-sm"
1770
+ />
1771
+ </label>
1772
+ </div>
1773
+ <div className="mt-2 grid grid-cols-[1fr_auto_auto] items-end gap-2">
1774
+ <label className="flex flex-col gap-1">
1775
+ <span className="text-[11px] text-neutral-500">
1776
+ {t("detailPage.properties.cellGridShape", { defaultValue: "모양" })}
1777
+ </span>
1778
+ <select
1779
+ value={shape}
1780
+ onChange={(e) => setShape(e.target.value)}
1781
+ className="h-8 rounded-md border border-neutral-200 px-2 text-sm"
1782
+ >
1783
+ {CELL_SHAPES.map((option) => (
1784
+ <option key={option.id} value={option.id}>
1785
+ {option.label}
1786
+ </option>
1787
+ ))}
1788
+ </select>
1789
+ </label>
1790
+ <ColorInput value={fill} onChange={setFill} />
1791
+ <ColorInput value={empty} onChange={setEmpty} />
1792
+ </div>
1793
+ <button
1794
+ type="button"
1795
+ onClick={run}
1796
+ disabled={busy || invalid}
1797
+ className="mt-2 inline-flex w-full items-center justify-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-neutral-700 disabled:cursor-not-allowed disabled:opacity-50"
1798
+ >
1799
+ <Film size={14} />
1800
+ {busy
1801
+ ? t("detailPage.properties.textGifBusy", { defaultValue: "만드는 중…" })
1802
+ : t("detailPage.properties.cellGridGifMake", {
1803
+ defaultValue: "차트 GIF 만들기",
1804
+ })}
1805
+ {creditCost ? ` · ${creditCost}` : ""}
1806
+ </button>
1807
+ <p className="mt-1.5 text-[11px] text-neutral-400">
1808
+ {error ??
1809
+ (invalid
1810
+ ? t("detailPage.properties.cellGridInvalid", {
1811
+ defaultValue: "행별 칸 수는 1개 이상이고 열 수를 넘을 수 없어요.",
1812
+ })
1813
+ : t("detailPage.properties.cellGridGifHint", {
1814
+ defaultValue:
1815
+ "채운 칸이 순서대로 켜진 뒤 2초 멈췄다 반복해요. 축·수치 글자는 캔버스에서 위에 얹으세요.",
1816
+ }))}
1817
+ </p>
1818
+ </Section>
1819
+ );
1820
+ });
1821
+
1822
+ const ImageInspector = observer(function ImageInspector({
1823
+ store,
1824
+ els,
1825
+ generatedId,
1826
+ isGif = false,
1827
+ imageCreditCost,
1828
+ imageCreditBalance,
1829
+ imageCostByTier,
1830
+ onBuyMore,
1831
+ onGenerateGif,
1832
+ gifCreditCost,
1833
+ onGenerateImageGif,
1834
+ imageGifCreditCost,
1835
+ onRemoveBackground,
1836
+ bgRemoveCreditCost,
1837
+ }: {
1838
+ store: StoreLike;
1839
+ els: ElementLike[];
1840
+ generatedId?: string;
1841
+ /** 선택 요소가 GIF면 AI 편집을 GIF 재생성으로 기본 전환하고 라벨을 GIF로 바꾼다. */
1842
+ isGif?: boolean;
1843
+ imageCreditCost?: number;
1844
+ imageCreditBalance?: number;
1845
+ imageCostByTier?: Partial<Record<ImageTier, number>>;
1846
+ onBuyMore?: () => void;
1847
+ /** 선택 이미지를 참조로 GIF 생성. 미지정이면 GIF 토글이 뜨지 않는다. */
1848
+ onGenerateGif?: GenerateGifFn;
1849
+ /** GIF 1회 비용(크레딧). */
1850
+ gifCreditCost?: number;
1851
+ /** 선택 이미지에 이펙트를 걸어 GIF로. 미지정이면 섹션이 뜨지 않는다. */
1852
+ onGenerateImageGif?: GenerateImageGifFn;
1853
+ /** 이미지 GIF 1회 비용(크레딧). */
1854
+ imageGifCreditCost?: number;
1855
+ /** 선택 이미지의 배경을 지운다. 미지정이면 섹션이 뜨지 않는다. */
1856
+ onRemoveBackground?: RemoveBackgroundFn;
1857
+ /** 배경 제거 1회 비용(크레딧). */
1858
+ bgRemoveCreditCost?: number;
1859
+ }) {
1860
+ const { t } = useTranslation("branding");
1861
+ const { api } = useDetailPageHost();
1862
+ const single = els.length === 1 ? els[0] : null;
1863
+ const ref = els[0];
1864
+ const radius = num(ref.cornerRadius, 0);
1865
+ const singleCustom = (single?.custom ?? {}) as Record<string, unknown>;
1866
+ const slotRole =
1867
+ typeof singleCustom.leviosaSlot === "string" ? singleCustom.leviosaSlot : "";
1868
+
1869
+ // 우측 실시간 편집: 선택 이미지를 base로 프롬프트 방향으로 재생성(크레딧 과금).
1870
+ // data URI면 base64로, http(s) URL이면 그대로 넘긴다. 402는 크레딧 부족 마커로 승격.
1871
+ const editImage = useCallback<GenerateImageFn>(
1872
+ async ({ prompt, tier, brandId, annotatedImage }) => {
1873
+ if (!single || !generatedId) return [];
1874
+ const src = str(single.src);
1875
+ const isData = src.startsWith("data:");
1876
+ try {
1877
+ const res = await api.promptEditDetailPageImage(generatedId, {
1878
+ slot_role: slotRole,
1879
+ current_image_url: isData ? undefined : src,
1880
+ current_image_base64: isData ? src.split(",")[1] : undefined,
1881
+ instruction: prompt,
1882
+ // 마킹본은 원본과 **함께** 간다. 마킹만 보내면 모델이 빨간 자국을 그림의
1883
+ // 일부로 읽는다 — 서버 계약이 막으려던 바로 그 실패다.
1884
+ annotated_image: annotatedImage,
1885
+ tier,
1886
+ brand_id: brandId,
1887
+ });
1888
+ return res.url ? [res.url] : [];
1889
+ } catch (err) {
1890
+ const short = api.asInsufficientCreditsError(err);
1891
+ if (short) {
1892
+ throw Object.assign(new Error(short.message), {
1893
+ insufficientCredits: true,
1894
+ });
1895
+ }
1896
+ throw err;
1897
+ }
1898
+ },
1899
+ [single, generatedId, slotRole],
1900
+ );
1901
+
1902
+ // 선택 이미지를 레퍼런스로 넣어 GIF 생성. 좌측 자유 생성과 달리 지금 고른 이미지를
1903
+ // 그대로 input으로 참조한다. GIF는 원본을 교체하지 않고 새 요소로 삽입된다(패널이
1904
+ // insertPersonalImage 사용). 백엔드 load_reference_bytes는 data:/http(s) 둘 다 받으므로,
1905
+ // 편집기 src(상대경로·blob·data·동일출처 프록시)를 data URI로 변환해 넘긴다(alpha 보존).
1906
+ // 교차출처 http(s)만 원본 URL로 넘겨 백엔드가 직접 내려받게 한다.
1907
+ const editGif = useCallback<GenerateGifFn>(
1908
+ async ({ prompt, referenceImages, transparent, brandId }) => {
1909
+ if (!single || !onGenerateGif) return [];
1910
+ const ref = await resolveReferenceSrc(str(single.src));
1911
+ const refs = ref ? [ref, ...referenceImages] : referenceImages;
1912
+ return onGenerateGif({
1913
+ prompt,
1914
+ referenceImages: refs,
1915
+ transparent,
1916
+ brandId,
1917
+ });
1918
+ },
1919
+ [single, onGenerateGif],
1920
+ );
1921
+
1922
+ return (
1923
+ <>
1924
+ <Section title={t("detailPage.properties.cornerRadius")}>
1925
+ <div className="flex items-center gap-3">
1926
+ <input
1927
+ type="range"
1928
+ min={0}
1929
+ max={200}
1930
+ value={radius}
1931
+ onChange={(e) => setAll(els, { cornerRadius: Number(e.target.value) })}
1932
+ className="min-w-0 flex-1 accent-neutral-900"
1933
+ />
1934
+ <span className="w-12 text-right text-sm tabular-nums text-neutral-700">
1935
+ {radius}px
1936
+ </span>
1937
+ </div>
1938
+ </Section>
1939
+ <OpacityRow els={els} />
1940
+ {/* 배경 지우기 — 선택 사진의 배경을 지워 그 자리에서 컷아웃으로 갈아 끼운다.
1941
+ GIF에는 숨긴다(프레임마다 지워야 해서 같은 경로로 처리되지 않는다). */}
1942
+ {single && !isGif && onRemoveBackground ? (
1943
+ <BgRemoveSection
1944
+ el={single}
1945
+ onRemove={onRemoveBackground}
1946
+ creditCost={bgRemoveCreditCost}
1947
+ />
1948
+ ) : null}
1949
+ {/* 이미지를 GIF로 — 선택 이미지에 이펙트를 걸어 새 GIF 요소로 삽입한다.
1950
+ 이미 GIF인 요소에는 숨긴다(GIF에 GIF를 다시 굽지 않게). */}
1951
+ {single && !isGif && onGenerateImageGif ? (
1952
+ <ImageGifSection
1953
+ store={store}
1954
+ el={single}
1955
+ onGenerate={onGenerateImageGif}
1956
+ creditCost={imageGifCreditCost}
1957
+ />
1958
+ ) : null}
1959
+ {/* AI 이미지 편집 — 선택된 이미지를 프롬프트로 실시간 재생성해 src를 교체한다.
1960
+ 비싸므로 크레딧을 과금하며, 생성 ID가 있을 때만 노출(픽스처는 자동 생략). */}
1961
+ {single && generatedId ? (
1962
+ <section className="border-t border-neutral-200">
1963
+ <h4 className="flex items-center gap-1.5 px-4 pt-3 text-[11px] font-semibold uppercase tracking-[0.06em] text-neutral-400">
1964
+ <Sparkles size={13} className="text-fuchsia-500" />
1965
+ {isGif
1966
+ ? t("detailPage.properties.aiGifEdit")
1967
+ : t("detailPage.properties.aiImageEdit")}
1968
+ </h4>
1969
+ <AiGeneratePanel
1970
+ store={store}
1971
+ onGenerate={editImage}
1972
+ onGenerateGif={onGenerateGif ? editGif : undefined}
1973
+ gifCreditCost={gifCreditCost}
1974
+ hasImplicitReference
1975
+ // 현재 선택된 이미지를 예시 입력(참조)으로 패널에 그대로 노출한다.
1976
+ implicitReferenceSrc={str(single.src)}
1977
+ // 같은 이미지 위에 그림으로 가리켜 고칠 수 있게 한다(마킹본 + 원본 두 장).
1978
+ annotateBaseSrc={str(single.src)}
1979
+ // GIF 요소를 편집 중이면 GIF 재생성 모드를 기본으로 연다.
1980
+ initialMode={isGif ? "gif" : "image"}
1981
+ onResult={(src) => single.set({ src })}
1982
+ costByTier={imageCostByTier}
1983
+ creditCost={imageCreditCost}
1984
+ creditBalance={imageCreditBalance}
1985
+ onBuyCredits={onBuyMore}
1986
+ />
1987
+ </section>
1988
+ ) : null}
1989
+ <DeleteRow store={store} els={els} />
1990
+ </>
1991
+ );
1992
+ });
1993
+
1994
+ // Canvas svg 요소는 마크업을 src에 data URI로 담는다(bubbleSvgDataUrl과 동일 인코딩).
1995
+ const svgToDataUri = encodeSvgDataUri;
1996
+
1997
+ // 선택 도형을 "내 도형"에 저장한다(개인 폴더, 공용 라이브러리와 분리). 실패해도
1998
+ // 조용히 넘어가지 않고 토스트로 알린다.
1999
+ async function saveShapeToMyShapes(
2000
+ host: DetailPageHost,
2001
+ markup: string,
2002
+ origin: string,
2003
+ t: (key: string) => string,
2004
+ { silent = false }: { silent?: boolean } = {},
2005
+ ): Promise<void> {
2006
+ try {
2007
+ const activeBrandId = host.brand.getStoredActiveBrandId();
2008
+ const res = activeBrandId
2009
+ ? {
2010
+ success: Boolean(
2011
+ await host.brand.uploadBrandAsset(
2012
+ activeBrandId,
2013
+ new File([markup], `canvas-shape-${Date.now()}.svg`, {
2014
+ type: "image/svg+xml",
2015
+ }),
2016
+ "shape",
2017
+ { metadata: { source: "canvas_shape", origin } },
2018
+ ),
2019
+ ),
2020
+ duplicate: false,
2021
+ message: undefined,
2022
+ }
2023
+ : await host.api.savePersonalDetailPageShape({ svg: markup, origin });
2024
+ if (silent) return; // 프롬프트 편집 자동 저장은 편집 성공 토스트와 겹치므로 조용히.
2025
+ if (res.success) {
2026
+ host.toast.success(
2027
+ res.duplicate
2028
+ ? t("detailPage.properties.shapeSavedDuplicate")
2029
+ : t("detailPage.properties.shapeSaved"),
2030
+ );
2031
+ } else if (res.message) {
2032
+ host.toast.error(res.message);
2033
+ }
2034
+ } catch {
2035
+ // 저장 실패는 편집 흐름을 막지 않는다.
2036
+ }
2037
+ }
2038
+
2039
+ // svg 도형(벡터 장식)용 인스펙터: 불투명도 + "내 도형에 저장" + 프롬프트 편집(생성
2040
+ // ID·디코드 가능한 마크업이 있을 때만) + 삭제. figure 등 마크업이 없는 도형은
2041
+ // 저장/프롬프트 편집을 숨긴다.
2042
+ /**
2043
+ * SVG 도형·아이콘의 색.
2044
+ *
2045
+ * 렌더러는 `colorsReplace`(`{바꿀색: 새색}`)를 오래전부터 읽고 있었는데 **그 값을 쓰는
2046
+ * UI가 하나도 없었다.** 그래서 도형을 넣으면 소스 색 그대로 박제됐고, 서식 복사로 다른
2047
+ * 도형의 색을 옮겨오는 우회로만 있었다. 여기가 그 반쪽을 채운다.
2048
+ *
2049
+ * 마크업에 실제로 쓰인 색을 뽑아 스와치로 세운다(단색 아이콘이면 하나, 다색 도형이면 N개).
2050
+ * 표기가 달라도 같은 색이면 한 스와치가 한꺼번에 바꾼다 — 정규화·치환 모두 렌더러 것을
2051
+ * 그대로 쓰기 때문이다.
2052
+ */
2053
+ const SvgColorSection = observer(function SvgColorSection({
2054
+ el,
2055
+ markup,
2056
+ }: {
2057
+ el: ElementLike;
2058
+ markup: string;
2059
+ }) {
2060
+ const { t } = useTranslation("branding");
2061
+ const originals = extractSvgColors(markup);
2062
+ const replaced = readColorReplace(el.colorsReplace);
2063
+
2064
+ if (!originals.length) return null;
2065
+
2066
+ const setColor = (from: string, to: string) => {
2067
+ const next: Record<string, string> = {};
2068
+ for (const [key, value] of replaced) next[key] = value;
2069
+ // 원래 색으로 되돌리면 항목을 지운다 — 빈 치환을 들고 다닐 이유가 없다.
2070
+ if (effectiveColor(from, new Map()) === to) delete next[from];
2071
+ else next[from] = to;
2072
+ el.set({ colorsReplace: next });
2073
+ };
2074
+
2075
+ return (
2076
+ <Section title={t("detailPage.properties.shapeColors")}>
2077
+ <div className="flex flex-col gap-2">
2078
+ {originals.map((original) => (
2079
+ <ColorInput
2080
+ key={original}
2081
+ value={effectiveColor(original, replaced)}
2082
+ onChange={(next) => setColor(original, next)}
2083
+ />
2084
+ ))}
2085
+ </div>
2086
+ {replaced.size ? (
2087
+ <button
2088
+ type="button"
2089
+ onClick={() => el.set({ colorsReplace: {} })}
2090
+ className="mt-2 w-full rounded-md border border-neutral-200 py-1.5 text-[11px] font-medium text-neutral-500 hover:border-neutral-400 hover:bg-neutral-50"
2091
+ >
2092
+ {t("detailPage.properties.shapeColorsReset")}
2093
+ </button>
2094
+ ) : null}
2095
+ </Section>
2096
+ );
2097
+ });
2098
+
2099
+ const SvgInspector = observer(function SvgInspector({
2100
+ store,
2101
+ els,
2102
+ generatedId,
2103
+ usage,
2104
+ onUsage,
2105
+ onBuyMore,
2106
+ onGenerateImageGif,
2107
+ imageGifCreditCost,
2108
+ }: {
2109
+ store: StoreLike;
2110
+ els: ElementLike[];
2111
+ generatedId?: string;
2112
+ usage?: EditUsageState;
2113
+ onUsage?: (used: number, limit: number) => void;
2114
+ onBuyMore?: () => void;
2115
+ onGenerateImageGif?: GenerateImageGifFn;
2116
+ imageGifCreditCost?: number;
2117
+ }) {
2118
+ const { t } = useTranslation("branding");
2119
+ const host = useDetailPageHost();
2120
+ const single = els.length === 1 ? els[0] : null;
2121
+ const currentSvg = single ? decodeSvgDataUri(str(single.src)) : null;
2122
+ const singleCustom = (single?.custom ?? {}) as Record<string, unknown>;
2123
+ const slotRole =
2124
+ typeof singleCustom.leviosaSlot === "string" ? singleCustom.leviosaSlot : "";
2125
+ return (
2126
+ <>
2127
+ {single && currentSvg ? (
2128
+ <SvgColorSection el={single} markup={currentSvg} />
2129
+ ) : null}
2130
+ <OpacityRow els={els} />
2131
+ {single && onGenerateImageGif ? (
2132
+ <ShapeGifSection
2133
+ store={store}
2134
+ el={single}
2135
+ onGenerate={onGenerateImageGif}
2136
+ creditCost={imageGifCreditCost}
2137
+ />
2138
+ ) : null}
2139
+ {single && currentSvg ? (
2140
+ <div className="border-t border-neutral-200 px-4 py-2">
2141
+ <button
2142
+ type="button"
2143
+ onClick={() => void saveShapeToMyShapes(host, currentSvg, "manual_save", t)}
2144
+ className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-neutral-200 py-2 text-xs font-medium text-neutral-600 hover:border-neutral-400 hover:bg-neutral-50"
2145
+ >
2146
+ <Shapes size={13} />
2147
+ {t("detailPage.properties.saveToMyShapes")}
2148
+ </button>
2149
+ </div>
2150
+ ) : null}
2151
+ {single && generatedId && currentSvg ? (
2152
+ <section className="border-t border-neutral-200 px-4 py-3">
2153
+ <h4 className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-neutral-400">
2154
+ <Sparkles size={13} className="text-primary" />
2155
+ {t("detailPage.properties.aiShapeEdit")}
2156
+ </h4>
2157
+ <SvgPromptEditPanel
2158
+ generatedId={generatedId}
2159
+ slotRole={slotRole}
2160
+ currentSvg={currentSvg}
2161
+ onApplied={(svg) => {
2162
+ single.set({ src: svgToDataUri(svg) });
2163
+ // 프롬프트로 편집한 결과는 "내 도형"에 자동 저장(재사용 가능하게).
2164
+ void saveShapeToMyShapes(host, svg, "prompt_edit", t, { silent: true });
2165
+ }}
2166
+ editsUsed={usage?.svgUsed}
2167
+ editLimit={usage?.svgLimit}
2168
+ unlimited={usage?.unlimited}
2169
+ onUsage={onUsage}
2170
+ onBuyMore={onBuyMore}
2171
+ />
2172
+ </section>
2173
+ ) : null}
2174
+ <DeleteRow store={store} els={els} />
2175
+ </>
2176
+ );
2177
+ });
2178
+
2179
+ // 그룹 인스펙터: 불투명도 + (생성 ID가 있고 편집 가능한 자식이 있으면) 그룹 편집 +
2180
+ // 그룹 해제/삭제. 그룹을 통째로 고른 채로 안에 든 텍스트·도형을 프롬프트 한 번으로
2181
+ // 함께 수정한다 — 프롬프트 박스는 딱 하나만 노출한다. 텍스트는 서로 어울리게 한 번에
2182
+ // 다시 쓰이고, 도형은 같은 지시로 각자 다시 그려진다. 이미지는 대상에서 제외한다.
2183
+ const GroupInspector = observer(function GroupInspector({
2184
+ store,
2185
+ els,
2186
+ generatedId,
2187
+ usage,
2188
+ applyUsage,
2189
+ onBuyMore,
2190
+ onGenerateTextGif,
2191
+ textGifCreditCost,
2192
+ }: {
2193
+ store: StoreLike;
2194
+ els: ElementLike[];
2195
+ generatedId?: string;
2196
+ usage?: EditUsageState;
2197
+ applyUsage?: (kind: "svg" | "text", used: number, limit: number) => void;
2198
+ onBuyMore?: () => void;
2199
+ /** 그룹 안이 전부 텍스트면 그룹째로 GIF를 굽는다(카피 그룹 편집과 같은 결). */
2200
+ onGenerateTextGif?: GenerateTextGifFn;
2201
+ textGifCreditCost?: number;
2202
+ }) {
2203
+ const { t } = useTranslation("branding");
2204
+ const host = useDetailPageHost();
2205
+ const members = collectEditableDescendants(els[0]);
2206
+ // 그룹 편집 요청 items + id→요소 매핑을 만든다(svg는 디코드 가능한 마크업이 있을 때만).
2207
+ const items: DetailPageGroupEditItem[] = [];
2208
+ const byId = new Map<string, ElementLike>();
2209
+ for (const el of members) {
2210
+ const custom = (el.custom ?? {}) as Record<string, unknown>;
2211
+ const slotRole =
2212
+ typeof custom.leviosaSlot === "string" ? custom.leviosaSlot : "";
2213
+ if (el.type === "text") {
2214
+ items.push({
2215
+ id: el.id,
2216
+ kind: "text",
2217
+ current_text: str(el.text),
2218
+ slot_role: slotRole,
2219
+ render_kind:
2220
+ typeof custom.leviosaSlotKind === "string"
2221
+ ? custom.leviosaSlotKind
2222
+ : undefined,
2223
+ });
2224
+ byId.set(el.id, el);
2225
+ } else if (el.type === "svg") {
2226
+ const svg = decodeSvgDataUri(str(el.src));
2227
+ if (svg) {
2228
+ items.push({ id: el.id, kind: "svg", current_svg: svg, slot_role: slotRole });
2229
+ byId.set(el.id, el);
2230
+ }
2231
+ }
2232
+ }
2233
+
2234
+ const applyResults = (results: DetailPageGroupEditResultItem[]) => {
2235
+ for (const r of results) {
2236
+ const el = byId.get(r.id);
2237
+ if (!el) continue;
2238
+ if (r.kind === "text" && typeof r.text === "string") {
2239
+ el.set({ text: r.text });
2240
+ } else if (r.kind === "svg" && typeof r.svg === "string") {
2241
+ el.set({ src: svgToDataUri(r.svg) });
2242
+ // 프롬프트로 편집한 도형은 "내 도형"에 자동 저장(재사용 가능하게).
2243
+ void saveShapeToMyShapes(host, r.svg, "prompt_edit", t, { silent: true });
2244
+ }
2245
+ }
2246
+ };
2247
+
2248
+ const hasText = items.some((i) => i.kind === "text");
2249
+ const hasSvg = items.some((i) => i.kind === "svg");
2250
+ const desc =
2251
+ hasText && hasSvg
2252
+ ? "그룹 안 텍스트와 도형을 한 번에 프롬프트로 수정합니다."
2253
+ : hasSvg
2254
+ ? "그룹 안 도형을 프롬프트로 함께 수정합니다."
2255
+ : "그룹 안 텍스트를 한 번에 프롬프트로 다시 씁니다.";
2256
+
2257
+ // 텍스트만 든 그룹만 GIF로 굽는다 — 도형·이미지가 섞이면 텍스트 렌더러가 그릴 수
2258
+ // 없는 것들이 조용히 빠져서 "일부만 담긴 GIF"가 나온다.
2259
+ const textOnlyGroup =
2260
+ members.length > 0 && members.every((member) => member.type === "text");
2261
+
2262
+ return (
2263
+ <>
2264
+ <OpacityRow els={els} />
2265
+ {textOnlyGroup && onGenerateTextGif ? (
2266
+ <TextGifSection
2267
+ store={store}
2268
+ els={members}
2269
+ // 그룹을 통째로 갈아 끼운다 — 자식만 지우면 빈 그룹이 남는다.
2270
+ targets={els}
2271
+ onGenerate={onGenerateTextGif}
2272
+ creditCost={textGifCreditCost}
2273
+ />
2274
+ ) : null}
2275
+ {generatedId && items.length > 0 ? (
2276
+ <section className="border-t border-neutral-200 px-4 py-3">
2277
+ <h4 className="mb-1 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-neutral-400">
2278
+ <Sparkles size={13} className="text-primary" />
2279
+ 그룹 편집
2280
+ </h4>
2281
+ <p className="mb-2 text-xs text-neutral-400">{desc}</p>
2282
+ <GroupPromptEditPanel
2283
+ generatedId={generatedId}
2284
+ items={items}
2285
+ onApplied={applyResults}
2286
+ textUsed={usage?.textUsed}
2287
+ textLimit={usage?.textLimit}
2288
+ svgUsed={usage?.svgUsed}
2289
+ svgLimit={usage?.svgLimit}
2290
+ unlimited={usage?.unlimited}
2291
+ onUsage={applyUsage}
2292
+ onBuyMore={onBuyMore}
2293
+ />
2294
+ </section>
2295
+ ) : null}
2296
+ <DeleteRow store={store} els={els} />
2297
+ </>
2298
+ );
2299
+ });
2300
+
2301
+ // 도형(figure) 인스펙터: 채우기(단색/그라데이션) + 모서리 둥글기 + 불투명도 + 삭제.
2302
+ // figure는 Canvas 네이티브 도형이라 ``fill``에 linear-gradient 문자열을 넣으면 useColor가
2303
+ // 그라데이션으로 렌더한다(svg 도형은 마크업에 색이 박혀 있어 SvgInspector가 담당).
2304
+ const FigureInspector = observer(function FigureInspector({
2305
+ store,
2306
+ els,
2307
+ onGenerateImageGif,
2308
+ imageGifCreditCost,
2309
+ }: {
2310
+ store: StoreLike;
2311
+ els: ElementLike[];
2312
+ onGenerateImageGif?: GenerateImageGifFn;
2313
+ imageGifCreditCost?: number;
2314
+ }) {
2315
+ const { t } = useTranslation("branding");
2316
+ const ref = els[0];
2317
+ const fill = str(ref.fill, "rgb(0, 161, 255)");
2318
+ const radius = num(ref.cornerRadius, 0);
2319
+ return (
2320
+ <>
2321
+ <Section title={t("detailPage.properties.color")}>
2322
+ <FillControl value={fill} onChange={(c) => setAll(els, { fill: c })} />
2323
+ </Section>
2324
+ <Section title={t("detailPage.properties.cornerRadius")}>
2325
+ <div className="flex items-center gap-3">
2326
+ <input
2327
+ type="range"
2328
+ min={0}
2329
+ max={200}
2330
+ value={radius}
2331
+ onChange={(e) => setAll(els, { cornerRadius: Number(e.target.value) })}
2332
+ className="min-w-0 flex-1 accent-neutral-900"
2333
+ />
2334
+ <span className="w-12 text-right text-sm tabular-nums text-neutral-700">
2335
+ {radius}px
2336
+ </span>
2337
+ </div>
2338
+ </Section>
2339
+ <OpacityRow els={els} />
2340
+ {els.length === 1 && onGenerateImageGif ? (
2341
+ <ShapeGifSection
2342
+ store={store}
2343
+ el={els[0]}
2344
+ onGenerate={onGenerateImageGif}
2345
+ creditCost={imageGifCreditCost}
2346
+ />
2347
+ ) : null}
2348
+ <DeleteRow store={store} els={els} />
2349
+ </>
2350
+ );
2351
+ });
2352
+
2353
+ /**
2354
+ * 이 화면(섹션)의 높이. 상세페이지 한 장은 세로로 이어 붙는 띠라서 장마다 길이가 다르고,
2355
+ * 그 길이 자체가 편집 대상이다 — "여기 좀 답답해요"의 답이 요소 배치가 아니라 높이일 때가
2356
+ * 많다. 캔버스 아래 손잡이로도 끌 수 있지만(``stacked-canvas-workspace``), 긴 화면은 아래
2357
+ * 끝이 화면 밖이라 숫자로 넣는 길이 항상 열려 있어야 한다.
2358
+ */
2359
+ const PageHeightSection = observer(function PageHeightSection({
2360
+ page,
2361
+ }: {
2362
+ page?: PageLike;
2363
+ }) {
2364
+ const { t } = useTranslation("branding");
2365
+ if (!page) return null;
2366
+ const height = Math.round(num(page.computedHeight, MIN_SECTION_HEIGHT));
2367
+ const contentBottom = sectionContentBottom(page);
2368
+ // 잘리는 것은 경고한다. 편집기 캔버스는 페이지 밖을 안 그리므로, 줄이는 순간 아래 내용이
2369
+ // "사라진" 것처럼 보인다 — 지운 게 아니라 화면 밖으로 나간 것이다.
2370
+ const overflow = contentBottom > height;
2371
+ return (
2372
+ <Section title={t("detailPage.properties.pageHeight")}>
2373
+ <div className="flex items-center gap-1.5">
2374
+ <div className="min-w-0 flex-1">
2375
+ <NumberField
2376
+ label="H"
2377
+ value={height}
2378
+ step={10}
2379
+ min={MIN_SECTION_HEIGHT}
2380
+ max={MAX_SECTION_HEIGHT}
2381
+ onChange={(v) => applySectionHeight(page, v)}
2382
+ />
2383
+ </div>
2384
+ <button
2385
+ type="button"
2386
+ disabled={contentBottom <= 0 || contentBottom === height}
2387
+ onClick={() => applySectionHeight(page, contentBottom)}
2388
+ className="h-9 shrink-0 rounded-md border border-neutral-200 bg-white px-2.5 text-xs font-semibold text-neutral-700 hover:bg-neutral-50 disabled:cursor-not-allowed disabled:text-neutral-300"
2389
+ >
2390
+ {t("detailPage.properties.pageHeightFit")}
2391
+ </button>
2392
+ </div>
2393
+ <p className="mt-2 text-xs text-neutral-400">
2394
+ {overflow
2395
+ ? t("detailPage.properties.pageHeightOverflow", { px: contentBottom })
2396
+ : t("detailPage.properties.pageHeightHint")}
2397
+ </p>
2398
+ </Section>
2399
+ );
2400
+ });
2401
+ PageHeightSection.displayName = "PageHeightSection";
2402
+
2403
+ const PageInspector = observer(function PageInspector({
2404
+ store,
2405
+ onGenerateDataGif,
2406
+ dataGifCreditCost,
2407
+ }: {
2408
+ store: StoreLike;
2409
+ /** 셀 차오름 GIF를 새 요소로 넣는다. 미지정이면 섹션 숨김. */
2410
+ onGenerateDataGif?: GenerateDataGifFn;
2411
+ dataGifCreditCost?: number;
2412
+ }) {
2413
+ const { t } = useTranslation("branding");
2414
+ const page = store.activePage ?? store.pages[0];
2415
+ const bg = str(page?.background, "#ffffff");
2416
+ return (
2417
+ <>
2418
+ <PageHeightSection page={page} />
2419
+ <Section title={t("detailPage.properties.pageBackground")}>
2420
+ <FillControl value={bg} onChange={(c) => page?.set?.({ background: c })} />
2421
+ <p className="mt-2 text-xs text-neutral-400">
2422
+ {t("detailPage.properties.pageBackgroundHint")}
2423
+ </p>
2424
+ </Section>
2425
+ {onGenerateDataGif ? (
2426
+ <CellGridGifSection
2427
+ store={store}
2428
+ onGenerate={onGenerateDataGif}
2429
+ creditCost={dataGifCreditCost}
2430
+ />
2431
+ ) : null}
2432
+ </>
2433
+ );
2434
+ });
2435
+
2436
+ // ── Header + root ─────────────────────────────────────────────────────────────
2437
+
2438
+ function InspectorHeader({ els }: { els: ElementLike[] }) {
2439
+ const { t } = useTranslation("branding");
2440
+ let icon = <Layers aria-hidden="true" size={16} />;
2441
+ let label = t("detailPage.properties.selectionNone");
2442
+ if (els.length === 1) {
2443
+ const type = els[0].type;
2444
+ if (isGifElement(els[0])) {
2445
+ icon = <Film aria-hidden="true" size={16} />;
2446
+ label = "GIF";
2447
+ } else if (type === "text") {
2448
+ icon = <TypeIcon aria-hidden="true" size={16} />;
2449
+ label = t("detailPage.properties.typeText");
2450
+ } else if (type === "image") {
2451
+ icon = <ImageIcon aria-hidden="true" size={16} />;
2452
+ label = t("detailPage.properties.typeImage");
2453
+ } else if (type === "svg" || type === "figure") {
2454
+ icon = <Square aria-hidden="true" size={16} />;
2455
+ label = t("detailPage.properties.typeShape");
2456
+ } else if (readChartSpec(els[0])) {
2457
+ icon = <BarChart3 aria-hidden="true" size={16} />;
2458
+ label = t("detailPage.chart.typeChart");
2459
+ } else if (readTableSpec(els[0])) {
2460
+ icon = <TableIcon aria-hidden="true" size={16} />;
2461
+ label = t("detailPage.table.typeTable");
2462
+ } else {
2463
+ label = type;
2464
+ }
2465
+ } else if (els.length > 1) {
2466
+ label = t("detailPage.properties.selectionCount", { count: els.length });
2467
+ }
2468
+ return (
2469
+ <div className="flex items-center gap-2 border-b border-neutral-200 px-4 py-3 text-sm font-semibold text-neutral-950">
2470
+ {icon}
2471
+ {label}
2472
+ </div>
2473
+ );
2474
+ }
2475
+
2476
+ export const DetailPageProperties = observer(function DetailPageProperties({
2477
+ store,
2478
+ generatedId,
2479
+ onBuyEditCredits,
2480
+ imageCreditCost,
2481
+ imageCreditBalance,
2482
+ imageCostByTier,
2483
+ onGenerateGif,
2484
+ gifCreditCost,
2485
+ onGenerateTextGif,
2486
+ textGifCreditCost,
2487
+ onGenerateImageGif,
2488
+ imageGifCreditCost,
2489
+ onGenerateDataGif,
2490
+ dataGifCreditCost,
2491
+ onRemoveBackground,
2492
+ bgRemoveCreditCost,
2493
+ }: {
2494
+ store: unknown;
2495
+ generatedId?: string;
2496
+ /** 편집 한도 소진 시 "편집 크레딧 추가하기" 목적지(레비오사 결제면). 미지정이면 CTA 비활성. */
2497
+ onBuyEditCredits?: () => void;
2498
+ /** 우측 AI 이미지 편집 1회 비용(크레딧). 0/미지정이면 크레딧 UI 숨김. */
2499
+ imageCreditCost?: number;
2500
+ /** 현재 보유 크레딧 잔액(1.5× 안전 마진 게이트용). */
2501
+ imageCreditBalance?: number;
2502
+ /** 티어별(basic/pro/max) 크레딧 단가. 모델 드롭다운의 각 항목 비용에 쓴다. */
2503
+ imageCostByTier?: Partial<Record<ImageTier, number>>;
2504
+ /** 선택 이미지를 참조로 GIF 생성(우측 패널 GIF 토글). 미지정이면 토글 숨김. */
2505
+ onGenerateGif?: GenerateGifFn;
2506
+ /** GIF 1회 비용(크레딧). */
2507
+ gifCreditCost?: number;
2508
+ /** 텍스트 인스펙터 '텍스트를 GIF로' 콜백. 미지정이면 섹션 숨김. */
2509
+ onGenerateTextGif?: GenerateTextGifFn;
2510
+ /** 텍스트 GIF 1회 비용(크레딧). */
2511
+ textGifCreditCost?: number;
2512
+ /** 이미지 인스펙터 '이미지를 GIF로' 콜백. 미지정이면 섹션 숨김. */
2513
+ onGenerateImageGif?: GenerateImageGifFn;
2514
+ /** 이미지 GIF 1회 비용(크레딧). */
2515
+ imageGifCreditCost?: number;
2516
+ /** 수치 GIF(카운트업·셀 차오름) 콜백. 미지정이면 두 섹션 모두 숨김. */
2517
+ onGenerateDataGif?: GenerateDataGifFn;
2518
+ /** 수치 GIF 1회 비용(크레딧). */
2519
+ dataGifCreditCost?: number;
2520
+ /** 이미지 인스펙터 '배경 지우기' 콜백. 미지정이면 섹션 숨김. */
2521
+ onRemoveBackground?: RemoveBackgroundFn;
2522
+ /** 배경 제거 1회 비용(크레딧). */
2523
+ bgRemoveCreditCost?: number;
2524
+ }) {
2525
+ const s = store as StoreLike;
2526
+ const { usage, applyUsage } = useDetailPageEditUsage(generatedId);
2527
+ // Resolve through selectedElementsIds so a GROUP CHILD picked in the layers
2528
+ // tree is editable here — the stock editor's selectedElements getter only sees
2529
+ // top-level page children and would report "선택 없음".
2530
+ const els = selectedElementsDeep(s) as ElementLike[];
2531
+ const allText = els.length > 0 && els.every((e) => e.type === "text");
2532
+ const allImage = els.length > 0 && els.every((e) => e.type === "image");
2533
+ // 단일 GIF면 이미지 인스펙터를 GIF 전용 모드로(AI 편집을 GIF 재생성으로 기본 전환).
2534
+ const singleGif = els.length === 1 && isGifElement(els[0]);
2535
+ // figure(네이티브 도형)만 골랐으면 채우기(그라데이션)·모서리를 편집한다.
2536
+ const allFigure = els.length > 0 && els.every((e) => e.type === "figure");
2537
+ // 단일 svg 도형이면 벡터 프롬프트 편집을 붙인다(figure/혼합은 기존 경로 유지).
2538
+ const singleSvg = els.length === 1 && els[0].type === "svg";
2539
+ // 단일 그룹이면 안에 든 텍스트 카피를 그룹째로 다시 쓸 수 있게 한다.
2540
+ const singleGroup = els.length === 1 && els[0].type === "group";
2541
+ // 차트도 그룹이라 GroupInspector가 먼저 잡아간다. 스펙이 있으면 차트 인스펙터가 이긴다.
2542
+ const chartSpec = els.length === 1 ? readChartSpec(els[0]) : null;
2543
+ // 캔버스에서 칸을 직접 고쳤을 수 있다. 저장된 스펙을 그대로 보여 주면 패널이 옛 글자를
2544
+ // 띄우고, 그 값으로 AI 편집을 보내면 사용자가 방금 고친 글자가 되돌려진다. 읽는 자리에서
2545
+ // 걷어 오면 타이밍을 볼 필요가 없다(쓰지는 않는다 — 쓰는 건 재생성 때 한 번뿐이다).
2546
+ const storedTableSpec = els.length === 1 ? readTableSpec(els[0]) : null;
2547
+ const tableSpec = storedTableSpec
2548
+ ? harvestTableGroup(els[0] as unknown as TableElementLike, storedTableSpec)
2549
+ : null;
2550
+
2551
+ return (
2552
+ <div className="flex h-full min-h-0 flex-col">
2553
+ <InspectorHeader els={els} />
2554
+ <div className="min-h-0 flex-1 overflow-y-auto">
2555
+ {els.length === 0 ? (
2556
+ <PageInspector
2557
+ store={s}
2558
+ onGenerateDataGif={onGenerateDataGif}
2559
+ dataGifCreditCost={dataGifCreditCost}
2560
+ />
2561
+ ) : (
2562
+ <>
2563
+ {/* 섹션 기준 정렬은 모든 요소 타입에서 노출 */}
2564
+ <AlignSection store={s} els={els} />
2565
+ {/* 크기·위치(W/H/X/Y)는 단일 선택일 때 — 폭을 직접 보고 고칠 수 있다. */}
2566
+ {els.length === 1 ? <SizeSection els={els} /> : null}
2567
+ {/* 정렬 순서(z-order)는 단일 선택일 때만 — 다중은 기준이 모호. */}
2568
+ {els.length === 1 ? <OrderSection els={els} /> : null}
2569
+ {allText ? (
2570
+ <TextInspector
2571
+ store={s}
2572
+ els={els}
2573
+ generatedId={generatedId}
2574
+ usage={usage}
2575
+ onUsage={(used, limit) => applyUsage("text", used, limit)}
2576
+ onBuyMore={onBuyEditCredits}
2577
+ onGenerateTextGif={onGenerateTextGif}
2578
+ textGifCreditCost={textGifCreditCost}
2579
+ onGenerateDataGif={onGenerateDataGif}
2580
+ dataGifCreditCost={dataGifCreditCost}
2581
+ />
2582
+ ) : allImage ? (
2583
+ <ImageInspector
2584
+ store={s}
2585
+ els={els}
2586
+ generatedId={generatedId}
2587
+ isGif={singleGif}
2588
+ imageCreditCost={imageCreditCost}
2589
+ imageCreditBalance={imageCreditBalance}
2590
+ imageCostByTier={imageCostByTier}
2591
+ onBuyMore={onBuyEditCredits}
2592
+ onGenerateGif={onGenerateGif}
2593
+ gifCreditCost={gifCreditCost}
2594
+ onGenerateImageGif={onGenerateImageGif}
2595
+ imageGifCreditCost={imageGifCreditCost}
2596
+ onRemoveBackground={onRemoveBackground}
2597
+ bgRemoveCreditCost={bgRemoveCreditCost}
2598
+ />
2599
+ ) : singleSvg ? (
2600
+ <SvgInspector
2601
+ store={s}
2602
+ els={els}
2603
+ generatedId={generatedId}
2604
+ usage={usage}
2605
+ onUsage={(used, limit) => applyUsage("svg", used, limit)}
2606
+ onBuyMore={onBuyEditCredits}
2607
+ onGenerateImageGif={onGenerateImageGif}
2608
+ imageGifCreditCost={imageGifCreditCost}
2609
+ />
2610
+ ) : allFigure ? (
2611
+ <FigureInspector
2612
+ store={s}
2613
+ els={els}
2614
+ onGenerateImageGif={onGenerateImageGif}
2615
+ imageGifCreditCost={imageGifCreditCost}
2616
+ />
2617
+ ) : chartSpec ? (
2618
+ <ChartInspector
2619
+ // 이 패널의 StoreLike는 페이지 addElement를 안 들고 있다(여기선 쓸 일이
2620
+ // 없어서). 차트 sync는 그게 필요하므로 실제 스토어를 그대로 넘긴다.
2621
+ store={s as unknown as ChartStoreLike}
2622
+ el={els[0] as unknown as ChartElementLike}
2623
+ spec={chartSpec}
2624
+ prompting={{
2625
+ generatedId,
2626
+ usage: {
2627
+ textUsed: usage?.textUsed,
2628
+ textLimit: usage?.textLimit,
2629
+ unlimited: usage?.unlimited,
2630
+ },
2631
+ // 스펙 편집은 text 버킷을 쓴다(서버와 같은 계약).
2632
+ onUsage: (used: number, limit: number) =>
2633
+ applyUsage?.("text", used, limit),
2634
+ onBuyMore: onBuyEditCredits,
2635
+ }}
2636
+ />
2637
+ ) : tableSpec ? (
2638
+ <TableInspector
2639
+ // 차트와 같은 이유로 실제 스토어를 그대로 넘긴다(이 패널의 StoreLike에는
2640
+ // 페이지 addElement가 없다).
2641
+ store={s as unknown as ChartStoreLike}
2642
+ el={els[0] as unknown as ChartElementLike}
2643
+ spec={tableSpec}
2644
+ prompting={{
2645
+ generatedId,
2646
+ usage: {
2647
+ textUsed: usage?.textUsed,
2648
+ textLimit: usage?.textLimit,
2649
+ unlimited: usage?.unlimited,
2650
+ },
2651
+ // 스펙 편집은 text 버킷을 쓴다(서버와 같은 계약).
2652
+ onUsage: (used: number, limit: number) =>
2653
+ applyUsage?.("text", used, limit),
2654
+ onBuyMore: onBuyEditCredits,
2655
+ }}
2656
+ />
2657
+ ) : singleGroup ? (
2658
+ <GroupInspector
2659
+ store={s}
2660
+ els={els}
2661
+ generatedId={generatedId}
2662
+ usage={usage}
2663
+ applyUsage={applyUsage}
2664
+ onBuyMore={onBuyEditCredits}
2665
+ onGenerateTextGif={onGenerateTextGif}
2666
+ textGifCreditCost={textGifCreditCost}
2667
+ />
2668
+ ) : (
2669
+ <>
2670
+ <OpacityRow els={els} />
2671
+ <DeleteRow store={s} els={els} />
2672
+ </>
2673
+ )}
2674
+ </>
2675
+ )}
2676
+ </div>
2677
+ </div>
2678
+ );
2679
+ });
2680
+ DetailPageProperties.displayName = "DetailPageProperties";