@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,245 @@
1
+ /**
2
+ * '텍스트를 GIF로'가 서버에 넘길 **실측 레이아웃**.
3
+ *
4
+ * 예전에는 글자와 색만 넘겼고 서버가 캔버스를 글자 수로 추정했다(``fontSize * chars *
5
+ * 0.66 + 96``). 그렇게 나온 GIF를 편집기가 페이지 폭의 62%로 꽂으니, 원본이 600px짜리
6
+ * 상자였어도 결과는 훨씬 크게 들어갔다 — 유저가 본 "폰트 크기가 커지고 줄이 밀리는"
7
+ * 증상의 정체다.
8
+ *
9
+ * 그래서 여기서 편집기가 아는 것을 전부 잰다: 원본 상자, 줄마다의 자리와 정렬, 그리고
10
+ * **눈에 보이는 줄바꿈**. 편집기의 텍스트는 상자 폭에서 자동으로 접히는데(soft wrap)
11
+ * SVG ``<text>``는 접히지 않으므로, 접힌 결과를 여기서 미리 줄로 쪼개 넘겨야 같은 모양이
12
+ * 나온다.
13
+ */
14
+
15
+ export type TextElementLike = {
16
+ x?: unknown;
17
+ y?: unknown;
18
+ width?: unknown;
19
+ height?: unknown;
20
+ text?: unknown;
21
+ fill?: unknown;
22
+ fontSize?: unknown;
23
+ fontWeight?: unknown;
24
+ fontFamily?: unknown;
25
+ lineHeight?: unknown;
26
+ align?: unknown;
27
+ verticalAlign?: unknown;
28
+ };
29
+
30
+ export type Box = { x: number; y: number; width: number; height: number };
31
+
32
+ export type LaidOutLine = {
33
+ text: string;
34
+ color: string;
35
+ fontSize: number;
36
+ fontWeight: number;
37
+ fontFamily: string;
38
+ /** 상자 좌상단 기준 앵커 x. */
39
+ x: number;
40
+ /** 상자 좌상단 기준 줄의 세로 중심 y. */
41
+ y: number;
42
+ anchor: "start" | "middle" | "end";
43
+ };
44
+
45
+ /** 한 줄의 렌더 폭을 재는 함수(브라우저에서는 canvas measureText). */
46
+ export type Measure = (spec: {
47
+ text: string;
48
+ fontSize: number;
49
+ fontWeight: number;
50
+ fontFamily: string;
51
+ }) => number;
52
+
53
+ /** Canvas 텍스트 기본값(model/text-model.js). */
54
+ const DEFAULT_LINE_HEIGHT = 1.2;
55
+ const DEFAULT_FONT_SIZE = 14;
56
+
57
+ /** 접기 폭주 방지 — 폭이 말도 안 되게 좁아도 무한 루프에 빠지지 않게. */
58
+ const MAX_WRAPPED_LINES = 60;
59
+
60
+ function num(value: unknown, fallback = 0): number {
61
+ const parsed = typeof value === "number" ? value : Number(value);
62
+ return Number.isFinite(parsed) ? parsed : fallback;
63
+ }
64
+
65
+ /** ``normal``/``bold`` 같은 CSS 키워드를 숫자 굵기로. */
66
+ export function toFontWeight(value: unknown): number {
67
+ if (typeof value === "number" && Number.isFinite(value)) {
68
+ return Math.min(900, Math.max(100, Math.round(value)));
69
+ }
70
+ const text = String(value ?? "").trim().toLowerCase();
71
+ if (text === "bold") return 700;
72
+ if (text === "normal" || text === "") return 400;
73
+ const parsed = Number(text);
74
+ return Number.isFinite(parsed)
75
+ ? Math.min(900, Math.max(100, Math.round(parsed)))
76
+ : 400;
77
+ }
78
+
79
+ /**
80
+ * 편집기가 보여주는 줄바꿈을 그대로 재현한다.
81
+ *
82
+ * 하드 개행으로 먼저 쪼개고, 각 줄이 상자 폭을 넘으면 들어가는 만큼 잘라 접는다.
83
+ * 자를 자리는 마지막 공백을 우선하되(단어 보존), 공백이 없는 한국어·중국어 문장은
84
+ * 글자 단위로 접는다 — Konva가 하는 것과 같은 결이다.
85
+ */
86
+ export function wrapText(
87
+ text: string,
88
+ maxWidth: number,
89
+ measure: Measure,
90
+ style: { fontSize: number; fontWeight: number; fontFamily: string },
91
+ ): string[] {
92
+ const hard = String(text ?? "").split(/\r?\n/);
93
+ if (!(maxWidth > 0)) {
94
+ return hard.map((line) => line.trim()).filter(Boolean);
95
+ }
96
+ const out: string[] = [];
97
+ for (const raw of hard) {
98
+ let rest = raw.trim();
99
+ if (!rest) continue;
100
+ let guard = 0;
101
+ while (rest && guard < MAX_WRAPPED_LINES) {
102
+ guard += 1;
103
+ if (measure({ ...style, text: rest }) <= maxWidth) {
104
+ out.push(rest);
105
+ break;
106
+ }
107
+ // 들어가는 가장 긴 앞부분(폭은 길이에 대해 단조 증가한다).
108
+ let low = 1;
109
+ let high = rest.length;
110
+ let fit = 1;
111
+ while (low <= high) {
112
+ const mid = (low + high) >> 1;
113
+ if (measure({ ...style, text: rest.slice(0, mid) }) <= maxWidth) {
114
+ fit = mid;
115
+ low = mid + 1;
116
+ } else {
117
+ high = mid - 1;
118
+ }
119
+ }
120
+ let cut = fit;
121
+ const lastSpace = rest.lastIndexOf(" ", cut);
122
+ if (lastSpace > 0) cut = lastSpace;
123
+ const head = rest.slice(0, cut).trim();
124
+ const tail = rest.slice(cut).replace(/^\s+/, "");
125
+ if (!head) {
126
+ // 한 글자도 안 들어가는 극단 — 그래도 진도는 나가야 한다.
127
+ out.push(rest.slice(0, 1));
128
+ rest = rest.slice(1);
129
+ continue;
130
+ }
131
+ out.push(head);
132
+ rest = tail;
133
+ if (!rest) break;
134
+ if (guard >= MAX_WRAPPED_LINES - 1 && rest) out.push(rest);
135
+ }
136
+ }
137
+ return out;
138
+ }
139
+
140
+ function anchorOf(align: unknown): "start" | "middle" | "end" {
141
+ const value = String(align ?? "").trim().toLowerCase();
142
+ if (value === "right") return "end";
143
+ if (value === "center") return "middle";
144
+ return "start";
145
+ }
146
+
147
+ /** 정렬에 따른 앵커 x(요소 좌표계). */
148
+ function anchorX(el: TextElementLike, anchor: "start" | "middle" | "end"): number {
149
+ const x = num(el.x);
150
+ const width = num(el.width);
151
+ if (anchor === "middle") return x + width / 2;
152
+ if (anchor === "end") return x + width;
153
+ return x;
154
+ }
155
+
156
+ /**
157
+ * 텍스트 요소들 → 상자 기준 줄 목록(위→아래).
158
+ *
159
+ * ``box``는 보통 이 요소들의 합집합 상자다. 서버는 이 좌표를 그대로 SVG에 찍고, 편집기는
160
+ * 같은 상자에 결과를 되꽂으므로 글자 크기와 자리가 원본과 일치한다.
161
+ */
162
+ export function layoutTextLines(
163
+ els: TextElementLike[],
164
+ box: Box,
165
+ measure: Measure,
166
+ ): LaidOutLine[] {
167
+ const ordered = [...els].sort((a, b) => num(a.y) - num(b.y));
168
+ const out: LaidOutLine[] = [];
169
+ for (const el of ordered) {
170
+ const fontSize = Math.max(1, Math.round(num(el.fontSize, DEFAULT_FONT_SIZE)));
171
+ const fontWeight = toFontWeight(el.fontWeight);
172
+ const fontFamily = String(el.fontFamily ?? "");
173
+ const lineHeight = Math.max(
174
+ 0.5,
175
+ num(el.lineHeight, DEFAULT_LINE_HEIGHT) || DEFAULT_LINE_HEIGHT,
176
+ );
177
+ const step = fontSize * lineHeight;
178
+ const anchor = anchorOf(el.align);
179
+ const lines = wrapText(String(el.text ?? ""), num(el.width), measure, {
180
+ fontSize,
181
+ fontWeight,
182
+ fontFamily,
183
+ });
184
+ if (lines.length === 0) continue;
185
+
186
+ // 세로 정렬: 요소 높이가 줄 높이 합보다 크면 top/middle/bottom에 따라 밀린다.
187
+ const block = step * lines.length;
188
+ const slack = Math.max(0, num(el.height) - block);
189
+ const vertical = String(el.verticalAlign ?? "top").trim().toLowerCase();
190
+ const offset =
191
+ vertical === "middle" ? slack / 2 : vertical === "bottom" ? slack : 0;
192
+
193
+ const x = anchorX(el, anchor) - box.x;
194
+ const top = num(el.y) + offset - box.y;
195
+ lines.forEach((line, index) => {
196
+ out.push({
197
+ text: line,
198
+ color: String(el.fill ?? "#26221e"),
199
+ fontSize,
200
+ fontWeight,
201
+ fontFamily,
202
+ x,
203
+ y: top + step * index + step / 2,
204
+ anchor,
205
+ });
206
+ });
207
+ }
208
+ return out;
209
+ }
210
+
211
+ /**
212
+ * 상자 밖으로 번지는 이펙트(글로우·물결·바운스)를 위한 여백.
213
+ *
214
+ * 가장 큰 글자를 기준으로 잡는다 — 진폭과 번짐이 글자 크기에 비례하기 때문이다.
215
+ * 편집기도 결과를 이만큼 키운 상자에 꽂으므로 글자 자리는 그대로 맞는다.
216
+ */
217
+ export function gifBleed(lines: Array<{ fontSize: number }>): number {
218
+ const largest = lines.reduce((max, line) => Math.max(max, line.fontSize), 0);
219
+ return Math.round(Math.min(120, Math.max(12, largest * 0.45)));
220
+ }
221
+
222
+ /** 브라우저 canvas 기반 폭 측정기. canvas를 못 얻으면 null(호출부가 폴백). */
223
+ export function createCanvasMeasure(): Measure | null {
224
+ if (typeof document === "undefined") return null;
225
+ const context = document.createElement("canvas").getContext("2d");
226
+ if (!context) return null;
227
+ return ({ text, fontSize, fontWeight, fontFamily }) => {
228
+ const family = fontFamily ? `"${fontFamily}", sans-serif` : "sans-serif";
229
+ context.font = `${fontWeight} ${fontSize}px ${family}`;
230
+ return context.measureText(text).width;
231
+ };
232
+ }
233
+
234
+ /**
235
+ * 측정기가 없을 때 쓰는 대략치(폰트 폭 ≈ 글자 크기의 0.62배, ASCII는 절반).
236
+ *
237
+ * 실측보다 나쁘지만 "아예 안 접힌다"보다는 낫다.
238
+ */
239
+ export const estimateMeasure: Measure = ({ text, fontSize }) => {
240
+ let width = 0;
241
+ for (const char of String(text ?? "")) {
242
+ width += /[ -ÿ]/.test(char) ? fontSize * 0.52 : fontSize;
243
+ }
244
+ return width;
245
+ };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * '텍스트를 GIF로' 요청 본문 만들기(camelCase 입력 → snake_case 페이로드).
3
+ *
4
+ * 운영 편집기(editor-client)와 데모 하니스(dev-canvas)가 같은 계약으로 호출해야 해서
5
+ * 한 곳에 둔다 — 한쪽에만 필드를 더하면 데모에서만 폰트가 안 실리는 식으로 갈린다.
6
+ */
7
+
8
+ export type TextGifRequestInput = {
9
+ text: string;
10
+ effect: string;
11
+ color?: string;
12
+ accent?: string;
13
+ background?: string;
14
+ fontSize?: number;
15
+ fontWeight?: number;
16
+ fontFamily?: string;
17
+ lines?: Array<{
18
+ text: string;
19
+ color: string;
20
+ fontSize: number;
21
+ fontWeight: number;
22
+ fontFamily: string;
23
+ /** 원본 상자 기준 앵커 x(px). 주면 서버가 그 자리에 그대로 찍는다. */
24
+ x?: number;
25
+ /** 원본 상자 기준 줄의 세로 중심 y(px). */
26
+ y?: number;
27
+ /** 가로 정렬 기준(편집기 left/center/right → start/middle/end). */
28
+ anchor?: "start" | "middle" | "end";
29
+ }>;
30
+ fonts?: Array<{ family: string; url: string; weight: number }>;
31
+ /** 원본 텍스트 상자 크기(px). 주면 서버가 캔버스를 추정하지 않는다. */
32
+ boxWidth?: number;
33
+ boxHeight?: number;
34
+ /** 상자 밖으로 번지는 이펙트를 위한 사방 여백(px). */
35
+ bleed?: number;
36
+ brandId?: string;
37
+ };
38
+
39
+ export function buildTextGifPayload(input: TextGifRequestInput) {
40
+ return {
41
+ text: input.text,
42
+ effect: input.effect,
43
+ color: input.color,
44
+ accent: input.accent,
45
+ background: input.background,
46
+ font_size: input.fontSize,
47
+ font_weight: input.fontWeight,
48
+ font_family: input.fontFamily,
49
+ // 한 줄짜리도 lines 로 보낸다 — 요소 안의 줄바꿈이 이미 여기서 줄로 쪼개져 있고,
50
+ // 서버는 lines 가 있으면 그걸 쌓는다.
51
+ lines: (input.lines ?? []).map((line) => ({
52
+ text: line.text,
53
+ color: line.color,
54
+ font_size: line.fontSize,
55
+ font_weight: line.fontWeight,
56
+ font_family: line.fontFamily,
57
+ x: line.x,
58
+ y: line.y,
59
+ anchor: line.anchor,
60
+ })),
61
+ fonts: input.fonts ?? [],
62
+ box_width: input.boxWidth,
63
+ box_height: input.boxHeight,
64
+ bleed: input.bleed,
65
+ brand_id: input.brandId,
66
+ };
67
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * 텍스트 프리셋 — 여러 줄이 한 덩어리로 들어가는 글 묶음.
3
+ *
4
+ * 스톡 `TextSection`도 크기 버튼 아래에 이런 묶음 격자를 달고 있었지만, 그건 벤더
5
+ * 서버에서 받아 오는 영문 포스터 디자인이라 우리 상세페이지에서 쓸 자리가 없었다.
6
+ * 여기 있는 건 우리 템플릿이 실제로 반복하는 짜임이다 — 제목+설명, 눈썹라벨+제목,
7
+ * 번호 매긴 단계, 인용, 각주 달린 문장.
8
+ *
9
+ * 좌표는 폭 `REFERENCE_WIDTH` 기준으로 적어 두고, 넣을 때 페이지 폭에 맞춰 한 번에
10
+ * 줄인다. 그래서 750짜리 섹션이든 860짜리든 같은 비율로 앉는다.
11
+ *
12
+ * 넣는 글자는 문서 기본 서체로만 들어간다. 서체·색은 넣은 뒤 우측 인스펙터에서 고른다.
13
+ */
14
+
15
+ import type { ElementLike, StoreLike } from "./spec-group/sync";
16
+
17
+ /** 프리셋 좌표계의 폭. 실제 삽입 폭은 페이지 폭의 80%다. */
18
+ export const REFERENCE_WIDTH = 1000;
19
+ const PAGE_RATIO = 0.8;
20
+
21
+ export type TextPresetNode = {
22
+ /** 문구 i18n 키의 꼬리(`detailPage.textPresets.<preset>.<key>`). */
23
+ key: string;
24
+ y: number;
25
+ fontSize: number;
26
+ fontWeight: number;
27
+ align: "left" | "center";
28
+ /** 안 주면 `fontSize * 1.35`. 여러 줄로 접힐 수 있는 칸만 따로 준다. */
29
+ height?: number;
30
+ lineHeight?: number;
31
+ letterSpacing?: number;
32
+ fill?: string;
33
+ /** 좌우 여백(기준 폭 안에서). 안 주면 0. */
34
+ x?: number;
35
+ width?: number;
36
+ };
37
+
38
+ export type TextPreset = {
39
+ key: string;
40
+ /** 묶음 전체 높이(기준 폭 기준). 미리보기 비율에도 쓴다. */
41
+ height: number;
42
+ nodes: TextPresetNode[];
43
+ };
44
+
45
+ const INK = "#111111";
46
+ const MUTED = "#6b6b6b";
47
+
48
+ export const TEXT_PRESETS: TextPreset[] = [
49
+ {
50
+ key: "headingBody",
51
+ height: 190,
52
+ nodes: [
53
+ { key: "heading", y: 0, fontSize: 62, fontWeight: 700, align: "center" },
54
+ {
55
+ key: "body",
56
+ y: 100,
57
+ fontSize: 26,
58
+ fontWeight: 400,
59
+ align: "center",
60
+ height: 80,
61
+ lineHeight: 1.6,
62
+ fill: MUTED,
63
+ },
64
+ ],
65
+ },
66
+ {
67
+ key: "eyebrowHeading",
68
+ height: 160,
69
+ nodes: [
70
+ {
71
+ key: "eyebrow",
72
+ y: 0,
73
+ fontSize: 22,
74
+ fontWeight: 600,
75
+ align: "center",
76
+ letterSpacing: 4,
77
+ fill: MUTED,
78
+ },
79
+ { key: "heading", y: 48, fontSize: 58, fontWeight: 700, align: "center" },
80
+ ],
81
+ },
82
+ {
83
+ key: "numberedStep",
84
+ height: 210,
85
+ nodes: [
86
+ { key: "number", y: 0, fontSize: 40, fontWeight: 700, align: "left", fill: MUTED },
87
+ { key: "heading", y: 56, fontSize: 40, fontWeight: 600, align: "left" },
88
+ {
89
+ key: "body",
90
+ y: 118,
91
+ fontSize: 24,
92
+ fontWeight: 400,
93
+ align: "left",
94
+ height: 84,
95
+ lineHeight: 1.6,
96
+ fill: MUTED,
97
+ },
98
+ ],
99
+ },
100
+ {
101
+ key: "quote",
102
+ height: 220,
103
+ nodes: [
104
+ {
105
+ key: "quote",
106
+ y: 0,
107
+ fontSize: 36,
108
+ fontWeight: 500,
109
+ align: "center",
110
+ height: 130,
111
+ lineHeight: 1.5,
112
+ },
113
+ {
114
+ key: "source",
115
+ y: 158,
116
+ fontSize: 22,
117
+ fontWeight: 400,
118
+ align: "center",
119
+ fill: MUTED,
120
+ },
121
+ ],
122
+ },
123
+ {
124
+ key: "claimFootnote",
125
+ height: 190,
126
+ nodes: [
127
+ {
128
+ key: "claim",
129
+ y: 0,
130
+ fontSize: 46,
131
+ fontWeight: 700,
132
+ align: "center",
133
+ height: 120,
134
+ lineHeight: 1.4,
135
+ },
136
+ {
137
+ key: "footnote",
138
+ y: 146,
139
+ fontSize: 18,
140
+ fontWeight: 400,
141
+ align: "center",
142
+ fill: MUTED,
143
+ },
144
+ ],
145
+ },
146
+ ];
147
+
148
+ function num(value: unknown, fallback = 0): number {
149
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
150
+ }
151
+
152
+ /** 하나의 undo 단계로 묶는다. history가 없으면(테스트 페이크) 그냥 실행한다. */
153
+ function inTransaction<T>(store: StoreLike, run: () => T): T {
154
+ const history = store.history;
155
+ history?.startTransaction?.();
156
+ try {
157
+ return run();
158
+ } finally {
159
+ history?.endTransaction?.();
160
+ }
161
+ }
162
+
163
+ export type InsertTextPresetOptions = {
164
+ /** 문구 해석기 — 패널이 자기 `t`를 넘긴다. */
165
+ text: (nodeKey: string) => string;
166
+ /** 문서에서 물려받을 서체. 안 주면 Pretendard. */
167
+ fontFamily?: string;
168
+ /** 레이어 트리에 뜰 이름. 안 주면 프리셋 키. */
169
+ name?: string;
170
+ };
171
+
172
+ /**
173
+ * 프리셋을 그룹 하나로 놓는다.
174
+ *
175
+ * 자식 좌표는 **페이지 절대 좌표**로 적는다 — 그룹에 base x/y를 남기면 자식이 두 번
176
+ * 밀린다(예전에 카드 밖으로 글자가 튀어나온 원인이 이거였다).
177
+ */
178
+ export function insertTextPreset(
179
+ store: StoreLike,
180
+ preset: TextPreset,
181
+ { text, fontFamily = "Pretendard", name }: InsertTextPresetOptions,
182
+ ): ElementLike | null {
183
+ const page = store.activePage ?? store.pages[0];
184
+ if (!page || !store.groupElements) return null;
185
+
186
+ const pageWidth = num(page.computedWidth, 750);
187
+ const pageHeight = num(page.computedHeight, 1000);
188
+ const width = Math.round(pageWidth * PAGE_RATIO);
189
+ const scale = width / REFERENCE_WIDTH;
190
+ const height = Math.round(preset.height * scale);
191
+ const originX = Math.round((pageWidth - width) / 2);
192
+ const originY = Math.round((pageHeight - height) / 2);
193
+
194
+ return inTransaction(store, () => {
195
+ const ids: string[] = [];
196
+ for (const node of preset.nodes) {
197
+ const fontSize = Math.round(node.fontSize * scale);
198
+ const added = page.addElement(
199
+ {
200
+ type: "text",
201
+ text: text(node.key),
202
+ fontFamily,
203
+ fontSize,
204
+ fontWeight: node.fontWeight,
205
+ fill: node.fill ?? INK,
206
+ align: node.align,
207
+ ...(node.lineHeight ? { lineHeight: node.lineHeight } : {}),
208
+ ...(node.letterSpacing
209
+ ? { letterSpacing: node.letterSpacing * scale }
210
+ : {}),
211
+ x: originX + Math.round(num(node.x) * scale),
212
+ y: originY + Math.round(node.y * scale),
213
+ width: Math.round((node.width ?? REFERENCE_WIDTH) * scale),
214
+ // 상자 높이를 안 주면 넣자마자 글자가 잘린다.
215
+ height: Math.round((node.height ?? node.fontSize * 1.35) * scale),
216
+ },
217
+ // 부품마다 선택이 옮겨 다니면 그사이 우측 인스펙터가 깜빡인다. 최종 선택은
218
+ // groupElements가 그룹으로 잡아 준다.
219
+ { skipSelect: true },
220
+ ) as ElementLike | undefined;
221
+ const id = added?.id;
222
+ if (typeof id === "string") ids.push(id);
223
+ }
224
+ if (ids.length === 0) return null;
225
+ const group = store.groupElements?.(ids, {
226
+ name: name ?? preset.key,
227
+ }) as ElementLike | undefined;
228
+ return group ?? null;
229
+ });
230
+ }
@@ -0,0 +1,67 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+
5
+ /**
6
+ * 긴 AI 생성(이미지·GIF·카피)이 진행되는 동안 "체감상" 진행률을 보여주는 가짜
7
+ * 프로그레스 훅.
8
+ *
9
+ * 백엔드는 실제 진행률을 스트리밍하지 않으므로(폴링만), 대략적인 예상 소요시간을
10
+ * 근거로 감속 곡선을 그려 준다. 핵심 규칙:
11
+ * - 초반엔 빠르게 차오르고 끝으로 갈수록 느려진다(지수 감쇠) — "멈춘 것 같은"
12
+ * 구간을 최소화하고, 예상보다 오래 걸려도 계속 야금야금 전진해 신뢰를 준다.
13
+ * - 완료 전에는 절대 100%에 닿지 않는다(상한 `cap`). 100%에서 오래 머무는 건
14
+ * 가짜 티가 가장 많이 나는 안티패턴이라 피한다.
15
+ * - 완료(`active`가 true→false)되는 순간에만 1(100%)로 스냅한 뒤, 짧은 여운 후
16
+ * 0으로 리셋한다.
17
+ *
18
+ * 반환값은 0~1. `estimateMs`는 그 작업의 대략적 소요시간(초과해도 안전).
19
+ */
20
+ export function useFakeProgress(
21
+ active: boolean,
22
+ estimateMs: number,
23
+ opts?: { cap?: number; tickMs?: number },
24
+ ): number {
25
+ const cap = opts?.cap ?? 0.95;
26
+ const tickMs = opts?.tickMs ?? 120;
27
+ const [progress, setProgress] = useState(0);
28
+ const startRef = useRef<number | null>(null);
29
+
30
+ useEffect(() => {
31
+ if (!active) return;
32
+ // 새 실행 시작: 타임스탬프 고정 후 0부터.
33
+ startRef.current = Date.now();
34
+ setProgress(0);
35
+ const id = window.setInterval(() => {
36
+ const start = startRef.current;
37
+ if (start == null) return;
38
+ const elapsed = Date.now() - start;
39
+ // r=경과/예상. displayed = cap*(1-e^{-2.2r}) → r=1에서 ~0.85cap, 이후 cap로 수렴.
40
+ const r = elapsed / Math.max(1, estimateMs);
41
+ const next = cap * (1 - Math.exp(-2.2 * r));
42
+ setProgress((prev) => (next > prev ? next : prev));
43
+ }, tickMs);
44
+ return () => window.clearInterval(id);
45
+ }, [active, estimateMs, cap, tickMs]);
46
+
47
+ useEffect(() => {
48
+ if (active) return;
49
+ // 완료: 100%로 잠깐 채우고 여운 후 리셋(다음 실행 대비).
50
+ startRef.current = null;
51
+ setProgress((prev) => (prev > 0 ? 1 : 0));
52
+ const id = window.setTimeout(() => setProgress(0), 500);
53
+ return () => window.clearTimeout(id);
54
+ }, [active]);
55
+
56
+ return progress;
57
+ }
58
+
59
+ /** 상세페이지 생성 종류별 대략 예상 소요시간(ms). 실측 기반의 여유 있는 값. */
60
+ export const GENERATION_ESTIMATE_MS = {
61
+ /** gpt-image 계열 이미지 1장(티어 무관 여유값). */
62
+ image: 35_000,
63
+ /** wan i2v GIF: 예측 22s + 다운로드·인코딩·폴링 오버헤드(콜드 포함). */
64
+ gif: 75_000,
65
+ /** 카피/텍스트·프롬프트 편집(LLM 1회 왕복). */
66
+ text: 15_000,
67
+ } as const;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * 정렬 순서(z-order) 한 벌. 우측 패널과 캔버스 우클릭 메뉴가 같은 규칙을 쓴다.
3
+ *
4
+ * **``element.moveUp()`` 을 쓰면 안 된다.** 스톡 편집기의 그 액션은 `e.page`를 기준으로
5
+ * 삼아서, 그룹 **안** 요소에 대고 부르면 조용히 아무 일도 안 한다(no-op). 우리 문서는
6
+ * 대부분이 그룹이라 그 경로는 실질적으로 죽은 경로다. 직접 부모(그룹 또는 페이지)가
7
+ * 공통으로 노출하는 ``setElementZIndex(id, index)``만 쓴다. index가 클수록 앞이다.
8
+ */
9
+
10
+ export type ZOrderParent = {
11
+ children?: ReadonlyArray<{ id: string }>;
12
+ setElementZIndex?: (id: string, index: number) => void;
13
+ };
14
+
15
+ export type ZOrderElement = {
16
+ id: string;
17
+ zIndex?: number;
18
+ parent?: ZOrderParent | null;
19
+ };
20
+
21
+ export type ZOrder = {
22
+ /** 형제 중 몇 번째인가(0 = 맨 뒤). */
23
+ z: number;
24
+ /** 형제 수. */
25
+ count: number;
26
+ atFront: boolean;
27
+ atBack: boolean;
28
+ };
29
+
30
+ /** 순서를 만질 수 있는 요소인가 — 아니면 null(컨트롤을 아예 안 그린다). */
31
+ export function zOrderOf(el: ZOrderElement | undefined | null): ZOrder | null {
32
+ const parent = el?.parent;
33
+ if (!el || !parent?.setElementZIndex) return null;
34
+ const siblings = parent.children ?? [];
35
+ const count = siblings.length;
36
+ // zIndex 게터가 있으면 그걸 믿는다(mobx 관찰 대상). 없으면 형제 배열에서 찾는다.
37
+ const z =
38
+ typeof el.zIndex === "number"
39
+ ? el.zIndex
40
+ : siblings.findIndex((c) => c.id === el.id);
41
+ if (count <= 1 || z < 0) return null;
42
+ return { z, count, atFront: z >= count - 1, atBack: z <= 0 };
43
+ }
44
+
45
+ export type ZMove = "front" | "forward" | "backward" | "back";
46
+
47
+ /** 목표 index. 범위 밖 요청은 끝으로 눌러 담는다. */
48
+ export function zTarget(order: ZOrder, move: ZMove): number {
49
+ const raw =
50
+ move === "front"
51
+ ? order.count - 1
52
+ : move === "back"
53
+ ? 0
54
+ : move === "forward"
55
+ ? order.z + 1
56
+ : order.z - 1;
57
+ return Math.max(0, Math.min(order.count - 1, raw));
58
+ }
59
+
60
+ /** 그 방향으로 더 갈 데가 있는가 — 버튼/메뉴 항목 비활성 판정. */
61
+ export function canMoveZ(order: ZOrder | null, move: ZMove): boolean {
62
+ if (!order) return false;
63
+ return move === "front" || move === "forward" ? !order.atFront : !order.atBack;
64
+ }
65
+
66
+ /** 실제로 옮긴다. 움직일 데가 없으면 false(호출부가 아무 것도 안 해도 되게). */
67
+ export function moveZ(el: ZOrderElement, move: ZMove): boolean {
68
+ const order = zOrderOf(el);
69
+ if (!canMoveZ(order, move)) return false;
70
+ el.parent?.setElementZIndex?.(el.id, zTarget(order!, move));
71
+ return true;
72
+ }
73
+
74
+ /** 임의의 자리로. 우측 패널의 "n/총" 표시와 짝. */
75
+ export function setZ(el: ZOrderElement, index: number): void {
76
+ const order = zOrderOf(el);
77
+ if (!order) return;
78
+ el.parent?.setElementZIndex?.(
79
+ el.id,
80
+ Math.max(0, Math.min(order.count - 1, index)),
81
+ );
82
+ }