@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.
- package/components/detail-page/detail-page-download-dialog.tsx +390 -220
- package/components/detail-page/leviosa-canvas-workspace.tsx +51 -12
- package/i18n/en.json +13 -0
- package/i18n/ko.json +13 -0
- package/lib/detail-page/export-platforms.ts +120 -0
- package/lib/detail-page-canvas/export/fit-budget.ts +103 -0
- package/lib/detail-page-canvas/export/gif-export.ts +176 -38
- package/package.json +1 -1
|
@@ -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
|
-
// 움직이는 섹션의 저장
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
82
|
+
// 움직이는 섹션의 저장 형식 문구. **어느 형식을 보여 줄지는 플랫폼이 정한다**
|
|
83
|
+
// (`export-platforms.ts`) — 쿠팡은 GIF 를 안 받고 WebP 만, 네이버는 반대로 WebP 를
|
|
84
|
+
// 안 받는다. 여기는 라벨과 설명만 있다. MP4 는 브라우저가 H.264 를 굽지 못하면
|
|
85
|
+
// 플랫폼이 허용해도 목록에서 빠진다.
|
|
81
86
|
const ANIMATION_FORMATS = [
|
|
82
|
-
{
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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<{
|
|
160
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
() =>
|
|
213
|
-
|
|
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
|
-
|
|
220
|
-
const
|
|
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 =
|
|
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, {
|
|
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, {
|
|
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
|
|
375
|
+
const { blob, unfitted, converted } = await gifLib.exportGifZip(s, {
|
|
268
376
|
host,
|
|
269
377
|
pageIds,
|
|
270
378
|
gifFlags,
|
|
271
|
-
pixelRatio:
|
|
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(
|
|
278
|
-
|
|
387
|
+
triggerBlobDownload(blob, `${base}.zip`);
|
|
388
|
+
finish(unfitted, converted);
|
|
279
389
|
return;
|
|
280
390
|
}
|
|
281
391
|
|
|
282
|
-
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
|
|
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 <
|
|
318
|
-
triggerDownload(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
<
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
<
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
603
|
+
{/* 움직이는 섹션은 정지 이미지로 눌러 담을 수 없어 ZIP 안에 따로 담긴다.
|
|
464
604
|
벡터·PSD는 위에서 일찍 빠져나가 첫 프레임 정지본만 남으므로, 이
|
|
465
605
|
선택은 픽셀 형식일 때만 의미가 있다. */}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
</
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
517
|
-
|
|
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}
|