@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,257 @@
1
+ /**
2
+ * 아이콘 검색 결과를 그리드에 뿌릴 수 있는 모양으로 다듬는 순수 함수들.
3
+ *
4
+ * 제공처(Iconify)가 돌려주는 것은 `"tabler:truck"` 같은 **문자열 배열**과 세트별 메타뿐이다.
5
+ * 그대로 뿌리면 세 가지가 깨진다.
6
+ *
7
+ * 1. 개수가 큰 세트가 격자를 독점한다(`material-symbols` 혼자 15,585개다).
8
+ * 2. 같은 개념이 변형 이름으로 도배된다 — "truck" 28건 중 `ph:truck-*`만 5건이다.
9
+ * 3. 선 아이콘과 채움 아이콘이 뒤섞여 상세페이지에 넣으면 바로 티가 난다.
10
+ *
11
+ * 그래서 **스타일로 거르고 → 세트를 섞고 → 같은 개념을 접는다.** 이 순서가 중요하다.
12
+ * 접기를 먼저 하면 한 세트가 대표를 다 가져간다.
13
+ *
14
+ * 라이선스 판정도 여기 있다. 세트 이름 목록을 손으로 관리하지 않고 제공처가 주는
15
+ * `license.spdx`를 본다 — 상류가 바뀌어도 무단 자산이 통과하지 못한다.
16
+ */
17
+
18
+ export type IconStyle = "stroke" | "fill";
19
+
20
+ /** 제공처의 세트 메타 중 우리가 쓰는 부분만. */
21
+ export type IconCollectionMeta = {
22
+ name?: string;
23
+ license?: { title?: string; spdx?: string; url?: string };
24
+ /** true면 색이 구워져 있다(Twemoji 등) — 브랜드 색 치환이 안 먹는다. */
25
+ palette?: boolean;
26
+ tags?: string[];
27
+ category?: string;
28
+ };
29
+
30
+ export type IconHit = {
31
+ /** `"tabler:truck"` — 제공처 식별자 그대로. */
32
+ id: string;
33
+ prefix: string;
34
+ name: string;
35
+ /** 변형 접미사를 뗀 개념 이름. 접기 기준이다. */
36
+ concept: string;
37
+ style: IconStyle;
38
+ };
39
+
40
+ /**
41
+ * 제공처가 **표기를 지키기만 하면** 결과물에 출처를 안 박아도 되는 것들.
42
+ *
43
+ * CC BY(예: Font Awesome Free)는 뺐다. 아이콘은 고객 상세페이지에 그대로 박히는
44
+ * 결과물이라 저작자 표시를 붙일 자리가 없다 — 패널 하단 링크로 되는 스톡 사진과 다르다.
45
+ */
46
+ export const ALLOWED_ICON_SPDX: readonly string[] = [
47
+ "MIT",
48
+ "ISC",
49
+ "Apache-2.0",
50
+ "CC0-1.0",
51
+ "Unlicense",
52
+ ];
53
+
54
+ /**
55
+ * 이름 끝에 붙는 변형 접미사. 세트마다 표기가 다르다:
56
+ * Phosphor `-fill/-bold/-thin/-light/-duotone`, Tabler `-filled`,
57
+ * Remix `-line/-fill`, Material Symbols `-outline/-rounded/-sharp`(중첩된다).
58
+ */
59
+ const VARIANT_SUFFIXES: readonly string[] = [
60
+ "outline",
61
+ "outlined",
62
+ "filled",
63
+ "fill",
64
+ "solid",
65
+ "line",
66
+ "duotone",
67
+ "twotone",
68
+ "two-tone",
69
+ "rounded",
70
+ "round",
71
+ "sharp",
72
+ "bold",
73
+ "light",
74
+ "thin",
75
+ "regular",
76
+ ];
77
+
78
+ /** 접미사가 "채움"을 뜻하는 것들. 나머지는 세트 성격을 따른다. */
79
+ const FILL_SUFFIXES = new Set(["fill", "filled", "solid", "duotone", "twotone", "two-tone"]);
80
+ /** 접미사가 "선"을 뜻하는 것들. */
81
+ const STROKE_SUFFIXES = new Set(["outline", "outlined", "line", "thin", "light"]);
82
+
83
+ export function parseIconId(id: string): { prefix: string; name: string } | null {
84
+ const colon = id.indexOf(":");
85
+ if (colon <= 0 || colon === id.length - 1) return null;
86
+ return { prefix: id.slice(0, colon), name: id.slice(colon + 1) };
87
+ }
88
+
89
+ /**
90
+ * 이름에서 변형 접미사를 **모두** 떼어 개념만 남긴다.
91
+ * `desktop-access-disabled-outline-sharp` → `desktop-access-disabled`.
92
+ *
93
+ * 통째로 비어 버리는 이름(`tabler:line`)은 원문을 지킨다 — 그런 아이콘이 실제로 있다.
94
+ */
95
+ export function conceptOf(name: string): string {
96
+ let out = name;
97
+ for (;;) {
98
+ const hit = VARIANT_SUFFIXES.find((suffix) => out.endsWith(`-${suffix}`));
99
+ if (!hit) break;
100
+ const next = out.slice(0, -(hit.length + 1));
101
+ if (!next) break;
102
+ out = next;
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /** 이름에 붙은 변형 접미사 목록(뒤에서 앞으로). 없으면 빈 배열. */
108
+ function suffixesOf(name: string): string[] {
109
+ const out: string[] = [];
110
+ let rest = name;
111
+ for (;;) {
112
+ const hit = VARIANT_SUFFIXES.find((suffix) => rest.endsWith(`-${suffix}`));
113
+ if (!hit) break;
114
+ const next = rest.slice(0, -(hit.length + 1));
115
+ if (!next) break;
116
+ out.push(hit);
117
+ rest = next;
118
+ }
119
+ return out;
120
+ }
121
+
122
+ /**
123
+ * 이 아이콘이 선인지 채움인지.
124
+ *
125
+ * 이름의 접미사가 먼저다 — 한 세트 안에 둘 다 있는 경우가 흔하다(Phosphor, Remix).
126
+ * 접미사가 말이 없으면 세트 메타의 `"Uses Stroke"` 태그를 본다(Lucide·Tabler가 여기 걸린다).
127
+ */
128
+ export function iconStyleOf(name: string, meta?: IconCollectionMeta): IconStyle {
129
+ for (const suffix of suffixesOf(name)) {
130
+ if (FILL_SUFFIXES.has(suffix)) return "fill";
131
+ if (STROKE_SUFFIXES.has(suffix)) return "stroke";
132
+ }
133
+ return meta?.tags?.includes("Uses Stroke") ? "stroke" : "fill";
134
+ }
135
+
136
+ /** 라이선스와 색 구움 여부로 세트를 통과시킬지 정한다. */
137
+ export function isAllowedCollection(
138
+ meta: IconCollectionMeta | undefined,
139
+ allowed: readonly string[] = ALLOWED_ICON_SPDX,
140
+ ): boolean {
141
+ if (!meta) return false;
142
+ // 색이 구워진 세트는 브랜드 색 치환이 안 먹는다 — 라이선스와 무관하게 뺀다.
143
+ if (meta.palette === true) return false;
144
+ const spdx = meta.license?.spdx;
145
+ return typeof spdx === "string" && allowed.includes(spdx);
146
+ }
147
+
148
+ /**
149
+ * 세트별로 한 개씩 돌아가며 뽑는다. 세트 안의 순서(제공처의 관련도)는 지킨다.
150
+ *
151
+ * `order`를 주면 그 순서로 돈다 — 우리가 정한 세트 우선순위를 반영하는 자리다.
152
+ * 목록에 없는 세트는 뒤에 붙는다.
153
+ */
154
+ export function interleaveBySet<T extends { prefix: string }>(
155
+ items: T[],
156
+ order: readonly string[] = [],
157
+ ): T[] {
158
+ const buckets = new Map<string, T[]>();
159
+ for (const item of items) {
160
+ const bucket = buckets.get(item.prefix);
161
+ if (bucket) bucket.push(item);
162
+ else buckets.set(item.prefix, [item]);
163
+ }
164
+
165
+ const prefixes = [...buckets.keys()].sort((a, b) => {
166
+ const ai = order.indexOf(a);
167
+ const bi = order.indexOf(b);
168
+ if (ai === bi) return 0;
169
+ if (ai < 0) return 1;
170
+ if (bi < 0) return -1;
171
+ return ai - bi;
172
+ });
173
+
174
+ const out: T[] = [];
175
+ for (let round = 0; out.length < items.length; round += 1) {
176
+ let moved = false;
177
+ for (const prefix of prefixes) {
178
+ const bucket = buckets.get(prefix)!;
179
+ if (round < bucket.length) {
180
+ out.push(bucket[round]);
181
+ moved = true;
182
+ }
183
+ }
184
+ if (!moved) break;
185
+ }
186
+ return out;
187
+ }
188
+
189
+ export type RankOptions = {
190
+ /** 제공처 검색 응답의 `icons` 배열(`"prefix:name"`). 순서가 관련도다. */
191
+ icons: readonly string[];
192
+ /** 제공처 검색 응답의 `collections`. */
193
+ collections: Readonly<Record<string, IconCollectionMeta>>;
194
+ /** 보고 싶은 축. 안 주면 안 거른다. */
195
+ style?: IconStyle;
196
+ /** 세트 우선순위. 인터리브 순서가 된다. */
197
+ order?: readonly string[];
198
+ /** 허용 SPDX. 기본은 `ALLOWED_ICON_SPDX`. */
199
+ allowedSpdx?: readonly string[];
200
+ /** 최대 개수. */
201
+ limit?: number;
202
+ };
203
+
204
+ /**
205
+ * 원시 결과 → 그리드에 뿌릴 목록.
206
+ *
207
+ * 거르기(라이선스·스타일) → 섞기(세트 인터리브) → 접기(같은 개념 하나) 순이다.
208
+ * 접기를 먼저 하면 목록 앞을 차지한 세트가 대표를 독식한다.
209
+ */
210
+ export function rankIcons({
211
+ icons,
212
+ collections,
213
+ style,
214
+ order = [],
215
+ allowedSpdx = ALLOWED_ICON_SPDX,
216
+ limit,
217
+ }: RankOptions): IconHit[] {
218
+ const kept: IconHit[] = [];
219
+ for (const id of icons) {
220
+ const parsed = parseIconId(id);
221
+ if (!parsed) continue;
222
+ const meta = collections[parsed.prefix];
223
+ if (!isAllowedCollection(meta, allowedSpdx)) continue;
224
+
225
+ const hitStyle = iconStyleOf(parsed.name, meta);
226
+ if (style && hitStyle !== style) continue;
227
+
228
+ kept.push({
229
+ id,
230
+ prefix: parsed.prefix,
231
+ name: parsed.name,
232
+ concept: conceptOf(parsed.name),
233
+ style: hitStyle,
234
+ });
235
+ }
236
+
237
+ const mixed = interleaveBySet(kept, order);
238
+
239
+ const seen = new Set<string>();
240
+ const out: IconHit[] = [];
241
+ for (const hit of mixed) {
242
+ if (seen.has(hit.concept)) continue;
243
+ seen.add(hit.concept);
244
+ out.push(hit);
245
+ if (limit && out.length >= limit) break;
246
+ }
247
+ return out;
248
+ }
249
+
250
+ /** `{prefix: [name, ...]}` — 마크업을 세트당 한 번에 받기 위한 묶음. */
251
+ export function groupByPrefix(hits: readonly IconHit[]): Record<string, string[]> {
252
+ const out: Record<string, string[]> = {};
253
+ for (const hit of hits) {
254
+ (out[hit.prefix] ??= []).push(hit.name);
255
+ }
256
+ return out;
257
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * 아이콘 검색 — 편집기 "요소 · 아이콘" 그룹이 쓰는 얇은 층.
3
+ *
4
+ * 검색은 우리 서버(`/api/icons`)를 거친다. 이유는 그 라우트 주석에 있다(라이선스 게이트 ·
5
+ * 한국어 확장 · 마크업 배치).
6
+ *
7
+ * 사진과 달리 **S3로 옮겨 담지 않는다.** 아이콘은 삽입하는 순간 마크업이 문서 안에
8
+ * 박히므로 저장된 상세페이지가 제공처 수명에 묶이지 않는다.
9
+ */
10
+
11
+ import type { IconStyle } from "./icon-search";
12
+
13
+ export type { IconStyle };
14
+
15
+ /** 일반 아이콘과 브랜드 로고는 성격이 달라 그룹을 나눈다(로고는 상표다). */
16
+ export type IconGroup = "icons" | "logos";
17
+
18
+ export type IconItem = {
19
+ /** `"tabler:truck"` — 제공처 식별자. 최근 목록의 키로도 쓴다. */
20
+ id: string;
21
+ style: IconStyle;
22
+ /** 완성된 `<svg>` 마크업. 모노크롬 세트는 `currentColor`를 쓴다. */
23
+ markup: string;
24
+ viewBox: string;
25
+ /** 사람이 읽는 세트 이름 — 격자에는 안 쓰고 툴팁·출처에만 쓴다. */
26
+ setName: string;
27
+ /** 통과한 SPDX. 화면에는 안 쓰지만 사고가 났을 때 추적할 수 있어야 한다. */
28
+ license: string;
29
+ };
30
+
31
+ export type IconSearchResponse = {
32
+ items: IconItem[];
33
+ group: IconGroup;
34
+ /** 0-based 쪽 번호. */
35
+ page: number;
36
+ /** 다음 쪽이 있다. 격자 바닥에 닿으면 이어 받는다. */
37
+ hasMore: boolean;
38
+ /** 전체 상한에서 잘렸다는 뜻. 패널이 "검색어를 좁혀 보라"고 안내한다. */
39
+ truncated: boolean;
40
+ };
41
+
42
+ export type IconSearchQuery = {
43
+ query: string;
44
+ group?: IconGroup;
45
+ style?: IconStyle;
46
+ /** 0-based. 안 주면 첫 쪽. */
47
+ page?: number;
48
+ signal?: AbortSignal;
49
+ };
50
+
51
+ export async function searchIcons({
52
+ query,
53
+ group = "icons",
54
+ style,
55
+ page = 0,
56
+ signal,
57
+ }: IconSearchQuery): Promise<IconSearchResponse> {
58
+ const params = new URLSearchParams();
59
+ if (query.trim()) params.set("q", query.trim());
60
+ if (group !== "icons") params.set("group", group);
61
+ if (style) params.set("style", style);
62
+ if (page > 0) params.set("page", String(page));
63
+
64
+ const response = await fetch(`/api/icons?${params.toString()}`, { signal });
65
+ if (!response.ok) throw new Error(`icons ${response.status}`);
66
+ return (await response.json()) as IconSearchResponse;
67
+ }
@@ -0,0 +1,121 @@
1
+ // 편집기 AI 이미지(개인 생성 / 우측 실시간 편집) 크레딧 게이트 유틸.
2
+ //
3
+ // 백엔드는 잔액을 못 읽어 원자적 decrement가 <1배에서만 실패한다. 안정성을 위한
4
+ // 1.5배 안전 마진은 프론트가 생성 전 soft 게이트로 건다(잔액 < 1.5×비용이면 차단).
5
+ // 티어별 feature 키는 백엔드 config 기본값과 일치해야 하며, 실제 단가는 중앙
6
+ // Supabase feature_costs에서 읽는다(useCredits). 단가 0(미구성)이면 게이트 비활성.
7
+
8
+ export const IMAGE_CREDIT_MULTIPLIER = 1.5;
9
+ export const DEFAULT_IMAGE_TIER: ImageTier = "pro";
10
+
11
+ export type ImageTier = "basic" | "pro" | "max";
12
+
13
+ /** 선택 UI에 노출할 티어 순서(저→고 품질). */
14
+ export const IMAGE_TIERS: ImageTier[] = ["basic", "pro", "max"];
15
+
16
+ export const IMAGE_FEATURE_KEYS: Record<ImageTier, string> = {
17
+ basic: "detail_page_image_basic",
18
+ pro: "detail_page_image_pro",
19
+ max: "detail_page_image_max",
20
+ };
21
+
22
+ /**
23
+ * AI GIF 생성 feature 키(백엔드 `DETAIL_PAGE_GIF_FEATURE` 기본값과 일치해야 함).
24
+ * 단가는 feature_costs에서 읽으며 현재 50크레딧으로 등록.
25
+ */
26
+ export const GIF_FEATURE_KEY = "detail_page_gif";
27
+
28
+ /**
29
+ * 텍스트→GIF 이펙트 feature 키(백엔드 `DETAIL_PAGE_TEXT_GIF_FEATURE` 기본값과 일치).
30
+ * 헤드리스 렌더라 원가 ~0이라 basic 이미지 티어급 소액(5크레딧)으로 feature_costs 등록.
31
+ */
32
+ export const TEXT_GIF_FEATURE_KEY = "detail_page_text_gif";
33
+
34
+ /**
35
+ * 이미지→GIF 이펙트 feature 키(백엔드 `DETAIL_PAGE_IMAGE_GIF_FEATURE` 기본값과 일치).
36
+ * PIL 프레임 합성이라 AI 원가는 없지만, 홀로그램 포일은 물체 검출(배경제거) 1콜이
37
+ * 붙으므로 텍스트 GIF보다 높게 feature_costs에 등록한다.
38
+ */
39
+ export const IMAGE_GIF_FEATURE_KEY = "detail_page_image_gif";
40
+
41
+ /**
42
+ * 수치→GIF(카운트업·셀 차오름) feature 키(백엔드 `DETAIL_PAGE_DATA_GIF_FEATURE` 기본값과
43
+ * 일치). 텍스트 GIF와 **같은 렌더러**라 원가가 같아 단가도 5크레딧으로 맞췄다.
44
+ */
45
+ export const DATA_GIF_FEATURE_KEY = "detail_page_data_gif";
46
+
47
+ /**
48
+ * 배경 제거(누끼) feature 키(백엔드 `DETAIL_PAGE_BG_REMOVE_FEATURE` 기본값과 일치).
49
+ * 배경제거 모델 1콜이 전부라 이미지 생성보다 훨씬 싸고, 홀로그램 포일이 쓰는 컷아웃
50
+ * 캐시를 공유해 같은 이미지를 다시 눌러도 모델 왕복이 없다. 1크레딧으로 등록.
51
+ */
52
+ export const BG_REMOVE_FEATURE_KEY = "detail_page_bg_remove";
53
+
54
+ /**
55
+ * 티어별 표시 메타. 모델·품질 설명은 백엔드 `_TIER_MODEL_CONFIG`와 일치해야 한다:
56
+ * basic=chatgpt-image-latest/low, pro(기본)=chatgpt-image-latest/medium,
57
+ * max=gpt-image-2/high.
58
+ */
59
+ export const IMAGE_TIER_META: Record<
60
+ ImageTier,
61
+ {
62
+ /** 드롭다운에 보이는 짧은 이름. */
63
+ label: string;
64
+ /** 한 줄 품질 요약. */
65
+ quality: string;
66
+ /** 툴팁 상세(속도·용도 트레이드오프). */
67
+ description: string;
68
+ /** 배지/추천 표기(선택). */
69
+ badge?: string;
70
+ /**
71
+ * 라이브 단가(feature_costs)를 못 읽는 데모(dev-canvas)에서 쓰는 대체 단가.
72
+ * 실제 편집기는 useCredits의 getCost로 덮어쓴다. 값은 중앙 feature_costs와
73
+ * 맞춰야 한다(2026-07-20: 5 / 20 / 70).
74
+ */
75
+ fallbackCost: number;
76
+ }
77
+ > = {
78
+ basic: {
79
+ label: "베이직",
80
+ quality: "빠르고 저렴 · 기본 품질",
81
+ description:
82
+ "가장 빠르고 저렴해요. 초안·썸네일처럼 가볍게 여러 장 시도할 때 좋아요. (저화질)",
83
+ fallbackCost: 5,
84
+ },
85
+ pro: {
86
+ label: "프로",
87
+ quality: "권장 · 균형잡힌 품질/속도",
88
+ description:
89
+ "품질과 속도의 균형이 가장 좋아 기본값으로 권장해요. 대부분의 상세페이지 컷에 적합합니다. (중화질)",
90
+ badge: "추천",
91
+ fallbackCost: 20,
92
+ },
93
+ max: {
94
+ label: "맥스",
95
+ quality: "최고 품질 · 느리고 비쌈",
96
+ description:
97
+ "가장 정교한 최고 화질이지만 생성이 느리고 크레딧이 많이 들어요. 최종 확정 컷에만 쓰는 걸 권장해요. (고화질)",
98
+ fallbackCost: 70,
99
+ },
100
+ };
101
+
102
+ export function imageFeatureKey(tier?: string): string {
103
+ return IMAGE_FEATURE_KEYS[(tier as ImageTier) ?? DEFAULT_IMAGE_TIER] ?? IMAGE_FEATURE_KEYS.pro;
104
+ }
105
+
106
+ /** 데모(라이브 단가 미주입)에서 쓸 티어별 대체 단가 맵. */
107
+ export const IMAGE_TIER_FALLBACK_COST: Record<ImageTier, number> = {
108
+ basic: IMAGE_TIER_META.basic.fallbackCost,
109
+ pro: IMAGE_TIER_META.pro.fallbackCost,
110
+ max: IMAGE_TIER_META.max.fallbackCost,
111
+ };
112
+
113
+ /** 생성/편집에 필요한 최소 보유 크레딧(비용의 1.5배, 올림). */
114
+ export function imageCreditRequired(cost: number): number {
115
+ return Math.ceil(Math.max(0, cost) * IMAGE_CREDIT_MULTIPLIER);
116
+ }
117
+
118
+ /** 잔액이 1.5배 미만이면 차단. 비용 0(미구성)이면 항상 허용. */
119
+ export function isImageCreditBlocked(cost: number, balance: number): boolean {
120
+ return cost > 0 && balance < imageCreditRequired(cost);
121
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * 편집기 이미지 src 를 **캔버스에 그릴 수 있는** data URI 로 바꾼다.
3
+ *
4
+ * 주석 합성은 밑그림을 캔버스에 그린 다음 ``toDataURL`` 로 뽑는다. 교차 출처 이미지를
5
+ * 그리면 그 캔버스가 오염돼 ``toDataURL`` 이 SecurityError 로 터진다 — 그것도 유저가
6
+ * 다 그리고 제출을 누른 **다음에야**. 그래서 그리기 전에 바이트를 먼저 확보한다.
7
+ *
8
+ * 실패하면 ``null``. 호출부는 그때 "그림으로 지시"를 열지 않거나 글로만 받는다.
9
+ */
10
+ export async function toDrawableDataUri(src: string): Promise<string | null> {
11
+ const cleaned = String(src || "").trim();
12
+ if (!cleaned) return null;
13
+ if (cleaned.startsWith("data:")) return cleaned;
14
+ try {
15
+ const res = await fetch(cleaned);
16
+ if (!res.ok) return null;
17
+ const blob = await res.blob();
18
+ return await new Promise<string | null>((resolve) => {
19
+ const reader = new FileReader();
20
+ reader.onloadend = () =>
21
+ resolve(typeof reader.result === "string" ? reader.result : null);
22
+ reader.onerror = () => resolve(null);
23
+ reader.readAsDataURL(blob);
24
+ });
25
+ } catch {
26
+ // 교차 출처·CORS 미허용. 원본 URL 을 그대로 돌려주면 캔버스가 오염되므로 안 준다.
27
+ return null;
28
+ }
29
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Insert a personal image/GIF onto the active Canvas page.
3
+ *
4
+ * Every image is measured and placed at its own aspect ratio, fitted inside a
5
+ * box of 62% of the page. Stills used to drop in as a fixed ``62% × 62%``
6
+ * *square* regardless of the source: on a 750-wide page every asset landed at
7
+ * 465×465, and since Canvas cover-crops images, a tall product cutout was
8
+ * center-cropped to a square. Fitting to the real ratio makes the element match
9
+ * the source, so the cover-crop becomes a no-op.
10
+ *
11
+ * The box is capped on both axes — capping width alone let a tall cutout
12
+ * (e.g. 171×669) come in at 465×1817 and run off a short section page.
13
+ *
14
+ * GIFs are additionally tagged ``custom.detailPageGif`` so the exporter treats
15
+ * their section as animated even if the presigned URL lacks a ``.gif`` suffix.
16
+ */
17
+
18
+ type AddElementOpts = Record<string, unknown>;
19
+ type PageLike = {
20
+ computedWidth: number;
21
+ computedHeight: number;
22
+ addElement: (opts: AddElementOpts) => unknown;
23
+ };
24
+ type StoreLike = { activePage?: PageLike; pages: PageLike[] };
25
+
26
+ const BOX_RATIO = 0.62;
27
+
28
+ export function insertPersonalImage(
29
+ store: unknown,
30
+ src: string,
31
+ opts: { isGif?: boolean } = {},
32
+ ): void {
33
+ const s = store as StoreLike;
34
+ const page = s.activePage ?? s.pages[0];
35
+ if (!page) return;
36
+ const maxWidth = page.computedWidth * BOX_RATIO;
37
+ const maxHeight = page.computedHeight * BOX_RATIO;
38
+
39
+ const place = (width: number, height: number) => {
40
+ page.addElement({
41
+ type: "image",
42
+ src,
43
+ x: Math.round((page.computedWidth - width) / 2),
44
+ y: Math.round((page.computedHeight - height) / 2),
45
+ width,
46
+ height,
47
+ // GIF는 레이어 트리에서 "image-2" 같은 자동 이름 대신 "GIF"로 보이게 이름을
48
+ // 박고, 내보내기가 이 섹션을 애니메이션으로 다루도록 custom 플래그를 단다.
49
+ ...(opts.isGif ? { name: "GIF", custom: { detailPageGif: true } } : {}),
50
+ });
51
+ };
52
+
53
+ /** 원본 비율을 박스 안에 맞춘다. 어느 축으로도 박스를 넘지 않는다. */
54
+ const fit = (naturalWidth: number, naturalHeight: number) => {
55
+ if (naturalWidth <= 0 || naturalHeight <= 0) {
56
+ place(Math.round(maxWidth), Math.round(maxWidth));
57
+ return;
58
+ }
59
+ const scale = Math.min(maxWidth / naturalWidth, maxHeight / naturalHeight);
60
+ place(Math.round(naturalWidth * scale), Math.round(naturalHeight * scale));
61
+ };
62
+
63
+ // Load with the same ``crossOrigin`` mode Canvas/Konva uses for the canvas.
64
+ // The S3 bucket DOES send CORS headers, but only when the request carries an
65
+ // ``Origin`` (and it omits ``Vary: Origin``): a no-CORS preload here caches an
66
+ // ACAO-less 200 that Konva's later crossOrigin load then reuses → blocked as
67
+ // "Can not load the image...". Matching the mode makes both share a CORS-safe
68
+ // cache entry. data: URIs never need CORS.
69
+ const img = new Image();
70
+ if (!src.startsWith("data:")) img.crossOrigin = "anonymous";
71
+ img.onload = () => fit(img.naturalWidth, img.naturalHeight);
72
+ // 측정 실패는 배치 실패가 아니다 — 예전처럼 정사각으로라도 넣는다.
73
+ img.onerror = () => place(Math.round(maxWidth), Math.round(maxWidth));
74
+ img.src = src;
75
+ }
@@ -0,0 +1,49 @@
1
+ import { encodeSvgDataUri } from "../detail-page-canvas/export/svg";
2
+
3
+ /**
4
+ * 공용/개인 도형(SVG 마크업)을 현재 페이지 중앙에 ``svg`` 요소로 삽입한다.
5
+ *
6
+ * Canvas svg 요소는 마크업을 ``src``에 data URI로 담는다(편집기의 인라인 렌더가
7
+ * el.src를 디코드한다). viewBox 종횡비를 유지하고, 아이콘이 과하게 커지지 않게
8
+ * 페이지 폭 대비 적당한 크기로 배치한다.
9
+ */
10
+
11
+ type AddElementOpts = Record<string, unknown>;
12
+ type PageLike = {
13
+ computedWidth: number;
14
+ computedHeight: number;
15
+ addElement: (opts: AddElementOpts) => unknown;
16
+ };
17
+ type StoreLike = { activePage?: PageLike; pages: PageLike[] };
18
+
19
+ function aspectFromViewBox(viewBox: string): number {
20
+ const parts = viewBox.trim().split(/\s+/).map(Number);
21
+ if (parts.length === 4 && parts[2] > 0 && parts[3] > 0) {
22
+ return parts[2] / parts[3];
23
+ }
24
+ return 1;
25
+ }
26
+
27
+ export function insertShape(
28
+ store: unknown,
29
+ markup: string,
30
+ viewBox = "0 0 24 24",
31
+ ): void {
32
+ const s = store as StoreLike;
33
+ const page = s.activePage ?? s.pages[0];
34
+ if (!page || !markup) return;
35
+
36
+ const aspect = aspectFromViewBox(viewBox);
37
+ // 아이콘/장식이라 페이지 폭의 ~18%(상한 200px)로 얌전하게.
38
+ const width = Math.round(Math.min(page.computedWidth * 0.18, 200));
39
+ const height = Math.round(width / (aspect || 1));
40
+
41
+ page.addElement({
42
+ type: "svg",
43
+ src: encodeSvgDataUri(markup),
44
+ x: Math.round((page.computedWidth - width) / 2),
45
+ y: Math.round((page.computedHeight - height) / 2),
46
+ width,
47
+ height,
48
+ });
49
+ }