@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,524 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import { observer } from "./canvas-observer";
5
+ import { useTranslation } from "react-i18next";
6
+ import { useDetailPageHost } from "./detail-page-host-context";
7
+ import { Download, ImageIcon, Loader2, X } from "lucide-react";
8
+
9
+ import { Button } from "../ui/button";
10
+ import { Label } from "../ui/label";
11
+ import { Slider } from "../ui/slider";
12
+ import { Switch } from "../ui/switch";
13
+ import {
14
+ Select,
15
+ SelectContent,
16
+ SelectItem,
17
+ SelectTrigger,
18
+ SelectValue,
19
+ } from "../ui/select";
20
+ import type { ExportDocument } from "../../lib/detail-page-canvas/export/document-model";
21
+ import { detectGifPages } from "../../lib/detail-page-canvas/export/gif-plan";
22
+ import { isMp4EncodeSupported } from "../../lib/detail-page-canvas/export/mp4-support";
23
+
24
+ /**
25
+ * hookable-style 다운로드 팝오버 — 다운로드 버튼 바로 아래에 떠서 파일 형식 / 등록
26
+ * 플랫폼 / 페이지 범위 / 한 장 병합 / 출력 해상도를 고른 뒤 클라이언트에서 직접
27
+ * 파일을 내보낸다.
28
+ *
29
+ * PNG/JPG는 Canvas 공식 ``store.toDataURL({ pageId, pixelRatio })``를 페이지마다
30
+ * 호출한다. 이 호출은 대상 페이지를 잠깐 강제 마운트하므로(스택 워크스페이스의
31
+ * ``_exporting`` 분기가 이를 받아준다) 화면 밖 페이지도 풀 해상도로 잡힌다.
32
+ *
33
+ * PSD(포토샵 레이어 파일)와 SVG(피그마 호환)는 ``store.toJSON()`` 문서를
34
+ * ``lib/detail-page-canvas/export``의 빌더로 직접 직렬화한다. ag-psd가 메인
35
+ * 번들에 들어오지 않도록 해당 모듈은 내보내기 시점에 dynamic import 한다.
36
+ */
37
+
38
+ type ExportPageLike = {
39
+ id: string;
40
+ computedWidth: number;
41
+ computedHeight: number;
42
+ };
43
+
44
+ type ExportStoreLike = {
45
+ pages: ExportPageLike[];
46
+ activePage?: ExportPageLike;
47
+ width: number;
48
+ toDataURL: (opts: {
49
+ pageId?: string;
50
+ pixelRatio?: number;
51
+ mimeType?: string;
52
+ }) => Promise<string>;
53
+ toJSON: () => Record<string, unknown>;
54
+ };
55
+
56
+ // 등록 플랫폼은 파일명 접미사에만 반영된다(상세페이지 폭은 750px 고정). 셀러가
57
+ // 어디에 올릴지 고르며 맥락을 잡도록 노출한다.
58
+ const PLATFORMS = [
59
+ { value: "naver", label: "네이버 스마트 스토어" },
60
+ { value: "coupang", label: "쿠팡" },
61
+ { value: "cafe24", label: "카페24" },
62
+ { value: "general", label: "일반(범용)" },
63
+ ] as const;
64
+
65
+ type Format = "png" | "jpeg" | "psd" | "svg" | "ai";
66
+ type Scope = "all" | "current";
67
+ type AnimationFormat = "webp" | "gif" | "mp4";
68
+
69
+ // 움직이는 섹션의 저장 형식. 기본이 WebP 인 이유는 화질·용량만이 아니다 — 쿠팡은
70
+ // 상세설명에 GIF 를 아예 받지 않고 WebP 만 받는다. GIF 는 카페24 와 메일처럼 WebP 를
71
+ // 못 받는 자리를 위해 남겨 둔다. MP4 는 네이버처럼 영상으로 붙이는 자리를 위한 것이고,
72
+ // 브라우저가 H.264 를 굽지 못하면 목록에서 빠진다.
73
+ const ANIMATION_FORMATS = [
74
+ { value: "webp", labelKey: "editor.animationWebp", hintKey: "editor.animationWebpNote" },
75
+ { value: "gif", labelKey: "editor.animationGif", hintKey: "editor.animationGifNote" },
76
+ { value: "mp4", labelKey: "editor.animationMp4", hintKey: "editor.animationMp4Note" },
77
+ ] as const;
78
+
79
+ /** 픽셀 형식만 병합 캔버스/해상도 슬라이더가 의미를 갖는다. */
80
+ const isRasterFormat = (f: Format) => f === "png" || f === "jpeg";
81
+
82
+ /** 일러스트레이터 아트보드 한계. 넘기면 파일을 열지 못한다. */
83
+ const AI_MAX_ARTBOARD = 16383;
84
+
85
+ function loadImage(src: string): Promise<HTMLImageElement> {
86
+ return new Promise((resolve, reject) => {
87
+ const img = new Image();
88
+ img.onload = () => resolve(img);
89
+ img.onerror = () => reject(new Error("image decode failed"));
90
+ img.src = src;
91
+ });
92
+ }
93
+
94
+ function triggerDownload(dataUrl: string, fileName: string) {
95
+ const link = document.createElement("a");
96
+ link.href = dataUrl;
97
+ link.download = fileName;
98
+ document.body.appendChild(link);
99
+ link.click();
100
+ link.remove();
101
+ }
102
+
103
+ function triggerBlobDownload(blob: Blob, fileName: string) {
104
+ const url = URL.createObjectURL(blob);
105
+ triggerDownload(url, fileName);
106
+ URL.revokeObjectURL(url);
107
+ }
108
+
109
+ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
110
+
111
+ function isFontEmbeddingFailure(
112
+ error: unknown,
113
+ ): error is { code: "FONT_EMBEDDING_FAILED"; family: string } {
114
+ return (
115
+ typeof error === "object" &&
116
+ error !== null &&
117
+ "code" in error &&
118
+ error.code === "FONT_EMBEDDING_FAILED" &&
119
+ "family" in error &&
120
+ typeof error.family === "string"
121
+ );
122
+ }
123
+
124
+ export const DetailPageDownloadDialog = observer(function DetailPageDownloadDialog({
125
+ store,
126
+ fileName = "detail-page",
127
+ slotBindings,
128
+ }: {
129
+ store: unknown;
130
+ fileName?: string;
131
+ /** PSD 레이어 이름에 쓰는 artifact slot_bindings (없어도 동작). */
132
+ slotBindings?: Record<string, { element_id: string }>;
133
+ }) {
134
+ const { t } = useTranslation("branding");
135
+ const host = useDetailPageHost();
136
+ const s = store as ExportStoreLike;
137
+ const [open, setOpen] = useState(false);
138
+ const [format, setFormat] = useState<Format>("png");
139
+ const [platform, setPlatform] = useState<string>("naver");
140
+ const [scope, setScope] = useState<Scope>("all");
141
+ const [single, setSingle] = useState(true);
142
+ const [resolution, setResolution] = useState(1);
143
+ const [animationFormat, setAnimationFormat] = useState<AnimationFormat>("webp");
144
+ // 움직이는 섹션이 하나라도 있을 때만 형식 선택이 의미가 있다. toJSON 은 문서
145
+ // 전체를 훑으므로 렌더마다 부르지 않고 팝오버를 열 때 한 번만 본다.
146
+ const [hasAnimation, setHasAnimation] = useState(false);
147
+ const [exporting, setExporting] = useState(false);
148
+ // Per-page export progress for the button label (raster path). null = no
149
+ // page-by-page phase running (idle, or PSD/SVG which export in one shot).
150
+ const [progress, setProgress] = useState<{ done: number; total: number } | null>(
151
+ null,
152
+ );
153
+ const [error, setError] = useState<string | null>(null);
154
+ const rootRef = useRef<HTMLDivElement>(null);
155
+
156
+ // 팝오버 바깥 클릭 / ESC로 닫기.
157
+ useEffect(() => {
158
+ if (!open) return;
159
+ const onPointer = (e: PointerEvent) => {
160
+ if (rootRef.current?.contains(e.target as Node)) return;
161
+ // Radix Select의 옵션 리스트는 body 포털에 뜨므로 rootRef 밖이다 — 셀렉트
162
+ // 조작이 팝오버 전체를 닫아버리지 않도록 리스트박스 내부 클릭은 무시한다.
163
+ const target = e.target instanceof Element ? e.target : null;
164
+ if (target?.closest('[role="listbox"], [role="option"]')) return;
165
+ setOpen(false);
166
+ };
167
+ const onKey = (e: KeyboardEvent) => {
168
+ if (e.key === "Escape") setOpen(false);
169
+ };
170
+ window.addEventListener("pointerdown", onPointer);
171
+ window.addEventListener("keydown", onKey);
172
+ return () => {
173
+ window.removeEventListener("pointerdown", onPointer);
174
+ window.removeEventListener("keydown", onKey);
175
+ };
176
+ }, [open]);
177
+
178
+ useEffect(() => {
179
+ if (!open) return;
180
+ try {
181
+ setHasAnimation(detectGifPages(s.toJSON() as ExportDocument).some(Boolean));
182
+ } catch {
183
+ setHasAnimation(false);
184
+ }
185
+ }, [open, s]);
186
+
187
+ // MP4 는 브라우저의 H.264 인코더로 굽는다. 없는 브라우저에서는 고를 수 있게 두면
188
+ // 내보내기를 눌러야 실패를 알게 되므로, 아예 목록에서 뺀다.
189
+ const animationFormats = useMemo(
190
+ () => ANIMATION_FORMATS.filter((f) => f.value !== "mp4" || isMp4EncodeSupported()),
191
+ [],
192
+ );
193
+
194
+ const selectedPages = useMemo(() => {
195
+ if (scope === "current" && s.activePage) return [s.activePage];
196
+ return s.pages;
197
+ }, [scope, s.activePage, s.pages]);
198
+
199
+ const docWidth = useMemo(
200
+ () => Math.max(1, s.width, ...s.pages.map((p) => p.computedWidth)),
201
+ [s.width, s.pages],
202
+ );
203
+ const totalHeight = useMemo(
204
+ () => selectedPages.reduce((acc, p) => acc + p.computedHeight, 0),
205
+ [selectedPages],
206
+ );
207
+ const outWidth = Math.round(docWidth * resolution);
208
+ const outHeight = Math.round(totalHeight * resolution);
209
+ // 상세페이지는 세로로 길어서, 병합하면 아트보드 한계를 넘길 수 있다.
210
+ const aiOverflow = format === "ai" && single && totalHeight > AI_MAX_ARTBOARD;
211
+
212
+ const handleExport = async () => {
213
+ if (exporting || selectedPages.length === 0) return;
214
+ setExporting(true);
215
+ setError(null);
216
+ const mime = format === "png" ? "image/png" : "image/jpeg";
217
+ const ext = format === "png" ? "png" : "jpg";
218
+ const base = `${fileName}-${platform}`;
219
+ try {
220
+ if (format === "psd" || format === "svg" || format === "ai") {
221
+ // 문서 JSON 기반 내보내기. ag-psd 포함 모듈은 이 시점에만 로드한다.
222
+ const exportLib = await import("../../lib/detail-page-canvas/export/export-files");
223
+ const doc = s.toJSON() as ExportDocument;
224
+ const pageIds = selectedPages.map((p) => p.id);
225
+ if (format === "psd") {
226
+ const blob = await exportLib.exportPsdBlob(doc, { slotBindings, pageIds });
227
+ exportLib.downloadBlob(blob, `${base}.psd`);
228
+ } else if (format === "ai") {
229
+ // 아트보드 한계를 넘으면 일러스트레이터가 파일을 아예 열지 못하므로,
230
+ // 병합 대신 페이지별 아트보드로 내린다(무엇을 했는지는 아래에 표시된다).
231
+ const merged = single && !aiOverflow;
232
+ const blob = await exportLib.exportAiBlob(doc, { pageIds, merged });
233
+ exportLib.downloadBlob(blob, `${base}.ai`);
234
+ } else {
235
+ const blobs = await exportLib.exportSvgBlobs(doc, { pageIds, merged: single });
236
+ for (let i = 0; i < blobs.length; i++) {
237
+ const suffix = blobs.length > 1 ? `-${String(i + 1).padStart(2, "0")}` : "";
238
+ exportLib.downloadBlob(blobs[i], `${base}${suffix}.svg`);
239
+ if (blobs.length > 1) await delay(180);
240
+ }
241
+ }
242
+ setOpen(false);
243
+ return;
244
+ }
245
+
246
+ // GIF sections can't be flattened into a still. If any selected section
247
+ // holds a GIF, export a ZIP: contiguous stills stack into PNGs and each
248
+ // GIF section is encoded as its own animated .gif. (Vector/PSD formats
249
+ // took the early return above and keep a first-frame still.)
250
+ const pageIds = selectedPages.map((p) => p.id);
251
+ const gifFlags = detectGifPages(s.toJSON() as ExportDocument, pageIds);
252
+ if (gifFlags.some(Boolean)) {
253
+ setProgress({ done: 0, total: gifFlags.length });
254
+ const gifLib = await import("../../lib/detail-page-canvas/export/gif-export");
255
+ const zip = await gifLib.exportGifZip(s, {
256
+ host,
257
+ pageIds,
258
+ gifFlags,
259
+ pixelRatio: resolution,
260
+ mimeType: mime,
261
+ ext,
262
+ animationFormat,
263
+ onProgress: (done, total) => setProgress({ done, total }),
264
+ });
265
+ triggerBlobDownload(zip, `${base}.zip`);
266
+ setOpen(false);
267
+ return;
268
+ }
269
+
270
+ const dataUrls: string[] = [];
271
+ // Each page is force-mounted and rasterized in turn, so this loop is the
272
+ // long pole — surface it as (done/total) on the button.
273
+ setProgress({ done: 0, total: selectedPages.length });
274
+ for (const page of selectedPages) {
275
+ dataUrls.push(
276
+ await s.toDataURL({ pageId: page.id, pixelRatio: resolution, mimeType: mime }),
277
+ );
278
+ setProgress((p) => (p ? { ...p, done: p.done + 1 } : p));
279
+ }
280
+
281
+ if (single && dataUrls.length > 1) {
282
+ const images = await Promise.all(dataUrls.map(loadImage));
283
+ const width = Math.max(...images.map((img) => img.width));
284
+ const height = images.reduce((acc, img) => acc + img.height, 0);
285
+ const canvas = document.createElement("canvas");
286
+ canvas.width = width;
287
+ canvas.height = height;
288
+ const ctx = canvas.getContext("2d");
289
+ if (!ctx) throw new Error("canvas 2d context unavailable");
290
+ // JPEG는 투명도가 없으므로 흰 배경을 깔아 검게 합성되는 것을 막는다.
291
+ if (format === "jpeg") {
292
+ ctx.fillStyle = "#ffffff";
293
+ ctx.fillRect(0, 0, width, height);
294
+ }
295
+ let y = 0;
296
+ for (const img of images) {
297
+ ctx.drawImage(img, 0, y);
298
+ y += img.height;
299
+ }
300
+ triggerDownload(canvas.toDataURL(mime, 0.95), `${base}.${ext}`);
301
+ } else if (dataUrls.length === 1) {
302
+ triggerDownload(dataUrls[0], `${base}.${ext}`);
303
+ } else {
304
+ // 페이지별 개별 파일. 브라우저가 연속 다운로드를 막지 않도록 짧게 끊어준다.
305
+ for (let i = 0; i < dataUrls.length; i++) {
306
+ triggerDownload(dataUrls[i], `${base}-${String(i + 1).padStart(2, "0")}.${ext}`);
307
+ await delay(180);
308
+ }
309
+ }
310
+ setOpen(false);
311
+ } catch (err) {
312
+ setError(
313
+ isFontEmbeddingFailure(err)
314
+ ? t("editor.fontEmbeddingFailed", { font: err.family })
315
+ : err instanceof Error
316
+ ? err.message
317
+ : String(err),
318
+ );
319
+ } finally {
320
+ setExporting(false);
321
+ setProgress(null);
322
+ }
323
+ };
324
+
325
+ return (
326
+ <div ref={rootRef} className="relative">
327
+ <button
328
+ type="button"
329
+ onClick={() => setOpen((v) => !v)}
330
+ aria-haspopup="dialog"
331
+ aria-expanded={open}
332
+ className="inline-flex h-9 items-center gap-2 rounded-md bg-neutral-900 px-3 text-sm font-semibold text-white transition-colors hover:bg-neutral-800"
333
+ >
334
+ <Download aria-hidden="true" size={16} />
335
+ {t("editor.download")}
336
+ </button>
337
+
338
+ {open ? (
339
+ <div
340
+ role="dialog"
341
+ aria-label={t("editor.downloadTitle")}
342
+ className="absolute right-0 top-[calc(100%+8px)] z-50 w-80 rounded-xl border border-neutral-200 bg-white p-4 shadow-[0_12px_40px_rgba(0,0,0,0.16)]"
343
+ >
344
+ <div className="mb-3 flex items-center justify-between">
345
+ <h3 className="text-base font-semibold text-neutral-950">
346
+ {t("editor.downloadTitle")}
347
+ </h3>
348
+ <button
349
+ type="button"
350
+ onClick={() => setOpen(false)}
351
+ aria-label={t("editor.close")}
352
+ className="flex h-7 w-7 items-center justify-center rounded-md text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700"
353
+ >
354
+ <X size={16} />
355
+ </button>
356
+ </div>
357
+
358
+ <div className="space-y-3">
359
+ <div className="space-y-1.5">
360
+ <Label className="text-xs text-neutral-500">{t("editor.fileFormat")}</Label>
361
+ <Select value={format} onValueChange={(v) => setFormat(v as Format)}>
362
+ <SelectTrigger className="w-full">
363
+ <span className="inline-flex items-center gap-2">
364
+ <ImageIcon size={15} className="text-neutral-500" />
365
+ <SelectValue />
366
+ </span>
367
+ </SelectTrigger>
368
+ <SelectContent>
369
+ <SelectItem value="png">PNG</SelectItem>
370
+ <SelectItem value="jpeg">JPG</SelectItem>
371
+ <SelectItem value="psd">{t("editor.formatPsd")}</SelectItem>
372
+ <SelectItem value="ai">{t("editor.formatAi")}</SelectItem>
373
+ <SelectItem value="svg">{t("editor.formatSvg")}</SelectItem>
374
+ </SelectContent>
375
+ </Select>
376
+ </div>
377
+
378
+ <div className="space-y-1.5">
379
+ <Label className="text-xs text-neutral-500">
380
+ {t("editor.registerPlatform")}
381
+ </Label>
382
+ <Select value={platform} onValueChange={setPlatform}>
383
+ <SelectTrigger className="w-full">
384
+ <SelectValue />
385
+ </SelectTrigger>
386
+ <SelectContent>
387
+ {PLATFORMS.map((p) => (
388
+ <SelectItem key={p.value} value={p.value}>
389
+ {p.label}
390
+ </SelectItem>
391
+ ))}
392
+ </SelectContent>
393
+ </Select>
394
+ </div>
395
+
396
+ <div className="space-y-1.5">
397
+ <Label className="text-xs text-neutral-500">{t("editor.pageScope")}</Label>
398
+ <Select value={scope} onValueChange={(v) => setScope(v as Scope)}>
399
+ <SelectTrigger className="w-full">
400
+ <SelectValue />
401
+ </SelectTrigger>
402
+ <SelectContent>
403
+ <SelectItem value="all">{t("editor.pageScopeAll")}</SelectItem>
404
+ <SelectItem value="current">{t("editor.pageScopeCurrent")}</SelectItem>
405
+ </SelectContent>
406
+ </Select>
407
+ </div>
408
+
409
+ {/* PSD는 페이지가 레이어 그룹으로 항상 한 파일이고, 해상도는 문서
410
+ 픽셀(1×) 고정. SVG/AI는 벡터라 해상도 개념이 없고 병합 여부만 고른다
411
+ (AI는 항상 한 파일이고, 스위치는 아트보드를 합칠지를 고른다). */}
412
+ {format !== "psd" ? (
413
+ <label className="flex cursor-pointer items-center gap-2.5 py-0.5">
414
+ <Switch checked={single} onCheckedChange={setSingle} />
415
+ <span className="text-sm text-neutral-700">
416
+ {format === "ai"
417
+ ? t("editor.mergeArtboard")
418
+ : format === "svg"
419
+ ? t("editor.mergeSingleFile")
420
+ : t("editor.mergeSingle")}
421
+ </span>
422
+ </label>
423
+ ) : null}
424
+
425
+ {isRasterFormat(format) ? (
426
+ <div className="space-y-1.5">
427
+ <div className="flex items-center justify-between">
428
+ <Label className="text-xs text-neutral-500">{t("editor.resolution")}</Label>
429
+ <span className="text-xs font-semibold text-neutral-700">{resolution}×</span>
430
+ </div>
431
+ <Slider
432
+ min={1}
433
+ max={4}
434
+ step={1}
435
+ value={[resolution]}
436
+ onValueChange={(v) => setResolution(v[0] ?? 1)}
437
+ />
438
+ <div className="flex justify-between text-[10px] text-neutral-400">
439
+ <span>1×</span>
440
+ <span>4×</span>
441
+ </div>
442
+ </div>
443
+ ) : null}
444
+
445
+ {/* 움직이는 섹션은 정지 이미지로 눌러 담을 수 없어 ZIP 안에 따로 담긴다.
446
+ 벡터·PSD는 위에서 일찍 빠져나가 첫 프레임 정지본만 남으므로, 이
447
+ 선택은 픽셀 형식일 때만 의미가 있다. */}
448
+ {hasAnimation && isRasterFormat(format) ? (
449
+ <div className="space-y-1.5">
450
+ <Label className="text-xs text-neutral-500">
451
+ {t("editor.animationFormat")}
452
+ </Label>
453
+ <Select
454
+ value={animationFormat}
455
+ onValueChange={(v) => setAnimationFormat(v as AnimationFormat)}
456
+ >
457
+ <SelectTrigger className="w-full">
458
+ <SelectValue />
459
+ </SelectTrigger>
460
+ <SelectContent>
461
+ {animationFormats.map((f) => (
462
+ <SelectItem key={f.value} value={f.value}>
463
+ {t(f.labelKey)}
464
+ </SelectItem>
465
+ ))}
466
+ </SelectContent>
467
+ </Select>
468
+ <p className="text-[10px] leading-relaxed text-neutral-400">
469
+ {t(
470
+ ANIMATION_FORMATS.find((f) => f.value === animationFormat)?.hintKey ??
471
+ "",
472
+ )}
473
+ </p>
474
+ </div>
475
+ ) : null}
476
+
477
+ <div className="rounded-lg bg-neutral-50 p-3 text-xs text-neutral-600">
478
+ <p className="mb-1 font-semibold text-neutral-700">{t("editor.downloadInfo")}</p>
479
+ <ul className="space-y-0.5">
480
+ <li>• {t("editor.pagesCount", { count: selectedPages.length })}</li>
481
+ <li>
482
+ •{" "}
483
+ {format === "svg" || format === "ai"
484
+ ? t("editor.vectorOutput")
485
+ : `${(isRasterFormat(format) ? outWidth : docWidth).toLocaleString()} × ${(isRasterFormat(format) ? outHeight : totalHeight).toLocaleString()} px`}
486
+ </li>
487
+ {format === "psd" ? <li>• {t("editor.psdNote")}</li> : null}
488
+ {format === "svg" ? <li>• {t("editor.svgNote")}</li> : null}
489
+ {format === "ai" ? <li>• {t("editor.aiNote")}</li> : null}
490
+ {aiOverflow ? (
491
+ <li className="text-amber-700">
492
+ • {t("editor.aiOverflowNote", { limit: AI_MAX_ARTBOARD.toLocaleString() })}
493
+ </li>
494
+ ) : null}
495
+ </ul>
496
+ </div>
497
+
498
+ {error ? (
499
+ <p className="text-xs font-medium text-red-600">{error}</p>
500
+ ) : null}
501
+
502
+ <Button
503
+ type="button"
504
+ onClick={handleExport}
505
+ disabled={exporting || selectedPages.length === 0}
506
+ className="h-11 w-full bg-neutral-900 text-sm font-semibold hover:bg-neutral-800"
507
+ >
508
+ {exporting ? (
509
+ <>
510
+ <Loader2 className="animate-spin" />
511
+ {t("editor.exporting")}
512
+ {progress ? ` (${progress.done}/${progress.total})` : null}
513
+ </>
514
+ ) : (
515
+ t("editor.downloadAction")
516
+ )}
517
+ </Button>
518
+ </div>
519
+ </div>
520
+ ) : null}
521
+ </div>
522
+ );
523
+ });
524
+ DetailPageDownloadDialog.displayName = "DetailPageDownloadDialog";