@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,167 @@
1
+ /**
2
+ * '수치를 GIF로' 요청 본문 만들기 + 선택 텍스트에서 숫자 읽어내기.
3
+ *
4
+ * 운영 편집기(editor-client)와 데모 하니스(dev-canvas)가 같은 계약으로 호출해야 해서
5
+ * 한 곳에 둔다 — 한쪽에만 필드를 더하면 데모에서만 값이 빠지는 식으로 갈린다.
6
+ *
7
+ * 카운트업은 **입력 폼을 따로 두지 않는다.** 이미 캔버스에 적어 둔 "279.45%" 를 고르면
8
+ * 거기서 목표값·소수 자릿수·접두/접미사를 읽는다. 숫자를 두 번 적게 하면 캔버스의 값과
9
+ * GIF의 값이 어긋나는 사고가 난다.
10
+ */
11
+
12
+ export type CountUpParsed = {
13
+ to: number;
14
+ decimals: number;
15
+ /** 원문에 천 단위 쉼표가 있었는지(없었으면 GIF에도 안 찍는다). */
16
+ grouping: boolean;
17
+ prefix: string;
18
+ suffix: string;
19
+ };
20
+
21
+ /** 서버 스키마 상한(스키마를 넘기면 422). */
22
+ const AFFIX_MAX = 8;
23
+ const DECIMALS_MAX = 4;
24
+
25
+ /**
26
+ * 텍스트 한 줄에서 숫자 하나를 읽는다. 숫자가 없으면 ``null`` — 호출부는 그때 섹션을
27
+ * 아예 감춘다("2천만 개" 같은 문구에 카운트업 버튼을 띄워도 눌러봐야 헛것이 나온다).
28
+ */
29
+ export function parseCountUpText(raw: string): CountUpParsed | null {
30
+ const text = String(raw ?? "").trim();
31
+ // 쉼표는 자릿수 구분으로만 인정한다(뒤에 정확히 세 자리가 올 때).
32
+ const match = text.match(/\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?/);
33
+ if (!match || match.index === undefined) return null;
34
+ const token = match[0];
35
+ const to = Number(token.replace(/,/g, ""));
36
+ if (!Number.isFinite(to)) return null;
37
+ const dot = token.indexOf(".");
38
+ return {
39
+ to,
40
+ decimals: dot < 0 ? 0 : Math.min(token.length - dot - 1, DECIMALS_MAX),
41
+ grouping: token.includes(","),
42
+ prefix: text.slice(0, match.index).slice(-AFFIX_MAX),
43
+ suffix: text.slice(match.index + token.length).slice(0, AFFIX_MAX),
44
+ };
45
+ }
46
+
47
+ /** SVG ``text-anchor``. Canvas 의 left/center/right 를 그대로 옮긴 것. */
48
+ export type TextAnchor = "start" | "middle" | "end";
49
+
50
+ /**
51
+ * 편집기 정렬 → SVG 앵커. 모르는 값은 왼쪽으로 둔다(Canvas 기본값과 같다).
52
+ *
53
+ * 상자가 글자보다 넓을 때는 정렬이 곧 자리다. 이걸 안 넘기면 서버가 항상 가운데로
54
+ * 그려서, 제자리 교체인데 숫자만 가운데로 옮겨 앉는다.
55
+ */
56
+ export function textAnchorOf(align: unknown): TextAnchor {
57
+ const value = String(align ?? "")
58
+ .trim()
59
+ .toLowerCase();
60
+ if (value === "center") return "middle";
61
+ if (value === "right") return "end";
62
+ return "start";
63
+ }
64
+
65
+ export type CountUpGifInput = {
66
+ kind: "count_up";
67
+ to: number;
68
+ start?: number;
69
+ decimals?: number;
70
+ grouping?: boolean;
71
+ prefix?: string;
72
+ suffix?: string;
73
+ color?: string;
74
+ fontSize?: number;
75
+ fontWeight?: number;
76
+ fontFamily?: string;
77
+ letterSpacing?: number;
78
+ anchor?: TextAnchor;
79
+ italic?: boolean;
80
+ /** 형광 마커 띠 색. 선택 텍스트에 하이라이트가 걸려 있으면 그 색이 온다. */
81
+ marker?: string;
82
+ /** 원본 상자 크기와 글자 기준선(px). 주면 서버가 캔버스를 추정하지 않는다. */
83
+ width?: number;
84
+ height?: number;
85
+ };
86
+
87
+ export type CellGridGifInput = {
88
+ kind: "cell_grid";
89
+ /** 행별로 채울 칸 수(위→아래). */
90
+ filled: number[];
91
+ cols?: number;
92
+ shape?: string;
93
+ size?: number;
94
+ pitchX?: number;
95
+ pitchY?: number;
96
+ fill?: string;
97
+ empty?: string;
98
+ order?: string;
99
+ };
100
+
101
+ export type DataGifRequestInput = (CountUpGifInput | CellGridGifInput) & {
102
+ background?: string;
103
+ transparent?: boolean;
104
+ fonts?: Array<{ family: string; url: string; weight: number }>;
105
+ brandId?: string;
106
+ };
107
+
108
+ export function buildDataGifPayload(input: DataGifRequestInput) {
109
+ const common = {
110
+ background: input.background,
111
+ transparent: input.transparent,
112
+ fonts: input.fonts ?? [],
113
+ brand_id: input.brandId,
114
+ };
115
+ if (input.kind === "count_up") {
116
+ return {
117
+ kind: "count_up",
118
+ count_up: {
119
+ to: input.to,
120
+ start: input.start,
121
+ decimals: input.decimals,
122
+ grouping: input.grouping,
123
+ prefix: input.prefix,
124
+ suffix: input.suffix,
125
+ color: input.color,
126
+ font_size: input.fontSize,
127
+ font_weight: input.fontWeight,
128
+ font_family: input.fontFamily,
129
+ letter_spacing: input.letterSpacing,
130
+ anchor: input.anchor,
131
+ italic: input.italic,
132
+ marker: input.marker,
133
+ width: input.width,
134
+ height: input.height,
135
+ },
136
+ ...common,
137
+ };
138
+ }
139
+ return {
140
+ kind: "cell_grid",
141
+ cell_grid: {
142
+ filled: input.filled,
143
+ cols: input.cols,
144
+ shape: input.shape,
145
+ size: input.size,
146
+ pitch_x: input.pitchX,
147
+ pitch_y: input.pitchY,
148
+ fill: input.fill,
149
+ empty: input.empty,
150
+ order: input.order,
151
+ },
152
+ ...common,
153
+ };
154
+ }
155
+
156
+ /**
157
+ * "6,4,3" / "6 4 3" 같은 입력 → 행별 채움 수. 서버가 422로 되돌려 주기 전에 여기서
158
+ * 숫자가 아닌 것과 음수를 걸러 낸다.
159
+ */
160
+ export function parseFilledRows(raw: string): number[] {
161
+ return String(raw ?? "")
162
+ .split(/[^\d]+/)
163
+ .filter(Boolean)
164
+ .map((v) => Math.max(0, Math.trunc(Number(v))))
165
+ .filter((v) => Number.isFinite(v))
166
+ .slice(0, 40);
167
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * 디자인 레퍼런스 — "이런 식으로"를 그림으로 가리키는 입력의 **브라우저 쪽 계약**.
3
+ *
4
+ * 서버(``app/services/detail_page/design_reference.py``)와 같은 어휘·같은 상한을 쓴다.
5
+ * 한쪽만 고치면 화면에서는 붙었는데 요청이 422 로 돌아오거나, 반대로 화면이 미리 막아
6
+ * 서버가 허용하는 것을 못 넣게 된다.
7
+ *
8
+ * ## 왜 축을 고르게 하는가
9
+ *
10
+ * 레퍼런스를 통째로 따라가면 남의 상세페이지가 된다. 실제로 원하는 것은 축 하나씩이다 —
11
+ * 이 장에서는 색감만, 저 장에서는 배치만. 축을 안 고르면 예전처럼 배치와 구성만
12
+ * 참고한다(기존 동작이 기본값이다).
13
+ *
14
+ * ## 왜 붙이는 자리에서 막는가
15
+ *
16
+ * 크기·형식 검사는 서버에도 있다. 그런데 서버에서만 막으면 유저는 **제출을 누른 다음에야**
17
+ * 알게 된다 — 그림을 고르고, 축을 고르고, 지시를 다 적은 뒤다. 같은 규칙을 붙이는
18
+ * 자리에도 두어 그 자리에서 말해 준다.
19
+ */
20
+
21
+ import { shrinkReferenceDataUri } from "./reference-image";
22
+
23
+ /**
24
+ * 참고할 축. 서버의 ``ASPECT_LABELS`` 와 **키가 같아야 한다**.
25
+ *
26
+ * ``content`` 는 **문구가 아니라 정보 구성**이다 — 어떤 항목을 어떤 순서로 다루는지
27
+ * (성분표 · 사용법 · 비교표 · 후기). 남의 문장을 옮겨 오는 축이 아니라서 설명도 그렇게
28
+ * 적는다. 셀러가 "내용을 참고"를 "문구를 베낀다"로 읽으면 안 된다.
29
+ */
30
+ export const DESIGN_REFERENCE_ASPECTS = [
31
+ { key: "palette", label: "색감", hint: "바탕·글자·강조의 색 관계" },
32
+ { key: "typography", label: "서체", hint: "굵기 대비와 글자 크기 리듬" },
33
+ { key: "layout", label: "레이아웃", hint: "단 수, 정렬, 여백 리듬" },
34
+ { key: "content", label: "내용 구성", hint: "다루는 정보 항목과 순서" },
35
+ { key: "mood", label: "분위기", hint: "여백의 양과 요소 밀도" },
36
+ { key: "decoration", label: "장식", hint: "구분선·도형의 성격" },
37
+ ] as const;
38
+
39
+ export type DesignReferenceAspect = (typeof DESIGN_REFERENCE_ASPECTS)[number]["key"];
40
+
41
+ /** 레퍼런스 한 장. 서버 요청 본문과 같은 모양이다. */
42
+ export type DesignReference = {
43
+ url: string;
44
+ aspects: DesignReferenceAspect[];
45
+ };
46
+
47
+ /**
48
+ * 장수 상한. 서버와 **같은 수**여야 한다 — 프론트가 더 보내면 서버가 거절하고,
49
+ * 서버보다 적게 막으면 유저가 넣을 수 있는 것을 못 넣는다.
50
+ */
51
+ export const MAX_DESIGN_REFERENCES = 6;
52
+
53
+ /**
54
+ * 고를 수 있는 파일 형식. 비전 모델이 읽는 것은 정지 이미지뿐이라 PDF·HEIC 는 못 쓴다.
55
+ *
56
+ * GIF 는 받는다. 움짤이어도 줄이기가 첫 프레임을 굳히고(캔버스는 한 프레임만 그린다),
57
+ * 줄이기를 건너뛴 작은 GIF 는 서버가 평탄화한다. 막는 것보다 첫 프레임으로 읽히는 편이
58
+ * 낫다 — "이 움짤 느낌으로"는 멀쩡한 요청이다.
59
+ */
60
+ export const ACCEPTED_REFERENCE_TYPES = [
61
+ "image/png",
62
+ "image/jpeg",
63
+ "image/webp",
64
+ "image/gif",
65
+ ] as const;
66
+
67
+ /** ``<input accept>`` 에 넣을 값. */
68
+ export const REFERENCE_ACCEPT_ATTR = ACCEPTED_REFERENCE_TYPES.join(",");
69
+
70
+ /** 고를 수 있는 원본 파일 크기. 이보다 크면 줄이기 전에 브라우저 메모리가 먼저 눕는다. */
71
+ export const MAX_REFERENCE_FILE_BYTES = 12 * 1024 * 1024;
72
+
73
+ /** 줄인 **뒤** 서버가 받아 주는 한 장의 크기. 서버의 ``MAX_REFERENCE_BYTES`` 와 같다. */
74
+ export const MAX_REFERENCE_UPLOAD_BYTES = 4 * 1024 * 1024;
75
+
76
+ /** 붙일 수 없는 파일이면 이유를, 괜찮으면 null. */
77
+ export function referenceFileRejection(file: File): string | null {
78
+ const type = String(file.type || "").toLowerCase();
79
+ if (!ACCEPTED_REFERENCE_TYPES.includes(type as (typeof ACCEPTED_REFERENCE_TYPES)[number])) {
80
+ return "PNG · JPG · WEBP · GIF 만 참고 사진으로 쓸 수 있어요.";
81
+ }
82
+ if (file.size > MAX_REFERENCE_FILE_BYTES) {
83
+ const limit = Math.round(MAX_REFERENCE_FILE_BYTES / (1024 * 1024));
84
+ return `참고 사진은 한 장에 ${limit}MB 까지예요.`;
85
+ }
86
+ return null;
87
+ }
88
+
89
+ /** base64 data URI 가 실제로 몇 바이트인지. 요청 본문 크기를 재는 데 쓴다. */
90
+ export function dataUriByteLength(uri: string): number {
91
+ const comma = uri.indexOf(",");
92
+ if (comma < 0) return 0;
93
+ const payload = uri.slice(comma + 1);
94
+ const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0;
95
+ return Math.max(0, Math.floor((payload.length * 3) / 4) - padding);
96
+ }
97
+
98
+ /**
99
+ * 고른 파일을 **바로 걸 수 있는** data URI 로 읽는다. 줄이기는 여기서 하지 않는다.
100
+ *
101
+ * 썸네일을 줄이기까지 기다렸다가 띄우면 안 된다 — 줄이기는 디코드를 기다리는 일이고,
102
+ * 디코더가 못 읽는 파일에서는 몇 초 뒤에야 포기한다. 그 사이 화면에 아무것도 안 뜨면
103
+ * 유저는 첨부가 먹히지 않은 줄 안다. 형식·원본 크기는 **읽기 전에** 이미 걸렀다.
104
+ *
105
+ * @throws Error 붙일 수 없는 파일. message 를 그대로 보여 주면 된다.
106
+ */
107
+ export async function readReferenceFile(file: File): Promise<string> {
108
+ const rejection = referenceFileRejection(file);
109
+ if (rejection) throw new Error(rejection);
110
+ return readFileAsDataUri(file);
111
+ }
112
+
113
+ /**
114
+ * 붙여 둔 레퍼런스를 보낼 크기로 줄인다.
115
+ *
116
+ * 줄이기는 **실패해도 원본을 돌려준다**(``shrinkReferenceDataUri``). 그래서 줄인 뒤에 한
117
+ * 번 더 잰다 — 줄이기가 못 돈 큰 그림이 그대로 서버까지 가서 422 로 돌아오면, 유저는
118
+ * 붙이는 자리가 아니라 제출 자리에서 실패를 본다.
119
+ *
120
+ * 크기(``inputTokens``)도 여기서 함께 잰다. 판독 값이 그 크기로 정해지는데, 재는 자리를
121
+ * 따로 두면 그림을 한 번 더 디코딩하게 된다. 못 쟀으면 0 이고, 값은 비싼 쪽으로 잡힌다.
122
+ *
123
+ * @returns 줄인 data URI, 또는 그래도 상한을 넘겼으면 이유.
124
+ */
125
+ export async function finalizeReferenceDataUri(
126
+ original: string,
127
+ ): Promise<{ uri: string; inputTokens: number } | { error: string }> {
128
+ const shrunk = await shrinkReferenceDataUri(original);
129
+ if (dataUriByteLength(shrunk.uri) > MAX_REFERENCE_UPLOAD_BYTES) {
130
+ const limit = Math.round(MAX_REFERENCE_UPLOAD_BYTES / (1024 * 1024));
131
+ return {
132
+ error: `참고 사진을 ${limit}MB 아래로 줄이지 못했어요. 더 작은 그림을 써 주세요.`,
133
+ };
134
+ }
135
+ return {
136
+ uri: shrunk.uri,
137
+ inputTokens: estimateImageInputTokens(shrunk.width, shrunk.height),
138
+ };
139
+ }
140
+
141
+ function readFileAsDataUri(file: File): Promise<string> {
142
+ return new Promise((resolve, reject) => {
143
+ const reader = new FileReader();
144
+ reader.onloadend = () =>
145
+ typeof reader.result === "string"
146
+ ? resolve(reader.result)
147
+ : reject(new Error("참고 사진을 읽지 못했어요."));
148
+ reader.onerror = () => reject(new Error("참고 사진을 읽지 못했어요."));
149
+ reader.readAsDataURL(file);
150
+ });
151
+ }
152
+
153
+ /** 셀러가 "1번 이미지"라고 쓰므로 화면도 1부터 센다. */
154
+ export function referenceOrdinal(index: number): string {
155
+ return `${index + 1}번`;
156
+ }
157
+
158
+ /* ---- 판독 크레딧 ------------------------------------------------------------ *
159
+ *
160
+ * 판독은 **선차감**이다. 그래서 값은 누르기 전에 화면에 떠 있어야 하고, 그러려면 화면이
161
+ * 서버와 **같은 공식**을 갖고 있어야 한다. 서버가 실제 usage 가 아니라 추정으로 청구하는
162
+ * 이유도 이것이다(``app/services/detail_page/design_brief.py``) — 미리 말해 준 값과 실제
163
+ * 청구가 다르면 그건 안내가 아니다.
164
+ *
165
+ * 값을 정하는 것은 장수가 아니라 **그림의 크기**다. 비전 입력은 픽셀이 아니라 512px 타일
166
+ * 수로 청구되므로, 정사각 썸네일과 세로로 긴 상세페이지 캡쳐는 같은 한 장이어도 값이
167
+ * 두 배 가까이 벌어진다.
168
+ */
169
+
170
+ const IMAGE_TOKENS_BASE = 85;
171
+ const IMAGE_TOKENS_PER_TILE = 170;
172
+ const IMAGE_TILE_PX = 512;
173
+ const IMAGE_SHORT_SIDE_PX = 768;
174
+ const IMAGE_LONG_SIDE_PX = 2048;
175
+
176
+ /**
177
+ * 크기를 못 잰 그림 한 장으로 잡는 토큰. 서버의 ``UNKNOWN_IMAGE_TOKENS`` 와 같다.
178
+ *
179
+ * 디코더가 못 읽는 파일이면 크기를 모르는 채로 값을 말해야 한다. 그때 싸게 잡으면
180
+ * 화면이 말한 값보다 실제 청구가 커진다 — 모르는 쪽은 **비싸게** 잡아야 안전하다.
181
+ */
182
+ export const UNKNOWN_IMAGE_TOKENS = 1445;
183
+
184
+ /** 그림을 뺀 나머지 입력(시스템 프롬프트 + 딱지 + 지시)의 대략치. 서버와 같은 값. */
185
+ const BRIEF_PROMPT_OVERHEAD_TOKENS = 1100;
186
+
187
+ /** 판독 출력의 대략치. 몇 장을 붙이든 돌려주는 것은 같은 크기의 JSON 한 덩이다. */
188
+ const BRIEF_OUTPUT_ALLOWANCE_TOKENS = 500;
189
+
190
+ /** 판독 모델(gpt-5.6-luna) USD per 1M = [입력, 출력]. 서버 단가표와 같아야 한다. */
191
+ const BRIEF_MODEL_PRICE_USD_PER_MILLION = [0.2, 1.2] as const;
192
+
193
+ /** 원가 → 크레딧 환산. 카피·재저작과 **같은 환율**이다(원가 1.5원 = 1크레딧). */
194
+ const USD_TO_KRW_RATE = 1500;
195
+ const KRW_PER_CREDIT = 1.5;
196
+
197
+ /**
198
+ * 그림 한 장이 비전 입력에서 차지하는 토큰. 서버의 ``estimate_image_input_tokens``.
199
+ *
200
+ * 긴 변을 2048 안에, 그다음 짧은 변을 768 로 **줄이기만** 한다(작은 그림을 키우면 없는
201
+ * 비용이 생긴다). 크기를 모르면 {@link UNKNOWN_IMAGE_TOKENS}.
202
+ */
203
+ export function estimateImageInputTokens(width: number, height: number): number {
204
+ let w = Math.floor(Number(width) || 0);
205
+ let h = Math.floor(Number(height) || 0);
206
+ if (w <= 0 || h <= 0) return UNKNOWN_IMAGE_TOKENS;
207
+
208
+ const longest = Math.max(w, h);
209
+ if (longest > IMAGE_LONG_SIDE_PX) {
210
+ const scale = IMAGE_LONG_SIDE_PX / longest;
211
+ w = Math.max(1, Math.round(w * scale));
212
+ h = Math.max(1, Math.round(h * scale));
213
+ }
214
+ const shortest = Math.min(w, h);
215
+ if (shortest > IMAGE_SHORT_SIDE_PX) {
216
+ const scale = IMAGE_SHORT_SIDE_PX / shortest;
217
+ w = Math.max(1, Math.round(w * scale));
218
+ h = Math.max(1, Math.round(h * scale));
219
+ }
220
+ const tiles = Math.ceil(w / IMAGE_TILE_PX) * Math.ceil(h / IMAGE_TILE_PX);
221
+ return IMAGE_TOKENS_BASE + IMAGE_TOKENS_PER_TILE * tiles;
222
+ }
223
+
224
+ /**
225
+ * 붙여 둔 레퍼런스를 읽는 데 드는 크레딧. 서버의 ``estimate_brief_credits`` 와 같은 값을
226
+ * 낸다 — 다르면 화면이 말한 값과 청구가 어긋난다.
227
+ *
228
+ * @param imageTokens 장별 입력 토큰. 못 잰 장은 0 으로 넣으면 비싼 쪽으로 잡는다.
229
+ */
230
+ export function estimateBriefCredits(imageTokens: number[]): number {
231
+ if (!imageTokens.length) return 0;
232
+ const [inputPrice, outputPrice] = BRIEF_MODEL_PRICE_USD_PER_MILLION;
233
+ const inputTokens =
234
+ BRIEF_PROMPT_OVERHEAD_TOKENS +
235
+ imageTokens.reduce(
236
+ (sum, tokens) => sum + (tokens > 0 ? tokens : UNKNOWN_IMAGE_TOKENS),
237
+ 0,
238
+ );
239
+ const costUsd =
240
+ (inputTokens / 1_000_000) * inputPrice +
241
+ (BRIEF_OUTPUT_ALLOWANCE_TOKENS / 1_000_000) * outputPrice;
242
+ if (costUsd <= 0) return 0;
243
+ return Math.max(1, Math.ceil((costUsd * USD_TO_KRW_RATE) / KRW_PER_CREDIT));
244
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * 선택 요소끼리 **간격**을 고르게. Figma·Canva의 distribute.
3
+ *
4
+ * 정렬(``alignInFrame``)은 기준 상자 안에서 한 줄로 세우는 것이고, 이건 이미 흩어져
5
+ * 있는 것들의 사이를 고르게 벌리는 것이다. 아이콘 3~5개를 나란히 놓는 배치에서
6
+ * 지금까지는 매번 눈대중이었다.
7
+ *
8
+ * **중심 간격이 아니라 여백을 고르게 한다.** 크기가 다른 요소들에서 중심을 등간격으로
9
+ * 두면 큰 것 옆이 좁아 보인다 — 눈이 재는 건 사이 여백이다.
10
+ */
11
+
12
+ export type DistributeAxis = "x" | "y";
13
+
14
+ export type DistributeItem = {
15
+ id: string;
16
+ /** 축 방향 시작 좌표(x 또는 y). */
17
+ start: number;
18
+ /** 축 방향 크기(width 또는 height). */
19
+ size: number;
20
+ };
21
+
22
+ /**
23
+ * 각 요소가 가야 할 시작 좌표. 양 끝은 **그대로 둔다** — 사용자가 잡아 둔 전체 폭이
24
+ * 안 변해야 결과가 예측된다.
25
+ *
26
+ * 셋 미만이거나 전체 폭이 없으면 null(버튼을 비활성).
27
+ */
28
+ export function distributeCoords(
29
+ items: ReadonlyArray<DistributeItem>,
30
+ ): Map<string, number> | null {
31
+ if (items.length < 3) return null;
32
+ const sorted = [...items].sort((a, b) => a.start - b.start);
33
+ const first = sorted[0];
34
+ const last = sorted[sorted.length - 1];
35
+ const span = last.start + last.size - first.start;
36
+ if (!Number.isFinite(span) || span <= 0) return null;
37
+
38
+ const content = sorted.reduce((sum, it) => sum + it.size, 0);
39
+ // 겹쳐 있으면 음수가 나온다 — 그대로 쓴다. 고르게 겹치는 게 제멋대로 겹치는 것보다
40
+ // 낫고, 사용자가 폭을 넓히면 바로 풀린다.
41
+ const gap = (span - content) / (sorted.length - 1);
42
+
43
+ const out = new Map<string, number>();
44
+ let cursor = first.start;
45
+ for (const it of sorted) {
46
+ out.set(it.id, Math.round(cursor));
47
+ cursor += it.size + gap;
48
+ }
49
+ // 반올림이 쌓여 마지막이 밀리지 않게 끝은 못 박는다.
50
+ out.set(last.id, Math.round(last.start));
51
+ return out;
52
+ }
53
+
54
+ export type DistributeElement = {
55
+ id: string;
56
+ x?: number;
57
+ y?: number;
58
+ width?: number;
59
+ height?: number;
60
+ parent?: unknown;
61
+ };
62
+
63
+ /**
64
+ * 배분할 수 있는 선택인가.
65
+ *
66
+ * 셋 이상이면서 **부모가 같아야** 한다. 그룹 자식과 최상위 요소가 섞이면 좌표계가
67
+ * 달라(그룹 자식의 x/y는 그룹 로컬 공간) 뒤섞인 결과가 나온다.
68
+ */
69
+ export function canDistribute(els: ReadonlyArray<DistributeElement>): boolean {
70
+ if (els.length < 3) return false;
71
+ const parent = els[0].parent;
72
+ return els.every((el) => el.parent === parent);
73
+ }
74
+
75
+ /** 축 좌표를 뽑아 배분 항목으로. 숫자가 아닌 값이 하나라도 있으면 null. */
76
+ export function toItems(
77
+ els: ReadonlyArray<DistributeElement>,
78
+ axis: DistributeAxis,
79
+ ): DistributeItem[] | null {
80
+ const out: DistributeItem[] = [];
81
+ for (const el of els) {
82
+ const start = axis === "x" ? el.x : el.y;
83
+ const size = axis === "x" ? el.width : el.height;
84
+ if (typeof start !== "number" || typeof size !== "number") return null;
85
+ if (!Number.isFinite(start) || !Number.isFinite(size)) return null;
86
+ out.push({ id: el.id, start, size });
87
+ }
88
+ return out;
89
+ }
@@ -0,0 +1,10 @@
1
+ const PRODUCTION_EDITOR_PATH =
2
+ "/branding/detail-page-generator/editor";
3
+
4
+ /** Routes that render the shared full-screen detail-page editor chrome. */
5
+ export function isDetailPageEditorPath(pathname: string): boolean {
6
+ return (
7
+ pathname.startsWith(PRODUCTION_EDITOR_PATH) ||
8
+ pathname.startsWith("/dev-canvas")
9
+ );
10
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * 요소 서랍의 "최근 사용 / 즐겨찾기".
3
+ *
4
+ * 상세페이지는 같은 배지·구분선·체크 아이콘을 20섹션 내내 반복해서 넣는다. 그때마다 다시
5
+ * 검색하는 것이 지금의 값이라 **검색의 절반은 "다시 안 찾기"** 다.
6
+ *
7
+ * ## 왜 마크업을 통째로 들고 있나
8
+ *
9
+ * 식별자(`"tabler:truck"`)만 저장하면 다시 넣을 때 제공처를 또 두드려야 하고, 세트에서
10
+ * 아이콘이 사라지면 최근 목록이 죽는다. 마크업은 아이콘 하나에 수백 바이트라 통째로
11
+ * 들고 있는 편이 싸고 확실하다.
12
+ *
13
+ * ## 왜 서버가 아니라 localStorage 인가
14
+ *
15
+ * 브랜드가 아니라 **사람의 손버릇**이다. 같은 브랜드를 여럿이 만져도 각자 최근이 다르고,
16
+ * 이걸 서버에 두면 테이블·RLS·동기화가 붙는데 값에 비해 비싸다. 오래 두고 팀이 공유할
17
+ * 자산은 이미 "내 도형"이 맡는다.
18
+ */
19
+
20
+ const STORAGE_KEY = "leviosa.detail-page.element-recents";
21
+
22
+ /** 최근은 짧게. 길면 스트립이 스크롤이 되고 스트립인 이유가 사라진다. */
23
+ const RECENT_MAX = 24;
24
+ /** 즐겨찾기는 사람이 직접 고른 것이라 넉넉히. */
25
+ const PINNED_MAX = 48;
26
+ /** localStorage 한도(보통 5MB)를 혼자 먹지 않게. 넘치면 오래된 최근부터 버린다. */
27
+ const BYTE_BUDGET = 512 * 1024;
28
+
29
+ export type ElementRecent = {
30
+ /** 같은 것인지 판정하는 키. 아이콘은 `"tabler:truck"`, 그 밖은 용도별 접두사를 붙인다. */
31
+ key: string;
32
+ markup: string;
33
+ viewBox: string;
34
+ /** 툴팁에 쓰는 사람 말. 없으면 키를 쓴다. */
35
+ label?: string;
36
+ };
37
+
38
+ export type ElementRecentsState = {
39
+ recent: ElementRecent[];
40
+ pinned: ElementRecent[];
41
+ };
42
+
43
+ const EMPTY: ElementRecentsState = { recent: [], pinned: [] };
44
+
45
+ let cache: ElementRecentsState | null = null;
46
+ const listeners = new Set<() => void>();
47
+
48
+ function isEntry(value: unknown): value is ElementRecent {
49
+ if (!value || typeof value !== "object") return false;
50
+ const entry = value as Record<string, unknown>;
51
+ return (
52
+ typeof entry.key === "string" &&
53
+ entry.key.length > 0 &&
54
+ typeof entry.markup === "string" &&
55
+ entry.markup.length > 0 &&
56
+ typeof entry.viewBox === "string"
57
+ );
58
+ }
59
+
60
+ function sanitize(list: unknown, max: number): ElementRecent[] {
61
+ if (!Array.isArray(list)) return [];
62
+ const out: ElementRecent[] = [];
63
+ const seen = new Set<string>();
64
+ for (const item of list) {
65
+ if (!isEntry(item) || seen.has(item.key)) continue;
66
+ seen.add(item.key);
67
+ out.push({
68
+ key: item.key,
69
+ markup: item.markup,
70
+ viewBox: item.viewBox,
71
+ ...(typeof item.label === "string" ? { label: item.label } : {}),
72
+ });
73
+ if (out.length >= max) break;
74
+ }
75
+ return out;
76
+ }
77
+
78
+ function read(): ElementRecentsState {
79
+ if (cache) return cache;
80
+ if (typeof window === "undefined") return EMPTY;
81
+ try {
82
+ const raw = window.localStorage.getItem(STORAGE_KEY);
83
+ if (!raw) {
84
+ cache = EMPTY;
85
+ return cache;
86
+ }
87
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
88
+ cache = {
89
+ recent: sanitize(parsed.recent, RECENT_MAX),
90
+ pinned: sanitize(parsed.pinned, PINNED_MAX),
91
+ };
92
+ } catch {
93
+ // 남이 망가뜨린 값에 편집기가 멈추면 안 된다.
94
+ cache = EMPTY;
95
+ }
96
+ return cache;
97
+ }
98
+
99
+ /**
100
+ * 예산을 넘으면 **최근 목록의 꼬리부터** 버린다. 즐겨찾기는 사람이 고른 것이라 마지막까지
101
+ * 지킨다. 그래도 안 되면 저장을 포기한다 — 최근 목록 때문에 편집이 막히면 안 된다.
102
+ */
103
+ function write(next: ElementRecentsState): void {
104
+ if (typeof window === "undefined") return;
105
+ const state: ElementRecentsState = {
106
+ recent: next.recent.slice(0, RECENT_MAX),
107
+ pinned: next.pinned.slice(0, PINNED_MAX),
108
+ };
109
+ for (;;) {
110
+ const payload = JSON.stringify(state);
111
+ if (payload.length <= BYTE_BUDGET) {
112
+ try {
113
+ window.localStorage.setItem(STORAGE_KEY, payload);
114
+ } catch {
115
+ // 용량 초과·프라이빗 모드. 이번 세션 동안 메모리로만 산다.
116
+ }
117
+ break;
118
+ }
119
+ if (!state.recent.length) break;
120
+ state.recent = state.recent.slice(0, -1);
121
+ }
122
+ cache = state;
123
+ for (const listener of listeners) listener();
124
+ }
125
+
126
+ export function subscribeElementRecents(listener: () => void): () => void {
127
+ listeners.add(listener);
128
+ return () => listeners.delete(listener);
129
+ }
130
+
131
+ export function getElementRecents(): ElementRecentsState {
132
+ return read();
133
+ }
134
+
135
+ /** 넣은 것을 최근 맨 앞으로. 이미 있으면 앞으로 끌어올린다(LRU). */
136
+ export function rememberElement(entry: ElementRecent): void {
137
+ const state = read();
138
+ write({
139
+ pinned: state.pinned,
140
+ recent: [entry, ...state.recent.filter((item) => item.key !== entry.key)],
141
+ });
142
+ }
143
+
144
+ /** 즐겨찾기 토글. 새로 꽂으면 맨 앞에 온다. */
145
+ export function toggleElementPin(entry: ElementRecent): void {
146
+ const state = read();
147
+ const has = state.pinned.some((item) => item.key === entry.key);
148
+ write({
149
+ recent: state.recent,
150
+ pinned: has
151
+ ? state.pinned.filter((item) => item.key !== entry.key)
152
+ : [entry, ...state.pinned],
153
+ });
154
+ }
155
+
156
+ export function isElementPinned(key: string): boolean {
157
+ return read().pinned.some((item) => item.key === key);
158
+ }
159
+
160
+ export function clearElementRecents(): void {
161
+ write({ recent: [], pinned: read().pinned });
162
+ }
163
+
164
+ /** 테스트용 — 모듈 캐시를 비운다. */
165
+ export function resetElementRecentsCache(): void {
166
+ cache = null;
167
+ }