@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,178 @@
1
+ "use client";
2
+
3
+ // "프롬프트로 편집" — 선택한 차트·표를 자연어 요청으로 고친다("행 하나 더 넣고 단위를
4
+ // %로"). 소싱 서버의 그룹 편집 엔드포인트(/{generatedId}/group/prompt-edit)에
5
+ // ``kind: "data"`` 항목 하나로 보내고, 돌아온 **스펙**을 그대로 적용한다.
6
+ //
7
+ // ⚠️ 자식 글자를 고쳐 받지 않는다. 차트·표의 자식(막대·칸 글자)은 스펙에서 매번 다시
8
+ // 그려지므로, 글자만 고쳐 두면 사용자가 다음에 행 하나 늘리는 순간 조용히 덮인다.
9
+ // 그래서 서버도 프론트도 스펙만 주고받고, 적용은 sync 경로(그리는 곳 하나)를 탄다.
10
+
11
+ import { useCallback, useState } from "react";
12
+ import { ArrowUp, Loader2, Sparkles } from "lucide-react";
13
+ import { useTranslation } from "react-i18next";
14
+
15
+ import { useDetailPageHost } from "./detail-page-host-context";
16
+ import { EditQuotaBlock, EditUsageBadge } from "./edit-quota-ui";
17
+ import { GenerationProgressLine } from "./generation-progress-fill";
18
+ import {
19
+ useFakeProgress,
20
+ GENERATION_ESTIMATE_MS,
21
+ } from "../../lib/detail-page/use-fake-progress";
22
+
23
+ interface SpecPromptEditPanelProps {
24
+ /** 생성 인스턴스 ID. 없으면(픽스처 모드) 이 패널은 렌더되지 않는다. */
25
+ generatedId: string;
26
+ /** 차트인지 표인지. 서버가 검증 규칙을 이걸로 가른다. */
27
+ specKind: "chart" | "table";
28
+ /** 편집기 요소 id(응답 매핑용). */
29
+ elementId: string;
30
+ /** 지금 요소에 얹혀 있는 스펙. */
31
+ currentSpec: unknown;
32
+ /** 수정된 스펙을 적용하는 콜백(sync 경로를 태운다). */
33
+ onApplied: (spec: unknown) => void;
34
+ /** 이 인스턴스의 텍스트 편집 사용 횟수/한도(부모가 관리). */
35
+ editsUsed?: number;
36
+ editLimit?: number;
37
+ /** scratch 등 한도 면제면 카운터/차단을 숨긴다. */
38
+ unlimited?: boolean;
39
+ /** 편집 성공/차단 후 사용량 갱신을 부모에 알린다. */
40
+ onUsage?: (used: number, limit: number) => void;
41
+ /** 한도 소진 시 "편집 크레딧 추가하기" 목적지. */
42
+ onBuyMore?: () => void;
43
+ }
44
+
45
+ export function SpecPromptEditPanel({
46
+ generatedId,
47
+ specKind,
48
+ elementId,
49
+ currentSpec,
50
+ onApplied,
51
+ editsUsed = 0,
52
+ editLimit = 0,
53
+ unlimited = false,
54
+ onUsage,
55
+ onBuyMore,
56
+ }: SpecPromptEditPanelProps) {
57
+ const { t } = useTranslation("branding");
58
+ const { api, toast } = useDetailPageHost();
59
+ const [input, setInput] = useState("");
60
+ const [busy, setBusy] = useState(false);
61
+ const progress = useFakeProgress(busy, GENERATION_ESTIMATE_MS.text);
62
+
63
+ const blocked = !unlimited && editLimit > 0 && editsUsed >= editLimit;
64
+
65
+ const send = useCallback(async () => {
66
+ const instruction = input.trim();
67
+ if (!instruction || busy || blocked) return;
68
+ setBusy(true);
69
+ try {
70
+ const result = await api.groupPromptEditDetailPage(generatedId, {
71
+ instruction,
72
+ items: [
73
+ {
74
+ id: elementId,
75
+ kind: "data",
76
+ spec_kind: specKind,
77
+ current_spec: currentSpec,
78
+ },
79
+ ],
80
+ });
81
+ if (
82
+ typeof result.text_used === "number" &&
83
+ typeof result.text_limit === "number"
84
+ ) {
85
+ onUsage?.(result.text_used, result.text_limit);
86
+ }
87
+ // 서버는 변경이 없으면 spec을 아예 안 싣는다(원본 유지 = 무과금).
88
+ const spec = result.results?.find((item) => item.id === elementId)?.spec;
89
+ if (!spec) {
90
+ toast.info(t("detailPage.promptEdit.noChange"));
91
+ } else {
92
+ onApplied(spec);
93
+ toast.success(t("detailPage.specPromptEdit.success"));
94
+ setInput("");
95
+ }
96
+ } catch (err) {
97
+ const quota = api.asEditQuotaError(err);
98
+ if (quota) {
99
+ onUsage?.(quota.limit, quota.limit);
100
+ toast.error(t("detailPage.promptEdit.quotaExhausted"));
101
+ } else {
102
+ toast.error(
103
+ err instanceof Error ? err.message : t("detailPage.promptEdit.requestFailed"),
104
+ );
105
+ }
106
+ } finally {
107
+ setBusy(false);
108
+ }
109
+ }, [
110
+ api,
111
+ toast,
112
+ input,
113
+ busy,
114
+ blocked,
115
+ generatedId,
116
+ specKind,
117
+ elementId,
118
+ currentSpec,
119
+ onApplied,
120
+ onUsage,
121
+ t,
122
+ ]);
123
+
124
+ return (
125
+ <div className="relative flex flex-col overflow-hidden rounded-xl border border-border bg-card">
126
+ <div className="flex items-center gap-1.5 border-b border-border px-3 py-2">
127
+ <Sparkles size={13} className="text-primary" />
128
+ <span className="text-xs font-medium text-foreground">
129
+ {t("detailPage.promptEdit.header")}
130
+ </span>
131
+ {unlimited || editLimit > 0 ? (
132
+ <EditUsageBadge used={editsUsed} limit={editLimit} unlimited={unlimited} />
133
+ ) : null}
134
+ </div>
135
+ {blocked ? (
136
+ <EditQuotaBlock kind="text" onBuyMore={onBuyMore} />
137
+ ) : (
138
+ <div className="flex items-end gap-1.5 px-2 py-2">
139
+ <textarea
140
+ value={input}
141
+ onChange={(e) => setInput(e.target.value)}
142
+ onKeyDown={(e) => {
143
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
144
+ e.preventDefault();
145
+ send();
146
+ }
147
+ }}
148
+ rows={2}
149
+ placeholder={
150
+ busy
151
+ ? t("detailPage.promptEdit.editing")
152
+ : t(`detailPage.specPromptEdit.placeholder.${specKind}`)
153
+ }
154
+ disabled={busy}
155
+ className="max-h-32 flex-1 resize-none bg-transparent px-2 py-1.5 text-[13px] leading-5 text-foreground outline-none placeholder:text-muted-foreground/70 disabled:opacity-60"
156
+ />
157
+ <button
158
+ type="button"
159
+ onClick={send}
160
+ disabled={busy || !input.trim()}
161
+ className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-primary text-primary-foreground disabled:opacity-40"
162
+ aria-label={t("detailPage.promptEdit.send")}
163
+ >
164
+ {busy ? (
165
+ <Loader2 size={15} className="animate-spin" />
166
+ ) : (
167
+ <ArrowUp size={15} />
168
+ )}
169
+ </button>
170
+ </div>
171
+ )}
172
+ {busy ? <GenerationProgressLine progress={progress} /> : null}
173
+ <p className="px-3 pb-2 text-[11px] leading-relaxed text-muted-foreground">
174
+ {t("detailPage.specPromptEdit.hint")}
175
+ </p>
176
+ </div>
177
+ );
178
+ }
@@ -0,0 +1,149 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, type RefObject } from "react";
4
+ import { observer } from "./canvas-observer";
5
+
6
+ import {
7
+ selectedElementsDeep,
8
+ type SelectableElement,
9
+ } from "./detail-page-selection";
10
+ import { chartBox, readChartSpec, syncChartGroup } from "../../lib/detail-page/chart/sync";
11
+ import { readTableSpec, syncTableGroup, tableBox } from "../../lib/detail-page/table/sync";
12
+ import type { ElementLike, StoreLike } from "../../lib/detail-page/spec-group/sync";
13
+
14
+ /**
15
+ * 차트·표를 트랜스포머로 끈 결과를 **끌기가 멈추자마자** 스펙에 되먹인다.
16
+ *
17
+ * 되먹임 자체는 ``absorbResize``가 재생성 안에서 하지만(``spec-group/sync``), 재생성은
18
+ * 사용자가 값을 고칠 때만 돈다. 그래서 이게 없으면 이런 일이 벌어진다: 세로로 끌면
19
+ * Konva가 자식을 통째로 스케일해 글자까지 커진 모습이 보이고 → 한참 뒤 칸 하나를 고치면
20
+ * 그제야 "글자는 원래 크기, 여백만 늘어난" 진짜 모습으로 다시 그려진다. 사용자에겐
21
+ * 아무 관계 없는 편집이 표를 튀게 만든 걸로 보인다.
22
+ *
23
+ * 끌고 있는 동안 매번 다시 그리면 캔버스가 덜덜 떨기 때문에, 상자가 잠잠해질 때까지
24
+ * 기다렸다 한 번만 돈다.
25
+ */
26
+
27
+ /** 끌기가 멈춘 걸로 보는 시간. */
28
+ const SETTLE_MS = 180;
29
+
30
+ type AbsorbStore = StoreLike & {
31
+ selectedElementsIds?: string[];
32
+ selectedElements?: SelectableElement[];
33
+ getElementById?: (id: string) => SelectableElement | undefined;
34
+ pages?: Array<{ children?: unknown }>;
35
+ };
36
+
37
+ type Kind = {
38
+ box: (group: ElementLike) => { width: number; height: number } | null;
39
+ sync: (store: StoreLike, group: ElementLike, spec: never) => unknown;
40
+ read: (el: ElementLike) => unknown;
41
+ };
42
+
43
+ const KINDS: Kind[] = [
44
+ {
45
+ read: (el) => readTableSpec(el),
46
+ box: tableBox,
47
+ sync: (store, group, spec) => syncTableGroup(store, group, spec),
48
+ },
49
+ {
50
+ read: (el) => readChartSpec(el),
51
+ box: chartBox,
52
+ sync: (store, group, spec) => syncChartGroup(store, group, spec),
53
+ },
54
+ ];
55
+
56
+ function childrenOf(node: { children?: unknown }): ElementLike[] {
57
+ return Array.isArray(node.children) ? (node.children as ElementLike[]) : [];
58
+ }
59
+
60
+ function idsUnder(el: ElementLike, into: Set<string>): Set<string> {
61
+ if (typeof el.id === "string") into.add(el.id);
62
+ for (const kid of childrenOf(el)) idsUnder(kid, into);
63
+ return into;
64
+ }
65
+
66
+ /** 선택이 들어 있는 스펙 그룹(차트든 표든)과 그 종류. */
67
+ export function findSpecGroup(
68
+ store: { pages?: Array<{ children?: unknown }> },
69
+ selectedIds: ReadonlyArray<string>,
70
+ ): { group: ElementLike; kind: Kind } | null {
71
+ if (selectedIds.length === 0) return null;
72
+ for (const page of store.pages ?? []) {
73
+ for (const el of childrenOf(page)) {
74
+ if (el.type !== "group") continue;
75
+ const kind = KINDS.find((candidate) => candidate.read(el));
76
+ if (!kind) continue;
77
+ const inside = idsUnder(el, new Set<string>());
78
+ if (selectedIds.some((id) => inside.has(id))) return { group: el, kind };
79
+ }
80
+ }
81
+ return null;
82
+ }
83
+
84
+ /** 상자가 의미 있게 달라졌는가. 소수점 흔들림으로 재생성이 도는 걸 막는다. */
85
+ export function boxChanged(
86
+ a: { width: number; height: number } | null,
87
+ b: { width: number; height: number } | null,
88
+ ): boolean {
89
+ if (!a || !b) return false;
90
+ return Math.abs(a.width - b.width) > 1 || Math.abs(a.height - b.height) > 1;
91
+ }
92
+
93
+ export const SpecResizeAbsorber = observer(function SpecResizeAbsorber({
94
+ store,
95
+ }: {
96
+ store: unknown;
97
+ /** 다른 오버레이와 자리를 맞추기 위해 받지만 쓰지 않는다(그리는 게 없다). */
98
+ containerRef?: RefObject<HTMLElement | null>;
99
+ }) {
100
+ const s = store as AbsorbStore;
101
+ const selected = selectedElementsDeep(s);
102
+ const found = findSpecGroup(s, selected.map((e) => String(e.id)));
103
+
104
+ // mobx가 자식의 이동·크기 변화를 감지하도록 렌더 중에 읽는다.
105
+ const stamp = found
106
+ ? childrenOf(found.group)
107
+ .map((k) => `${k.id}:${k.x},${k.y},${k.width},${k.height}`)
108
+ .join("|")
109
+ : "";
110
+ const groupId = found ? String(found.group.id) : null;
111
+
112
+ const foundRef = useRef(found);
113
+ foundRef.current = found;
114
+
115
+ // "저장된 frame과 다른가"가 아니라 "직전에 본 상자와 달라졌는가"로 판정한다. 바탕도
116
+ // 테두리도 없는 표는 자식 합집합이 프레임보다 좁아서, 전자로 걸면 영영 같아지지 않고
117
+ // 재생성이 무한히 돈다.
118
+ const lastBox = useRef<{ width: number; height: number } | null>(null);
119
+ useEffect(() => {
120
+ const current = foundRef.current;
121
+ if (!current) {
122
+ lastBox.current = null;
123
+ return;
124
+ }
125
+ const box = current.kind.box(current.group);
126
+ if (!box) return;
127
+ const previous = lastBox.current;
128
+ if (!previous) {
129
+ lastBox.current = { width: box.width, height: box.height };
130
+ return;
131
+ }
132
+ if (!boxChanged(previous, box)) return;
133
+ const timer = setTimeout(() => {
134
+ const target = foundRef.current;
135
+ if (!target) return;
136
+ const spec = target.kind.read(target.group);
137
+ if (!spec) return;
138
+ target.kind.sync(s, target.group, spec as never);
139
+ const settled = target.kind.box(target.group);
140
+ lastBox.current = settled
141
+ ? { width: settled.width, height: settled.height }
142
+ : null;
143
+ }, SETTLE_MS);
144
+ return () => clearTimeout(timer);
145
+ }, [stamp, groupId, s]);
146
+
147
+ return null;
148
+ });
149
+ SpecResizeAbsorber.displayName = "SpecResizeAbsorber";
@@ -0,0 +1,139 @@
1
+ "use client";
2
+
3
+ // "프롬프트로 편집" — 선택한 SVG 도형(벡터 장식)을 자연어 요청으로 다시 그린다. 소싱
4
+ // 서버의 svg 편집 엔드포인트(/{generatedId}/svg/prompt-edit)에 현재 마크업 + 지시를 한
5
+ // 번 POST 하고, 돌아온 <svg> 마크업을 data URI로 인코딩해 해당 Canvas 요소의 src에
6
+ // 적용한다(viewBox/치수 유지, 순수 벡터). 카피용 PromptEditPanel의 SVG 판.
7
+
8
+ import { useCallback, useState } from "react";
9
+ import { ArrowUp, Loader2, Sparkles } from "lucide-react";
10
+ import { useTranslation } from "react-i18next";
11
+ import { useDetailPageHost } from "./detail-page-host-context";
12
+ import { EditQuotaBlock, EditUsageBadge } from "./edit-quota-ui";
13
+ import { GenerationProgressLine } from "./generation-progress-fill";
14
+ import {
15
+ useFakeProgress,
16
+ GENERATION_ESTIMATE_MS,
17
+ } from "../../lib/detail-page/use-fake-progress";
18
+
19
+ interface SvgPromptEditPanelProps {
20
+ /** 생성 인스턴스 ID. 없으면(픽스처 모드) 이 패널은 렌더되지 않는다. */
21
+ generatedId: string;
22
+ /** 요소의 custom.leviosaSlot(있으면 문맥용, 없으면 빈 문자열). */
23
+ slotRole?: string;
24
+ /** 편집기에 지금 표시된 SVG 마크업(el.src를 디코딩한 값). */
25
+ currentSvg: string;
26
+ /** 수정된 SVG 마크업을 요소에 반영하는 콜백 (el.set({ src: dataUri })). */
27
+ onApplied: (svg: string) => void;
28
+ /** 이 인스턴스의 SVG 편집 사용 횟수/한도(부모가 관리). */
29
+ editsUsed?: number;
30
+ editLimit?: number;
31
+ /** scratch 등 한도 면제면 카운터/차단을 숨긴다. */
32
+ unlimited?: boolean;
33
+ /** 편집 성공/차단 후 사용량 갱신을 부모에 알린다. */
34
+ onUsage?: (used: number, limit: number) => void;
35
+ /** 한도 소진 시 "편집 크레딧 추가하기" 목적지(레비오사 결제면). */
36
+ onBuyMore?: () => void;
37
+ }
38
+
39
+ export function SvgPromptEditPanel({
40
+ generatedId,
41
+ slotRole,
42
+ currentSvg,
43
+ onApplied,
44
+ editsUsed = 0,
45
+ editLimit = 0,
46
+ unlimited = false,
47
+ onUsage,
48
+ onBuyMore,
49
+ }: SvgPromptEditPanelProps) {
50
+ const { t } = useTranslation("branding");
51
+ const { api, toast } = useDetailPageHost();
52
+ const [input, setInput] = useState("");
53
+ const [busy, setBusy] = useState(false);
54
+ const progress = useFakeProgress(busy, GENERATION_ESTIMATE_MS.text);
55
+
56
+ const blocked = !unlimited && editLimit > 0 && editsUsed >= editLimit;
57
+
58
+ const send = useCallback(async () => {
59
+ const instruction = input.trim();
60
+ if (!instruction || busy || blocked) return;
61
+ setBusy(true);
62
+ try {
63
+ const result = await api.svgPromptEditDetailPage(generatedId, {
64
+ slot_role: slotRole,
65
+ current_svg: currentSvg,
66
+ instruction,
67
+ });
68
+ if (typeof result.edits_used === "number" && typeof result.edit_limit === "number") {
69
+ onUsage?.(result.edits_used, result.edit_limit);
70
+ }
71
+ const svg = (result.svg ?? "").trim();
72
+ if (!svg || svg === currentSvg.trim()) {
73
+ toast.info(t("detailPage.promptEdit.noChange"));
74
+ } else {
75
+ onApplied(svg);
76
+ toast.success(t("detailPage.svgPromptEdit.success"));
77
+ setInput("");
78
+ }
79
+ } catch (err) {
80
+ const quota = api.asEditQuotaError(err);
81
+ if (quota) {
82
+ onUsage?.(quota.limit, quota.limit);
83
+ toast.error(t("detailPage.promptEdit.quotaExhausted"));
84
+ } else {
85
+ toast.error(err instanceof Error ? err.message : t("detailPage.promptEdit.requestFailed"));
86
+ }
87
+ } finally {
88
+ setBusy(false);
89
+ }
90
+ }, [api, toast, input, busy, blocked, generatedId, slotRole, currentSvg, onApplied, onUsage, t]);
91
+
92
+ return (
93
+ <div className="relative flex flex-col overflow-hidden rounded-xl border border-border bg-card">
94
+ <div className="flex items-center gap-1.5 border-b border-border px-3 py-2">
95
+ <Sparkles size={13} className="text-primary" />
96
+ <span className="text-xs font-medium text-foreground">{t("detailPage.promptEdit.header")}</span>
97
+ {unlimited || editLimit > 0 ? (
98
+ <EditUsageBadge used={editsUsed} limit={editLimit} unlimited={unlimited} />
99
+ ) : null}
100
+ </div>
101
+ {blocked ? (
102
+ <EditQuotaBlock kind="svg" onBuyMore={onBuyMore} />
103
+ ) : (
104
+ <div className="flex items-end gap-1.5 px-2 py-2">
105
+ <textarea
106
+ value={input}
107
+ onChange={(e) => setInput(e.target.value)}
108
+ onKeyDown={(e) => {
109
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
110
+ e.preventDefault();
111
+ send();
112
+ }
113
+ }}
114
+ rows={2}
115
+ placeholder={
116
+ busy ? t("detailPage.promptEdit.editing") : t("detailPage.svgPromptEdit.placeholder")
117
+ }
118
+ disabled={busy}
119
+ className="max-h-32 flex-1 resize-none bg-transparent px-2 py-1.5 text-[13px] leading-5 text-foreground outline-none placeholder:text-muted-foreground/70 disabled:opacity-60"
120
+ />
121
+ <button
122
+ type="button"
123
+ onClick={send}
124
+ disabled={busy || !input.trim()}
125
+ className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-primary text-primary-foreground disabled:opacity-40"
126
+ aria-label={t("detailPage.promptEdit.send")}
127
+ >
128
+ {busy ? (
129
+ <Loader2 size={15} className="animate-spin" />
130
+ ) : (
131
+ <ArrowUp size={15} />
132
+ )}
133
+ </button>
134
+ </div>
135
+ )}
136
+ {busy ? <GenerationProgressLine progress={progress} /> : null}
137
+ </div>
138
+ );
139
+ }