@leviosa-ai/detail-page-editor 0.15.0 → 0.16.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.
@@ -21,15 +21,30 @@ import type { ExportDocument } from "../../lib/detail-page-canvas/export/documen
21
21
  import { detectGifPages } from "../../lib/detail-page-canvas/export/gif-plan";
22
22
  import { activeFramePages } from "../../lib/detail-page/frame-pages";
23
23
  import { isMp4EncodeSupported } from "../../lib/detail-page-canvas/export/mp4-support";
24
+ import {
25
+ dataUrlBytes,
26
+ fitSteps,
27
+ fitToBudget,
28
+ pngFallbackSteps,
29
+ type FitStep,
30
+ } from "../../lib/detail-page-canvas/export/fit-budget";
31
+ import {
32
+ EXPORT_PLATFORMS,
33
+ exportPlatform,
34
+ platformPixelRatio,
35
+ type AnimationFormat,
36
+ } from "../../lib/detail-page/export-platforms";
24
37
  import {
25
38
  detailPageEditorProfile,
26
39
  type DetailPageEditorFormat,
27
40
  } from "../../lib/detail-page/editor-profile";
28
41
 
29
42
  /**
30
- * hookable-style 다운로드 팝오버 — 다운로드 버튼 바로 아래에 떠서 파일 형식 / 등록
31
- * 플랫폼 / 페이지 범위 / 한 장 병합 / 출력 해상도를 고른 뒤 클라이언트에서 직접
32
- * 파일을 내보낸다.
43
+ * hookable-style 다운로드 팝오버 — 다운로드 버튼 바로 아래에 떠서 **등록 플랫폼을
44
+ * 먼저** 고른 뒤 파일 형식 / 페이지 범위 / 한 장 병합 / 출력 해상도를 고르고
45
+ * 클라이언트에서 직접 파일을 내보낸다. 플랫폼이 출력 폭·움직이는 섹션 형식·파일
46
+ * 용량 상한을 정한다(``lib/detail-page/export-platforms.ts``); 상한을 넘는 파일은
47
+ * 화질·크기를 내려 다시 굽는다(``export/fit-budget.ts``).
33
48
  *
34
49
  * PNG/JPG는 Canvas 공식 ``store.toDataURL({ pageId, pixelRatio })``를 페이지마다
35
50
  * 호출한다. 이 호출은 대상 페이지를 잠깐 강제 마운트하므로(스택 워크스페이스의
@@ -56,32 +71,34 @@ type ExportStoreLike = {
56
71
  pageId?: string;
57
72
  pixelRatio?: number;
58
73
  mimeType?: string;
74
+ quality?: number;
59
75
  }) => Promise<string>;
60
76
  toJSON: () => Record<string, unknown>;
61
77
  };
62
78
 
63
- // 등록 플랫폼은 파일명 접미사에만 반영된다(상세페이지 폭은 750px 고정). 셀러가
64
- // 어디에 올릴지 고르며 맥락을 잡도록 노출한다. 캐러셀은 인스타그램 한 곳으로만
65
- // 나가므로 이 목록을 아예 안 띄운다 — `profile.registerPlatform` 이 그것을 정한다.
66
- const PLATFORMS = [
67
- { value: "naver", label: "네이버 스마트 스토어" },
68
- { value: "coupang", label: "쿠팡" },
69
- { value: "cafe24", label: "카페24" },
70
- { value: "general", label: "일반(범용)" },
71
- ] as const;
72
-
73
79
  type Format = DetailPageEditorFormat;
74
80
  type Scope = "all" | "current";
75
- type AnimationFormat = "webp" | "gif" | "mp4";
76
81
 
77
- // 움직이는 섹션의 저장 형식. 기본이 WebP 인 이유는 화질·용량만이 아니다 — 쿠팡은
78
- // 상세설명에 GIF 를 아예 받지 않고 WebP 만 받는다. GIF 는 카페24 와 메일처럼 WebP 를
79
- // 못 받는 자리를 위해 남겨 둔다. MP4 는 네이버처럼 영상으로 붙이는 자리를 위한 것이고,
80
- // 브라우저가 H.264 를 굽지 못하면 목록에서 빠진다.
82
+ // 움직이는 섹션의 저장 형식 문구. **어느 형식을 보여 줄지는 플랫폼이 정한다**
83
+ // (`export-platforms.ts`) — 쿠팡은 GIF 를 안 받고 WebP 만, 네이버는 반대로 WebP 를
84
+ // 안 받는다. 여기는 라벨과 설명만 있다. MP4 는 브라우저가 H.264 를 굽지 못하면
85
+ // 플랫폼이 허용해도 목록에서 빠진다.
81
86
  const ANIMATION_FORMATS = [
82
- { value: "webp", labelKey: "editor.animationWebp", hintKey: "editor.animationWebpNote" },
83
- { value: "gif", labelKey: "editor.animationGif", hintKey: "editor.animationGifNote" },
84
- { value: "mp4", labelKey: "editor.animationMp4", hintKey: "editor.animationMp4Note" },
87
+ {
88
+ value: "webp",
89
+ labelKey: "editor.animationWebp",
90
+ hintKey: "editor.animationWebpNote",
91
+ },
92
+ {
93
+ value: "gif",
94
+ labelKey: "editor.animationGif",
95
+ hintKey: "editor.animationGifNote",
96
+ },
97
+ {
98
+ value: "mp4",
99
+ labelKey: "editor.animationMp4",
100
+ hintKey: "editor.animationMp4Note",
101
+ },
85
102
  ] as const;
86
103
 
87
104
  /** 픽셀 형식만 병합 캔버스/해상도 슬라이더가 의미를 갖는다. */
@@ -116,6 +133,34 @@ function triggerBlobDownload(blob: Blob, fileName: string) {
116
133
 
117
134
  const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
118
135
 
136
+ /** 용량 상한을 사람이 읽는 MB 로. 정수면 소수점을 안 붙인다. */
137
+ function bytesToMb(bytes: number): string {
138
+ const mb = bytes / (1024 * 1024);
139
+ return Number.isInteger(mb) ? String(mb) : mb.toFixed(1);
140
+ }
141
+
142
+ /** 페이지 그림들을 세로로 쌓아 한 장으로. JPEG 는 투명한 자리가 검게 되지 않게 흰 바탕. */
143
+ async function stackDataUrls(urls: string[], mime: string, quality: number): Promise<string> {
144
+ const images = await Promise.all(urls.map(loadImage));
145
+ const width = Math.max(...images.map((img) => img.width));
146
+ const height = images.reduce((acc, img) => acc + img.height, 0);
147
+ const canvas = document.createElement("canvas");
148
+ canvas.width = width;
149
+ canvas.height = height;
150
+ const ctx = canvas.getContext("2d");
151
+ if (!ctx) throw new Error("canvas 2d context unavailable");
152
+ if (mime === "image/jpeg") {
153
+ ctx.fillStyle = "#ffffff";
154
+ ctx.fillRect(0, 0, width, height);
155
+ }
156
+ let y = 0;
157
+ for (const img of images) {
158
+ ctx.drawImage(img, 0, y);
159
+ y += img.height;
160
+ }
161
+ return canvas.toDataURL(mime, quality);
162
+ }
163
+
119
164
  function isFontEmbeddingFailure(
120
165
  error: unknown,
121
166
  ): error is { code: "FONT_EMBEDDING_FAILED"; family: string } {
@@ -145,7 +190,9 @@ export const DetailPageDownloadDialog = observer(function DetailPageDownloadDial
145
190
  const profile = detailPageEditorProfile();
146
191
  const [open, setOpen] = useState(false);
147
192
  const [format, setFormat] = useState<Format>(profile.exports[0]);
148
- const [platform, setPlatform] = useState<string>("naver");
193
+ // 플랫폼은 **먼저** 고른다. 고르기 전에는 나머지 선택지를 아예 안 보여 준다 —
194
+ // 폭과 움직이는 이미지 형식이 플랫폼에서 나오므로, 그 전에 고른 형식은 거짓말이다.
195
+ const [platform, setPlatform] = useState<string | null>(null);
149
196
  const [scope, setScope] = useState<Scope>("all");
150
197
  const [single, setSingle] = useState(true);
151
198
  const [resolution, setResolution] = useState(1);
@@ -156,12 +203,19 @@ export const DetailPageDownloadDialog = observer(function DetailPageDownloadDial
156
203
  const [exporting, setExporting] = useState(false);
157
204
  // Per-page export progress for the button label (raster path). null = no
158
205
  // page-by-page phase running (idle, or PSD/SVG which export in one shot).
159
- const [progress, setProgress] = useState<{ done: number; total: number } | null>(
160
- null,
161
- );
206
+ const [progress, setProgress] = useState<{
207
+ done: number;
208
+ total: number;
209
+ } | null>(null);
162
210
  const [error, setError] = useState<string | null>(null);
211
+ // 내려받긴 했지만 알려야 할 것 — 사다리 끝까지 줄여도 용량 상한을 넘은 파일.
212
+ const [notice, setNotice] = useState<string | null>(null);
163
213
  const rootRef = useRef<HTMLDivElement>(null);
164
214
 
215
+ // 캐러셀은 플랫폼을 안 물으므로 늘 준비된 상태다.
216
+ const chosen = profile.registerPlatform ? exportPlatform(platform) : null;
217
+ const ready = !profile.registerPlatform || chosen !== null;
218
+
165
219
  // 팝오버 바깥 클릭 / ESC로 닫기.
166
220
  useEffect(() => {
167
221
  if (!open) return;
@@ -193,12 +247,31 @@ export const DetailPageDownloadDialog = observer(function DetailPageDownloadDial
193
247
  }
194
248
  }, [open, s]);
195
249
 
196
- // MP4 는 브라우저의 H.264 인코더로 굽는다. 없는 브라우저에서는 고를 수 있게 두면
197
- // 내보내기를 눌러야 실패를 알게 되므로, 아예 목록에서 뺀다.
198
- const animationFormats = useMemo(
199
- () => ANIMATION_FORMATS.filter((f) => f.value !== "mp4" || isMp4EncodeSupported()),
200
- [],
201
- );
250
+ // 플랫폼이 받는 형식만, 그 순서대로. MP4 는 브라우저의 H.264 인코더로 굽는다 —
251
+ // 없는 브라우저에서는 고를 수 있게 두면 내보내기를 눌러야 실패를 알게 되므로,
252
+ // 플랫폼이 허용해도 목록에서 뺀다.
253
+ const animationFormats = useMemo(() => {
254
+ const allowed = chosen?.animation ?? ANIMATION_FORMATS.map((f) => f.value);
255
+ return allowed
256
+ .map((value) => ANIMATION_FORMATS.find((f) => f.value === value))
257
+ .filter((f): f is (typeof ANIMATION_FORMATS)[number] => Boolean(f))
258
+ .filter((f) => f.value !== "mp4" || isMp4EncodeSupported());
259
+ }, [chosen]);
260
+
261
+ // 플랫폼을 바꾸면 그 플랫폼의 기본 형식(첫 항목)으로 돌아간다. "이전 값이 새 목록에
262
+ // 있으면 둔다"로 하면 카페24 에서 WebP 를 골라 둔 채 범용으로 옮겼다가 돌아올 때
263
+ // 기본이 GIF 인데 WebP 가 남는다 — 규격표의 "첫 항목이 기본" 계약이 깨진다.
264
+ // `animationFormats` 는 플랫폼이 바뀔 때만 새로 만들어지므로 그 때만 돈다.
265
+ useEffect(() => {
266
+ const first = animationFormats[0]?.value;
267
+ if (first) setAnimationFormat(first);
268
+ }, [animationFormats]);
269
+
270
+ // 지난 내보내기의 안내(JPG 로 바꿨다, 상한을 넘었다)는 그 선택에 대한 말이다.
271
+ // 플랫폼·형식·범위를 바꾸면 더는 맞지 않으므로 지운다.
272
+ useEffect(() => {
273
+ setNotice(null);
274
+ }, [platform, format, scope, single, animationFormat]);
202
275
 
203
276
  const selectedPages = useMemo(() => {
204
277
  if (scope === "current" && s.activePage) return [s.activePage];
@@ -208,26 +281,55 @@ export const DetailPageDownloadDialog = observer(function DetailPageDownloadDial
208
281
  return activeFramePages(s.pages, s.activePage?.id);
209
282
  }, [scope, s.activePage, s.pages]);
210
283
 
284
+ // 배율의 분모는 **내보낼 페이지**의 폭이다. 문서 전체의 최대 폭을 쓰면 보고 있지
285
+ // 않은 다른 벌(프레임)에 1440 짜리 페이지가 있을 때 750 짜리 현재 벌이 860/1440
286
+ // 배로 줄어 나간다 — 창은 860px 이라고 말하면서.
211
287
  const docWidth = useMemo(
212
- () => Math.max(1, s.width, ...s.pages.map((p) => p.computedWidth)),
213
- [s.width, s.pages],
288
+ () =>
289
+ selectedPages.length > 0
290
+ ? Math.max(1, ...selectedPages.map((p) => p.computedWidth))
291
+ : Math.max(1, s.width),
292
+ [selectedPages, s.width],
214
293
  );
215
294
  const totalHeight = useMemo(
216
295
  () => selectedPages.reduce((acc, p) => acc + p.computedHeight, 0),
217
296
  [selectedPages],
218
297
  );
219
- const outWidth = Math.round(docWidth * resolution);
220
- const outHeight = Math.round(totalHeight * resolution);
298
+ // 플랫폼에 폭이 있으면 그 폭에 맞추는 배율, 없으면 슬라이더의 배율.
299
+ const ratio = platformPixelRatio(chosen, docWidth, resolution);
300
+ const outWidth = Math.round(docWidth * ratio);
301
+ const outHeight = Math.round(totalHeight * ratio);
302
+ const maxBytes = chosen?.maxBytes ?? null;
221
303
  // 상세페이지는 세로로 길어서, 병합하면 아트보드 한계를 넘길 수 있다.
222
304
  const aiOverflow = format === "ai" && single && totalHeight > AI_MAX_ARTBOARD;
223
305
 
306
+ // 내려받기가 끝난 뒤. 말할 것이 없으면 창을 닫고, 있으면 열어 둔 채 알린다 —
307
+ // 닫아 버리면 알릴 자리가 없다. 말할 것은 둘이다: PNG 로는 상한을 넘어 JPG 로
308
+ // 바꾼 파일, 끝까지 줄여도 상한을 넘은 파일.
309
+ const finish = (unfitted: string[], converted: string[]) => {
310
+ if (!chosen?.maxBytes || (unfitted.length === 0 && converted.length === 0)) {
311
+ setOpen(false);
312
+ return;
313
+ }
314
+ const size = bytesToMb(chosen.maxBytes);
315
+ const lines: string[] = [];
316
+ if (converted.length > 0) {
317
+ lines.push(t("editor.formatFallbackNote", { files: converted.join(", "), size }));
318
+ }
319
+ if (unfitted.length > 0) {
320
+ lines.push(t("editor.sizeUnfitNote", { files: unfitted.join(", "), size }));
321
+ }
322
+ setNotice(lines.join(" "));
323
+ };
324
+
224
325
  const handleExport = async () => {
225
- if (exporting || selectedPages.length === 0) return;
326
+ if (exporting || selectedPages.length === 0 || !ready) return;
226
327
  setExporting(true);
227
328
  setError(null);
329
+ setNotice(null);
228
330
  const mime = format === "png" ? "image/png" : "image/jpeg";
229
331
  const ext = format === "png" ? "png" : "jpg";
230
- const base = profile.registerPlatform ? `${fileName}-${platform}` : fileName;
332
+ const base = chosen ? `${fileName}-${chosen.value}` : fileName;
231
333
  try {
232
334
  if (format === "psd" || format === "svg" || format === "ai") {
233
335
  // 문서 JSON 기반 내보내기. ag-psd 포함 모듈은 이 시점에만 로드한다.
@@ -235,7 +337,10 @@ export const DetailPageDownloadDialog = observer(function DetailPageDownloadDial
235
337
  const doc = s.toJSON() as ExportDocument;
236
338
  const pageIds = selectedPages.map((p) => p.id);
237
339
  if (format === "psd") {
238
- const blob = await exportLib.exportPsdBlob(doc, { slotBindings, pageIds });
340
+ const blob = await exportLib.exportPsdBlob(doc, {
341
+ slotBindings,
342
+ pageIds,
343
+ });
239
344
  exportLib.downloadBlob(blob, `${base}.psd`);
240
345
  } else if (format === "ai") {
241
346
  // 아트보드 한계를 넘으면 일러스트레이터가 파일을 아예 열지 못하므로,
@@ -244,7 +349,10 @@ export const DetailPageDownloadDialog = observer(function DetailPageDownloadDial
244
349
  const blob = await exportLib.exportAiBlob(doc, { pageIds, merged });
245
350
  exportLib.downloadBlob(blob, `${base}.ai`);
246
351
  } else {
247
- const blobs = await exportLib.exportSvgBlobs(doc, { pageIds, merged: single });
352
+ const blobs = await exportLib.exportSvgBlobs(doc, {
353
+ pageIds,
354
+ merged: single,
355
+ });
248
356
  for (let i = 0; i < blobs.length; i++) {
249
357
  const suffix = blobs.length > 1 ? `-${String(i + 1).padStart(2, "0")}` : "";
250
358
  exportLib.downloadBlob(blobs[i], `${base}${suffix}.svg`);
@@ -264,62 +372,85 @@ export const DetailPageDownloadDialog = observer(function DetailPageDownloadDial
264
372
  if (gifFlags.some(Boolean)) {
265
373
  setProgress({ done: 0, total: gifFlags.length });
266
374
  const gifLib = await import("../../lib/detail-page-canvas/export/gif-export");
267
- const zip = await gifLib.exportGifZip(s, {
375
+ const { blob, unfitted, converted } = await gifLib.exportGifZip(s, {
268
376
  host,
269
377
  pageIds,
270
378
  gifFlags,
271
- pixelRatio: resolution,
379
+ pixelRatio: ratio,
272
380
  mimeType: mime,
273
381
  ext,
274
382
  animationFormat,
383
+ animationMaxWidth: chosen?.width ?? undefined,
384
+ maxBytes,
275
385
  onProgress: (done, total) => setProgress({ done, total }),
276
386
  });
277
- triggerBlobDownload(zip, `${base}.zip`);
278
- setOpen(false);
387
+ triggerBlobDownload(blob, `${base}.zip`);
388
+ finish(unfitted, converted);
279
389
  return;
280
390
  }
281
391
 
282
- const dataUrls: string[] = [];
283
- // Each page is force-mounted and rasterized in turn, so this loop is the
284
- // long pole — surface it as (done/total) on the button.
285
- setProgress({ done: 0, total: selectedPages.length });
286
- for (const page of selectedPages) {
287
- dataUrls.push(
288
- await s.toDataURL({ pageId: page.id, pixelRatio: resolution, mimeType: mime }),
289
- );
290
- setProgress((p) => (p ? { ...p, done: p.done + 1 } : p));
291
- }
392
+ // PNG 는 넘치면 JPG 로 떨어지는 사다리, JPG 는 화질 사다리. 한 단계의 형식은
393
+ // 그 단계가 정한다.
394
+ const steps = format === "jpeg" ? fitSteps(true) : pngFallbackSteps();
395
+ const mimeOf = (step: FitStep) => (step.lossy ? "image/jpeg" : "image/png");
396
+ const extOf = (step: FitStep) => (step.lossy ? "jpg" : "png");
397
+ // 한 페이지를 주어진 단계로 그린다. 용량 사다리는 이 함수를 단계를 낮춰 다시
398
+ // 부른다 — 비트맵을 줄이는 대신 다시 그려야 글자가 또렷하다.
399
+ const renderPage = (pageId: string, step: FitStep) =>
400
+ s.toDataURL({
401
+ pageId,
402
+ pixelRatio: ratio * step.scale,
403
+ mimeType: mimeOf(step),
404
+ quality: step.lossy ? step.quality : undefined,
405
+ });
292
406
 
293
- if (single && dataUrls.length > 1) {
294
- const images = await Promise.all(dataUrls.map(loadImage));
295
- const width = Math.max(...images.map((img) => img.width));
296
- const height = images.reduce((acc, img) => acc + img.height, 0);
297
- const canvas = document.createElement("canvas");
298
- canvas.width = width;
299
- canvas.height = height;
300
- const ctx = canvas.getContext("2d");
301
- if (!ctx) throw new Error("canvas 2d context unavailable");
302
- // JPEG는 투명도가 없으므로 흰 배경을 깔아 검게 합성되는 것을 막는다.
303
- if (format === "jpeg") {
304
- ctx.fillStyle = "#ffffff";
305
- ctx.fillRect(0, 0, width, height);
306
- }
307
- let y = 0;
308
- for (const img of images) {
309
- ctx.drawImage(img, 0, y);
310
- y += img.height;
311
- }
312
- triggerDownload(canvas.toDataURL(mime, 0.95), `${base}.${ext}`);
313
- } else if (dataUrls.length === 1) {
314
- triggerDownload(dataUrls[0], `${base}.${ext}`);
407
+ const unfitted: string[] = [];
408
+ const converted: string[] = [];
409
+ const record = (name: string, fit: { fitted: boolean; step: FitStep }) => {
410
+ if (!fit.fitted) unfitted.push(name);
411
+ if (extOf(fit.step) !== ext) converted.push(name);
412
+ };
413
+ if (single && selectedPages.length > 1) {
414
+ // 병합본은 쌓은 한 장이 파일이므로 쌓은 뒤의 크기로 잰다.
415
+ setProgress({ done: 0, total: selectedPages.length });
416
+ const fit = await fitToBudget(maxBytes, steps, async (step) => {
417
+ const urls: string[] = [];
418
+ for (const page of selectedPages) {
419
+ urls.push(await renderPage(page.id, step));
420
+ setProgress({ done: urls.length, total: selectedPages.length });
421
+ }
422
+ const url = await stackDataUrls(urls, mimeOf(step), step.quality);
423
+ return { value: url, bytes: dataUrlBytes(url) };
424
+ });
425
+ const name = `${base}.${extOf(fit.step)}`;
426
+ record(name, fit);
427
+ triggerDownload(fit.value, name);
315
428
  } else {
429
+ // Each page is force-mounted and rasterized in turn, so this loop is the
430
+ // long pole — surface it as (done/total) on the button.
431
+ setProgress({ done: 0, total: selectedPages.length });
432
+ const files: Array<{ url: string; name: string }> = [];
433
+ for (let i = 0; i < selectedPages.length; i++) {
434
+ const id = selectedPages[i].id;
435
+ // 파일 하나의 용량이 상한을 넘으면 사다리를 내려간다. `dataUrlBytes` 는 그
436
+ // 문자열이 파일로 떨어질 때의 크기다.
437
+ const fit = await fitToBudget(maxBytes, steps, async (step) => {
438
+ const url = await renderPage(id, step);
439
+ return { value: url, bytes: dataUrlBytes(url) };
440
+ });
441
+ const suffix = selectedPages.length > 1 ? `-${String(i + 1).padStart(2, "0")}` : "";
442
+ const name = `${base}${suffix}.${extOf(fit.step)}`;
443
+ record(name, fit);
444
+ files.push({ url: fit.value, name });
445
+ setProgress({ done: i + 1, total: selectedPages.length });
446
+ }
316
447
  // 페이지별 개별 파일. 브라우저가 연속 다운로드를 막지 않도록 짧게 끊어준다.
317
- for (let i = 0; i < dataUrls.length; i++) {
318
- triggerDownload(dataUrls[i], `${base}-${String(i + 1).padStart(2, "0")}.${ext}`);
319
- await delay(180);
448
+ for (let i = 0; i < files.length; i++) {
449
+ triggerDownload(files[i].url, files[i].name);
450
+ if (i < files.length - 1) await delay(180);
320
451
  }
321
452
  }
322
- setOpen(false);
453
+ finish(unfitted, converted);
323
454
  } catch (err) {
324
455
  setError(
325
456
  isFontEmbeddingFailure(err)
@@ -368,171 +499,210 @@ export const DetailPageDownloadDialog = observer(function DetailPageDownloadDial
368
499
  </div>
369
500
 
370
501
  <div className="space-y-3">
371
- <div className="space-y-1.5">
372
- <Label className="text-xs text-le-ink-500">{t("editor.fileFormat")}</Label>
373
- <Select value={format} onValueChange={(v) => setFormat(v as Format)}>
374
- <SelectTrigger className="w-full">
375
- <span className="inline-flex items-center gap-2">
376
- <ImageIcon size={15} className="text-le-ink-500" />
377
- <SelectValue />
378
- </span>
379
- </SelectTrigger>
380
- <SelectContent>
381
- {profile.exports.map((value) => (
382
- <SelectItem key={value} value={value}>
383
- {value === "png"
384
- ? "PNG"
385
- : value === "jpeg"
386
- ? "JPG"
387
- : t(`editor.format${value[0].toUpperCase()}${value.slice(1)}`)}
388
- </SelectItem>
389
- ))}
390
- </SelectContent>
391
- </Select>
392
- </div>
393
-
394
502
  {profile.registerPlatform && (
395
503
  <div className="space-y-1.5">
396
- <Label className="text-xs text-le-ink-500">
397
- {t("editor.registerPlatform")}
398
- </Label>
399
- <Select value={platform} onValueChange={setPlatform}>
504
+ <Label className="text-xs text-le-ink-500">{t("editor.registerPlatform")}</Label>
505
+ <Select value={platform ?? ""} onValueChange={setPlatform}>
400
506
  <SelectTrigger className="w-full">
401
- <SelectValue />
507
+ <SelectValue placeholder={t("editor.platformPlaceholder")} />
402
508
  </SelectTrigger>
403
509
  <SelectContent>
404
- {PLATFORMS.map((p) => (
510
+ {EXPORT_PLATFORMS.map((p) => (
405
511
  <SelectItem key={p.value} value={p.value}>
406
512
  {p.label}
407
513
  </SelectItem>
408
514
  ))}
409
515
  </SelectContent>
410
516
  </Select>
517
+ {!ready ? (
518
+ <p className="text-[10px] leading-relaxed text-le-ink-400">
519
+ {t("editor.platformFirstHint")}
520
+ </p>
521
+ ) : null}
411
522
  </div>
412
523
  )}
413
524
 
414
- <div className="space-y-1.5">
415
- <Label className="text-xs text-le-ink-500">{t("editor.pageScope")}</Label>
416
- <Select value={scope} onValueChange={(v) => setScope(v as Scope)}>
417
- <SelectTrigger className="w-full">
418
- <SelectValue />
419
- </SelectTrigger>
420
- <SelectContent>
421
- <SelectItem value="all">{t("editor.pageScopeAll")}</SelectItem>
422
- <SelectItem value="current">{t("editor.pageScopeCurrent")}</SelectItem>
423
- </SelectContent>
424
- </Select>
425
- </div>
426
-
427
- {/* PSD는 페이지가 레이어 그룹으로 항상 한 파일이고, 해상도는 문서
525
+ {ready ? (
526
+ <>
527
+ <div className="space-y-1.5">
528
+ <Label className="text-xs text-le-ink-500">{t("editor.fileFormat")}</Label>
529
+ <Select value={format} onValueChange={(v) => setFormat(v as Format)}>
530
+ <SelectTrigger className="w-full">
531
+ <span className="inline-flex items-center gap-2">
532
+ <ImageIcon size={15} className="text-le-ink-500" />
533
+ <SelectValue />
534
+ </span>
535
+ </SelectTrigger>
536
+ <SelectContent>
537
+ {profile.exports.map((value) => (
538
+ <SelectItem key={value} value={value}>
539
+ {value === "png"
540
+ ? "PNG"
541
+ : value === "jpeg"
542
+ ? "JPG"
543
+ : t(`editor.format${value[0].toUpperCase()}${value.slice(1)}`)}
544
+ </SelectItem>
545
+ ))}
546
+ </SelectContent>
547
+ </Select>
548
+ </div>
549
+
550
+ <div className="space-y-1.5">
551
+ <Label className="text-xs text-le-ink-500">{t("editor.pageScope")}</Label>
552
+ <Select value={scope} onValueChange={(v) => setScope(v as Scope)}>
553
+ <SelectTrigger className="w-full">
554
+ <SelectValue />
555
+ </SelectTrigger>
556
+ <SelectContent>
557
+ <SelectItem value="all">{t("editor.pageScopeAll")}</SelectItem>
558
+ <SelectItem value="current">{t("editor.pageScopeCurrent")}</SelectItem>
559
+ </SelectContent>
560
+ </Select>
561
+ </div>
562
+
563
+ {/* PSD는 페이지가 레이어 그룹으로 항상 한 파일이고, 해상도는 문서
428
564
  픽셀(1×) 고정. SVG/AI는 벡터라 해상도 개념이 없고 병합 여부만 고른다
429
565
  (AI는 항상 한 파일이고, 스위치는 아트보드를 합칠지를 고른다). */}
430
- {format !== "psd" ? (
431
- <label className="flex cursor-pointer items-center gap-2.5 py-0.5">
432
- <Switch checked={single} onCheckedChange={setSingle} />
433
- <span className="text-sm text-le-ink-700">
434
- {format === "ai"
435
- ? t("editor.mergeArtboard")
436
- : format === "svg"
437
- ? t("editor.mergeSingleFile")
438
- : t("editor.mergeSingle")}
439
- </span>
440
- </label>
441
- ) : null}
566
+ {format !== "psd" ? (
567
+ <label className="flex cursor-pointer items-center gap-2.5 py-0.5">
568
+ <Switch checked={single} onCheckedChange={setSingle} />
569
+ <span className="text-sm text-le-ink-700">
570
+ {format === "ai"
571
+ ? t("editor.mergeArtboard")
572
+ : format === "svg"
573
+ ? t("editor.mergeSingleFile")
574
+ : t("editor.mergeSingle")}
575
+ </span>
576
+ </label>
577
+ ) : null}
442
578
 
443
- {isRasterFormat(format) ? (
444
- <div className="space-y-1.5">
445
- <div className="flex items-center justify-between">
446
- <Label className="text-xs text-le-ink-500">{t("editor.resolution")}</Label>
447
- <span className="text-xs font-le-semibold text-le-ink-700">{resolution}×</span>
448
- </div>
449
- <Slider
450
- min={1}
451
- max={4}
452
- step={1}
453
- value={[resolution]}
454
- onValueChange={(v) => setResolution(v[0] ?? 1)}
455
- />
456
- <div className="flex justify-between text-[10px] text-le-ink-400">
457
- <span>1×</span>
458
- <span>4×</span>
459
- </div>
460
- </div>
461
- ) : null}
579
+ {/* 플랫폼에 폭이 있으면 배율은 그 폭에서 나온다 — 슬라이더를 두면 두 값이
580
+ 서로 싸운다. 폭이 없는 범용·캐러셀에서만 고르게 둔다. */}
581
+ {isRasterFormat(format) && !chosen?.width ? (
582
+ <div className="space-y-1.5">
583
+ <div className="flex items-center justify-between">
584
+ <Label className="text-xs text-le-ink-500">{t("editor.resolution")}</Label>
585
+ <span className="text-xs font-le-semibold text-le-ink-700">
586
+ {resolution}×
587
+ </span>
588
+ </div>
589
+ <Slider
590
+ min={1}
591
+ max={4}
592
+ step={1}
593
+ value={[resolution]}
594
+ onValueChange={(v) => setResolution(v[0] ?? 1)}
595
+ />
596
+ <div className="flex justify-between text-[10px] text-le-ink-400">
597
+ <span>1×</span>
598
+ <span>4×</span>
599
+ </div>
600
+ </div>
601
+ ) : null}
462
602
 
463
- {/* 움직이는 섹션은 정지 이미지로 눌러 담을 수 없어 ZIP 안에 따로 담긴다.
603
+ {/* 움직이는 섹션은 정지 이미지로 눌러 담을 수 없어 ZIP 안에 따로 담긴다.
464
604
  벡터·PSD는 위에서 일찍 빠져나가 첫 프레임 정지본만 남으므로, 이
465
605
  선택은 픽셀 형식일 때만 의미가 있다. */}
466
- {hasAnimation && isRasterFormat(format) ? (
467
- <div className="space-y-1.5">
468
- <Label className="text-xs text-le-ink-500">
469
- {t("editor.animationFormat")}
470
- </Label>
471
- <Select
472
- value={animationFormat}
473
- onValueChange={(v) => setAnimationFormat(v as AnimationFormat)}
474
- >
475
- <SelectTrigger className="w-full">
476
- <SelectValue />
477
- </SelectTrigger>
478
- <SelectContent>
479
- {animationFormats.map((f) => (
480
- <SelectItem key={f.value} value={f.value}>
481
- {t(f.labelKey)}
482
- </SelectItem>
483
- ))}
484
- </SelectContent>
485
- </Select>
486
- <p className="text-[10px] leading-relaxed text-le-ink-400">
487
- {t(
488
- ANIMATION_FORMATS.find((f) => f.value === animationFormat)?.hintKey ??
489
- "",
490
- )}
491
- </p>
492
- </div>
493
- ) : null}
606
+ {hasAnimation && isRasterFormat(format) ? (
607
+ <div className="space-y-1.5">
608
+ <Label className="text-xs text-le-ink-500">{t("editor.animationFormat")}</Label>
609
+ <Select
610
+ value={animationFormat}
611
+ onValueChange={(v) => setAnimationFormat(v as AnimationFormat)}
612
+ >
613
+ <SelectTrigger className="w-full">
614
+ <SelectValue />
615
+ </SelectTrigger>
616
+ <SelectContent>
617
+ {animationFormats.map((f) => (
618
+ <SelectItem key={f.value} value={f.value}>
619
+ {t(f.labelKey)}
620
+ </SelectItem>
621
+ ))}
622
+ </SelectContent>
623
+ </Select>
624
+ <p className="text-[10px] leading-relaxed text-le-ink-400">
625
+ {t(ANIMATION_FORMATS.find((f) => f.value === animationFormat)?.hintKey ?? "")}
626
+ </p>
627
+ </div>
628
+ ) : null}
494
629
 
495
- <div className="rounded-le-lg bg-le-ink-50 p-3 text-xs text-le-ink-600">
496
- <p className="mb-1 font-le-semibold text-le-ink-700">{t("editor.downloadInfo")}</p>
497
- <ul className="space-y-0.5">
498
- <li>• {t("editor.pagesCount", { count: selectedPages.length })}</li>
499
- <li>
500
- •{" "}
501
- {format === "svg" || format === "ai"
502
- ? t("editor.vectorOutput")
503
- : `${(isRasterFormat(format) ? outWidth : docWidth).toLocaleString()} × ${(isRasterFormat(format) ? outHeight : totalHeight).toLocaleString()} px`}
504
- </li>
505
- {format === "psd" ? <li>• {t("editor.psdNote")}</li> : null}
506
- {format === "svg" ? <li>• {t("editor.svgNote")}</li> : null}
507
- {format === "ai" ? <li>• {t("editor.aiNote")}</li> : null}
508
- {aiOverflow ? (
509
- <li className="text-le-warn-700">
510
- • {t("editor.aiOverflowNote", { limit: AI_MAX_ARTBOARD.toLocaleString() })}
511
- </li>
630
+ <div className="rounded-le-lg bg-le-ink-50 p-3 text-xs text-le-ink-600">
631
+ <p className="mb-1 font-le-semibold text-le-ink-700">
632
+ {t("editor.downloadInfo")}
633
+ </p>
634
+ <ul className="space-y-0.5">
635
+ <li>
636
+ •{" "}
637
+ {t("editor.pagesCount", {
638
+ count: selectedPages.length,
639
+ })}
640
+ </li>
641
+ <li>
642
+ •{" "}
643
+ {format === "svg" || format === "ai"
644
+ ? t("editor.vectorOutput")
645
+ : `${(isRasterFormat(format) ? outWidth : docWidth).toLocaleString()} × ${(isRasterFormat(format) ? outHeight : totalHeight).toLocaleString()} px`}
646
+ </li>
647
+ {chosen?.width && isRasterFormat(format) ? (
648
+ <li>
649
+ •{" "}
650
+ {t("editor.platformWidthNote", {
651
+ platform: chosen.label,
652
+ width: chosen.width.toLocaleString(),
653
+ })}
654
+ </li>
655
+ ) : null}
656
+ {chosen?.maxBytes && isRasterFormat(format) ? (
657
+ <li>
658
+ •{" "}
659
+ {t(
660
+ format === "png"
661
+ ? "editor.platformSizePngNote"
662
+ : "editor.platformSizeNote",
663
+ { size: bytesToMb(chosen.maxBytes) },
664
+ )}
665
+ </li>
666
+ ) : null}
667
+ {format === "psd" ? <li>• {t("editor.psdNote")}</li> : null}
668
+ {format === "svg" ? <li>• {t("editor.svgNote")}</li> : null}
669
+ {format === "ai" ? <li>• {t("editor.aiNote")}</li> : null}
670
+ {aiOverflow ? (
671
+ <li className="text-le-warn-700">
672
+ •{" "}
673
+ {t("editor.aiOverflowNote", {
674
+ limit: AI_MAX_ARTBOARD.toLocaleString(),
675
+ })}
676
+ </li>
677
+ ) : null}
678
+ </ul>
679
+ </div>
680
+
681
+ {notice ? (
682
+ <p className="text-xs font-le-medium text-le-warn-700">{notice}</p>
683
+ ) : null}
684
+ {error ? (
685
+ <p className="text-xs font-le-medium text-le-danger-600">{error}</p>
512
686
  ) : null}
513
- </ul>
514
- </div>
515
687
 
516
- {error ? (
517
- <p className="text-xs font-le-medium text-le-danger-600">{error}</p>
688
+ <Button
689
+ type="button"
690
+ onClick={handleExport}
691
+ disabled={exporting || selectedPages.length === 0}
692
+ className="h-11 w-full bg-le-ink-900 text-sm font-le-semibold hover:bg-le-ink-800"
693
+ >
694
+ {exporting ? (
695
+ <>
696
+ <Loader2 className="animate-spin" />
697
+ {t("editor.exporting")}
698
+ {progress ? ` (${progress.done}/${progress.total})` : null}
699
+ </>
700
+ ) : (
701
+ t("editor.downloadAction")
702
+ )}
703
+ </Button>
704
+ </>
518
705
  ) : null}
519
-
520
- <Button
521
- type="button"
522
- onClick={handleExport}
523
- disabled={exporting || selectedPages.length === 0}
524
- className="h-11 w-full bg-le-ink-900 text-sm font-le-semibold hover:bg-le-ink-800"
525
- >
526
- {exporting ? (
527
- <>
528
- <Loader2 className="animate-spin" />
529
- {t("editor.exporting")}
530
- {progress ? ` (${progress.done}/${progress.total})` : null}
531
- </>
532
- ) : (
533
- t("editor.downloadAction")
534
- )}
535
- </Button>
536
706
  </div>
537
707
  </div>
538
708
  ) : null}