@leviosa-ai/detail-page-editor 0.8.0 → 0.10.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.
@@ -1,20 +1,12 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useMemo, useState, type ReactNode } from "react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
4
4
  import { useTranslation } from "react-i18next";
5
5
  import { EditorHotkeys } from "./editor-hotkeys";
6
+ import { useAutoSave, type SaveReason } from "./use-auto-save";
6
7
  import { FindReplacePanel } from "./find-replace-panel";
7
8
 
8
- import {
9
- ChevronLeft,
10
- Copy,
11
- Download,
12
- Eye,
13
- EyeOff,
14
- ListChecks,
15
- RefreshCw,
16
- Save,
17
- } from "lucide-react";
9
+ import { ChevronLeft, Save } from "lucide-react";
18
10
 
19
11
  import {
20
12
  applyTextLineFit,
@@ -49,16 +41,28 @@ import type {
49
41
  GenerateDataGifFn,
50
42
  RemoveBackgroundFn,
51
43
  } from "./ai-generate-panel";
52
- import type {
53
- LeviosaCanvasDocument,
54
- CanvasSlotBinding,
55
- } from "../../types/detail-page-canvas";
44
+ import type { LeviosaCanvasDocument } from "../../types/detail-page-canvas";
56
45
 
57
46
  export type DetailPageEditorProps = {
58
47
  initialDocument: LeviosaCanvasDocument;
59
48
  saving?: boolean;
60
49
  uploadFile?: (file: File) => Promise<string>;
61
- onSave: (document: LeviosaCanvasDocument) => Promise<void>;
50
+ /**
51
+ * 문서를 저장한다. `reason` 은 왜 지금 저장하는가다 — 앱이 이걸 보고 무거운 뒷일
52
+ * (상세페이지의 HTML 굽기 같은)을 할지 말지 고른다. 자동저장은 `auto`, 저장 버튼은
53
+ * `manual`, 탭을 닫거나 편집기를 떠날 때는 `leave` 로 온다.
54
+ */
55
+ onSave: (
56
+ document: LeviosaCanvasDocument,
57
+ meta: { reason: SaveReason },
58
+ ) => Promise<void>;
59
+ /**
60
+ * 자동저장 간격(ms). 없으면 자동저장을 안 한다.
61
+ *
62
+ * 저장이 무거운 화면은 길게 잡는다 — 상세페이지 저장은 서버가 HTML 을 새로 만들어
63
+ * S3 에 올리고, 캐러셀 저장은 문서 JSON 만 넣는다.
64
+ */
65
+ autoSaveDelayMs?: number;
62
66
  /** AI 생성 탭의 프롬프트 → 이미지 URL 콜백. 없으면 패널은 안내만 표시. */
63
67
  onGenerateImage?: GenerateImageFn;
64
68
  /** AI 생성 탭의 프롬프트 → 애니메이션 GIF URL 콜백. 없으면 GIF 모드는 안내만 표시. */
@@ -124,313 +128,12 @@ export type DetailPageEditorProps = {
124
128
 
125
129
  type CanvasJson = Record<string, unknown>;
126
130
 
127
- type CanvasJsonElement = Record<string, unknown> & {
128
- id?: string;
129
- type?: string;
130
- x?: number;
131
- y?: number;
132
- width?: number;
133
- height?: number;
134
- text?: string;
135
- src?: string;
136
- custom?: Record<string, unknown>;
137
- children?: CanvasJsonElement[];
138
- };
139
-
140
- type SlotInspection = {
141
- slot: string;
142
- binding?: CanvasSlotBinding;
143
- element?: CanvasJsonElement;
144
- pageId?: string;
145
- state: "ok" | "warning" | "error";
146
- message: string;
147
- };
148
-
149
- type TemplateLintIssue = {
150
- level: "error" | "warning" | "info";
151
- title: string;
152
- detail: string;
153
- slot?: string;
154
- elementId?: string;
155
- };
156
-
157
- type TemplateQaSnapshot = {
158
- json: CanvasJson;
159
- slots: SlotInspection[];
160
- lint: TemplateLintIssue[];
161
- summary: string;
162
- counts: {
163
- pages: number;
164
- elements: number;
165
- slots: number;
166
- errors: number;
167
- warnings: number;
168
- };
169
- };
170
-
171
- const QA_OVERLAY_CUSTOM_KEY = "leviosaQaOverlay";
172
- const QA_OVERLAY_PREFIX = "leviosa-qa-overlay";
173
-
174
- function asRecord(value: unknown): Record<string, unknown> {
175
- return value && typeof value === "object" && !Array.isArray(value)
176
- ? (value as Record<string, unknown>)
177
- : {};
178
- }
179
-
180
- function asArray(value: unknown): unknown[] {
181
- return Array.isArray(value) ? value : [];
182
- }
183
-
184
- function finiteNumber(value: unknown): number | null {
185
- return typeof value === "number" && Number.isFinite(value) ? value : null;
186
- }
187
-
188
- function isQaOverlayElement(element: CanvasJsonElement): boolean {
189
- return element.id?.startsWith(`${QA_OVERLAY_PREFIX}-`) === true ||
190
- element.custom?.[QA_OVERLAY_CUSTOM_KEY] === true;
191
- }
192
-
193
- function walkElements(
194
- json: CanvasJson,
195
- visitor: (element: CanvasJsonElement, page: Record<string, unknown>) => void,
196
- ) {
197
- for (const page of asArray(json.pages) as Array<Record<string, unknown>>) {
198
- const visit = (element: CanvasJsonElement) => {
199
- visitor(element, page);
200
- for (const child of asArray(element.children) as CanvasJsonElement[]) {
201
- visit(child);
202
- }
203
- };
204
- for (const element of asArray(page.children) as CanvasJsonElement[]) {
205
- visit(element);
206
- }
207
- }
208
- }
209
-
210
- function stripQaOverlayElements(json: CanvasJson): CanvasJson {
211
- const clone = JSON.parse(JSON.stringify(json)) as CanvasJson;
212
- const strip = (elements: CanvasJsonElement[]): CanvasJsonElement[] =>
213
- elements
214
- .filter((element) => !isQaOverlayElement(element))
215
- .map((element) => ({
216
- ...element,
217
- children: Array.isArray(element.children)
218
- ? strip(element.children)
219
- : element.children,
220
- }));
221
-
222
- clone.pages = (asArray(clone.pages) as Array<Record<string, unknown>>).map((page) => ({
223
- ...page,
224
- children: strip(asArray(page.children) as CanvasJsonElement[]),
225
- }));
226
- return clone;
227
- }
228
-
229
- function getSlotFromElement(element: CanvasJsonElement): string {
230
- const custom = asRecord(element.custom);
231
- return typeof custom.leviosaSlot === "string" ? custom.leviosaSlot : "";
232
- }
233
-
234
- function getElementKind(element?: CanvasJsonElement): CanvasSlotBinding["kind"] | null {
235
- if (!element) return null;
236
- if (element.type === "image") return "image";
237
- if (element.type === "text") return "text";
238
- return null;
239
- }
240
-
241
- function formatRect(element?: CanvasJsonElement): string {
242
- if (!element) return "-";
243
- const x = finiteNumber(element.x) ?? 0;
244
- const y = finiteNumber(element.y) ?? 0;
245
- const width = finiteNumber(element.width) ?? 0;
246
- const height = finiteNumber(element.height) ?? 0;
247
- return `${Math.round(x)}, ${Math.round(y)} / ${Math.round(width)} x ${Math.round(height)}`;
248
- }
249
-
250
- function buildQaSnapshot(
251
- document: LeviosaCanvasDocument,
252
- json: CanvasJson,
253
- ): TemplateQaSnapshot {
254
- const slotBindings = document.slot_bindings ?? {};
255
- const cleanJson = stripQaOverlayElements(json);
256
- const pages = asArray(cleanJson.pages) as Array<Record<string, unknown>>;
257
- const elementById = new Map<string, { element: CanvasJsonElement; pageId?: string }>();
258
- const slotByElementId = new Map<string, string[]>();
259
- const slotsFromElements = new Map<string, { element: CanvasJsonElement; pageId?: string }>();
260
- let elementCount = 0;
261
-
262
- walkElements(cleanJson, (element, page) => {
263
- elementCount += 1;
264
- if (element.id) {
265
- const pageId = typeof page.id === "string" ? page.id : undefined;
266
- elementById.set(element.id, { element, pageId });
267
- const slot = getSlotFromElement(element);
268
- if (slot) slotsFromElements.set(slot, { element, pageId });
269
- }
270
- });
271
-
272
- for (const [slot, binding] of Object.entries(slotBindings)) {
273
- const list = slotByElementId.get(binding.element_id) ?? [];
274
- list.push(slot);
275
- slotByElementId.set(binding.element_id, list);
276
- }
277
-
278
- const lint: TemplateLintIssue[] = [];
279
- const slots = Object.entries(slotBindings)
280
- .sort(([a], [b]) => a.localeCompare(b))
281
- .map(([slot, binding]) => {
282
- const target = elementById.get(binding.element_id);
283
- const element = target?.element;
284
- const inferredKind = getElementKind(element);
285
- let state: SlotInspection["state"] = "ok";
286
- let message = "연결됨";
287
-
288
- if (!element) {
289
- state = "error";
290
- message = "바인딩 대상 요소가 없습니다";
291
- lint.push({
292
- level: "error",
293
- title: "Missing binding target",
294
- detail: `${binding.element_id} 요소를 찾을 수 없습니다.`,
295
- slot,
296
- elementId: binding.element_id,
297
- });
298
- } else if (inferredKind && inferredKind !== binding.kind) {
299
- state = "warning";
300
- message = `${binding.kind} 슬롯이 ${element.type} 요소에 연결됨`;
301
- lint.push({
302
- level: "warning",
303
- title: "Slot kind mismatch",
304
- detail: `${binding.kind} 슬롯이 ${element.type} 요소에 연결되어 있습니다.`,
305
- slot,
306
- elementId: binding.element_id,
307
- });
308
- }
309
-
310
- if (element?.type === "text" && !String(element.text ?? "").trim()) {
311
- state = state === "error" ? state : "warning";
312
- message = "텍스트가 비어 있습니다";
313
- lint.push({
314
- level: "warning",
315
- title: "Empty text slot",
316
- detail: "텍스트 요소에 표시할 문구가 없습니다.",
317
- slot,
318
- elementId: binding.element_id,
319
- });
320
- }
321
-
322
- if (element?.type === "image" && !String(element.src ?? "").trim()) {
323
- state = state === "error" ? state : "warning";
324
- message = "이미지 URL이 비어 있습니다";
325
- lint.push({
326
- level: "warning",
327
- title: "Empty image slot",
328
- detail: "이미지 요소에 src가 없습니다.",
329
- slot,
330
- elementId: binding.element_id,
331
- });
332
- }
333
-
334
- const width = finiteNumber(element?.width);
335
- const height = finiteNumber(element?.height);
336
- if (element && (width === null || height === null || width <= 0 || height <= 0)) {
337
- state = state === "error" ? state : "warning";
338
- message = "요소 크기를 확인하세요";
339
- lint.push({
340
- level: "warning",
341
- title: "Invalid element bounds",
342
- detail: "요소의 width 또는 height가 0 이하입니다.",
343
- slot,
344
- elementId: binding.element_id,
345
- });
346
- }
347
-
348
- return {
349
- slot,
350
- binding,
351
- element,
352
- pageId: target?.pageId,
353
- state,
354
- message,
355
- };
356
- });
357
-
358
- for (const [elementId, slotNames] of slotByElementId) {
359
- if (slotNames.length > 1) {
360
- lint.push({
361
- level: "warning",
362
- title: "Duplicate element binding",
363
- detail: `${slotNames.join(", ")} 슬롯이 같은 요소를 공유합니다.`,
364
- elementId,
365
- });
366
- }
367
- }
368
-
369
- for (const [slot, target] of slotsFromElements) {
370
- if (!slotBindings[slot]) {
371
- lint.push({
372
- level: "info",
373
- title: "Unregistered slot marker",
374
- detail: "요소 custom.leviosaSlot은 있지만 slot_bindings에는 없습니다.",
375
- slot,
376
- elementId: target.element.id,
377
- });
378
- }
379
- }
380
-
381
- const canvasWidth = finiteNumber(document.canvas.width);
382
- const jsonWidth = finiteNumber(cleanJson.width);
383
- if (canvasWidth && jsonWidth && canvasWidth !== jsonWidth) {
384
- lint.push({
385
- level: "warning",
386
- title: "Canvas width drift",
387
- detail: `문서 canvas.width ${canvasWidth}와 문서 JSON width ${jsonWidth}가 다릅니다.`,
388
- });
389
- }
390
-
391
- const errors = lint.filter((issue) => issue.level === "error").length;
392
- const warnings = lint.filter((issue) => issue.level === "warning").length;
393
- const summary = [
394
- `Template: ${document.template_id ?? "unspecified"} v${document.template_version ?? "-"}`,
395
- `Canvas: ${cleanJson.width ?? document.canvas.width} x ${cleanJson.height ?? "-"}`,
396
- `Pages: ${pages.length}, Elements: ${elementCount}, Slots: ${slots.length}`,
397
- `Lint: ${errors} errors, ${warnings} warnings`,
398
- "",
399
- "Slots",
400
- ...slots.map(
401
- (slot) =>
402
- `- ${slot.slot}: ${slot.state.toUpperCase()} | ${slot.binding?.kind ?? "-"} | ${slot.binding?.element_id ?? "-"} | ${formatRect(slot.element)} | ${slot.message}`,
403
- ),
404
- "",
405
- "Lint",
406
- ...(lint.length
407
- ? lint.map(
408
- (issue) =>
409
- `- ${issue.level.toUpperCase()}: ${issue.title}${issue.slot ? ` (${issue.slot})` : ""} - ${issue.detail}`,
410
- )
411
- : ["- PASS: template lint found no blocking issues"]),
412
- ].join("\n");
413
-
414
- return {
415
- json: cleanJson,
416
- slots,
417
- lint,
418
- summary,
419
- counts: {
420
- pages: pages.length,
421
- elements: elementCount,
422
- slots: slots.length,
423
- errors,
424
- warnings,
425
- },
426
- };
427
- }
428
-
429
131
  export function DetailPageEditor({
430
132
  initialDocument,
431
133
  saving = false,
432
134
  uploadFile,
433
135
  onSave,
136
+ autoSaveDelayMs,
434
137
  onGenerateImage,
435
138
  onGenerateGif,
436
139
  onGenerateTextGif,
@@ -467,25 +170,23 @@ export function DetailPageEditor({
467
170
  const [saveOk, setSaveOk] = useState(false);
468
171
  // 편집 한도 소진 시 "편집 크레딧 추가하기" → pricing 모달을 인플레이스로 연다.
469
172
  const [pricingOpen, setPricingOpen] = useState(false);
470
- const [qaJson, setQaJson] = useState<CanvasJson>(initialDocument.canvas_json);
471
- const [overlayEnabled, setOverlayEnabled] = useState(false);
472
- const [summaryCopied, setSummaryCopied] = useState(false);
473
173
  // 어댑터를 안 거친다. `adaptLeviosaCustomProps`는 SDK가 **못 읽는** 장식을 읽을 수
474
174
  // 있는 모양으로 낮춰 주던 일이었고(그림자·그라데이션 → 대표 단색), 우리 렌더러는
475
175
  // 그 필드들을 원본 그대로 읽는다. 낮춘 것을 넣으면 오히려 원본보다 못한 그림이 된다.
476
176
  // 브랜드 자산 주소는 바이트 경로로 옮겨서 연다 — 302 를 따라가면 CORS 가 깨져서
477
177
  // 캔버스가 그림을 못 읽는다(asset-bytes-url 에 실측 표를 적어 뒀다).
478
- const store = useMemo(() => {
178
+ // 문서는 편집기가 열릴 때 **한 번만** 읽는다. 저장이 끝나고 앱이 서버가 돌려준
179
+ // 문서를 도로 넣어도 캔버스를 다시 만들지 않는다 — 다시 만들면 되돌리기 100단과
180
+ // 선택이 통째로 날아간다. 저장 버튼 한 번에 ⌘Z 가 죽던 것이 그 증상이었고,
181
+ // 자동저장이 붙으면 몇 초마다 그렇게 된다.
182
+ //
183
+ // 정말로 다른 문서를 여는 것은 부르는 쪽이 `key` 로 다시 마운트한다.
184
+ const [store] = useState(() => {
479
185
  ensureCanvasKey();
480
186
  return createCanvasStore(
481
187
  normalizeDocumentAssetSrcs(initialDocument.canvas_json) as DocumentJson,
482
188
  );
483
- }, [initialDocument.canvas_json]);
484
-
485
- const qaSnapshot = useMemo(
486
- () => buildQaSnapshot(initialDocument, qaJson),
487
- [initialDocument, qaJson],
488
- );
189
+ });
489
190
 
490
191
  // 글꼴 준비. 문서가 쓰는 얼굴을 전부 받아 놓고, 다 온 뒤에 줄바꿈 손질
491
192
  // (`applyTextLineFit`)을 한 번 돌린다 — 폴백 서체로 재면 과대측정해서 손질이
@@ -506,159 +207,30 @@ export function DetailPageEditor({
506
207
  };
507
208
  }, [store]);
508
209
 
509
- useEffect(() => {
510
- // Initial sync is immediate so the QA panel is populated on mount.
511
- setQaJson(stripQaOverlayElements(store.toJSON() as CanvasJson));
512
-
513
- // 스토어는 요소를 끄는 내내 "change"를 계속 쏜다 — 끌기·크기조절·타이핑 모두.
514
- // or typed into. Recomputing the QA snapshot on every event deep-clones and
515
- // walks the whole document many times per second, which freezes the canvas
516
- // layer on large detail-page documents. The QA panel does not need live
517
- // updates mid-gesture, so coalesce changes with a trailing debounce: the
518
- // snapshot refreshes ~300ms after the user stops interacting.
519
- let timer: ReturnType<typeof setTimeout> | null = null;
520
- // 바뀐 문서를 이벤트에 실어 받지 않고 **잠잠해진 뒤에** 한 번 읽는다. 글자 한 자마다
521
- // 문서 전체를 직렬화해 넘기면 그 비용이 타이핑 지연으로 그대로 보인다.
522
- const flush = () => {
523
- timer = null;
524
- setQaJson(stripQaOverlayElements(store.toJSON() as CanvasJson));
525
- };
526
- const off = store.on("change", () => {
527
- if (timer) clearTimeout(timer);
528
- timer = setTimeout(flush, 300);
529
- });
530
- return () => {
531
- if (timer) clearTimeout(timer);
532
- off();
533
- };
534
- }, [store]);
535
-
536
- useEffect(() => {
537
- setSummaryCopied(false);
538
- }, [qaSnapshot.summary]);
539
-
540
- const removeOverlayElements = () => {
541
- const ids: string[] = [];
542
- walkElements(store.toJSON() as CanvasJson, (element) => {
543
- if (element.id && isQaOverlayElement(element)) ids.push(element.id);
544
- });
545
- if (ids.length) store.deleteElements(ids);
546
- };
547
-
548
- const addOverlayElements = () => {
549
- removeOverlayElements();
550
- const cleanJson = qaSnapshot.json;
551
- const pages = asArray(cleanJson.pages) as Array<Record<string, unknown>>;
552
- for (const slot of qaSnapshot.slots) {
553
- if (!slot.element || !slot.element.id || !slot.pageId) continue;
554
- const pageIndex = pages.findIndex((page) => page.id === slot.pageId);
555
- const page = store.pages[pageIndex >= 0 ? pageIndex : 0];
556
- if (!page) continue;
557
- const x = finiteNumber(slot.element.x) ?? 0;
558
- const y = finiteNumber(slot.element.y) ?? 0;
559
- const width = Math.max(finiteNumber(slot.element.width) ?? 1, 1);
560
- const height = Math.max(finiteNumber(slot.element.height) ?? 1, 1);
561
- const color = slot.state === "error" ? "#dc2626" : slot.state === "warning" ? "#d97706" : "#059669";
562
- page.addElement(
563
- {
564
- id: `${QA_OVERLAY_PREFIX}-box-${slot.element.id}`,
565
- type: "figure",
566
- subType: "rect",
567
- x,
568
- y,
569
- width,
570
- height,
571
- fill: "rgba(255,255,255,0)",
572
- stroke: color,
573
- strokeWidth: 3,
574
- dash: [10, 6] as never,
575
- selectable: false,
576
- removable: false,
577
- alwaysOnTop: true,
578
- showInExport: false,
579
- custom: { [QA_OVERLAY_CUSTOM_KEY]: true, slot: slot.slot },
580
- },
581
- { skipSelect: true },
582
- );
583
- page.addElement(
584
- {
585
- id: `${QA_OVERLAY_PREFIX}-label-${slot.element.id}`,
586
- type: "text",
587
- x,
588
- y: Math.max(0, y - 30),
589
- width: Math.max(120, Math.min(width, 320)),
590
- height: 24,
591
- text: slot.slot,
592
- fontSize: 14,
593
- fontWeight: "700",
594
- fill: "#ffffff",
595
- backgroundEnabled: true,
596
- backgroundColor: color,
597
- backgroundOpacity: 0.92,
598
- backgroundCornerRadius: 4,
599
- backgroundPadding: 6,
600
- selectable: false,
601
- removable: false,
602
- alwaysOnTop: true,
603
- showInExport: false,
604
- custom: { [QA_OVERLAY_CUSTOM_KEY]: true, slot: slot.slot },
605
- },
606
- { skipSelect: true },
607
- );
608
- }
609
- };
610
-
611
- useEffect(() => {
612
- if (overlayEnabled) addOverlayElements();
613
- else removeOverlayElements();
614
- return removeOverlayElements;
615
- // The overlay is intentionally regenerated from the latest QA snapshot.
616
- // eslint-disable-next-line react-hooks/exhaustive-deps
617
- }, [overlayEnabled, qaSnapshot.summary, store]);
618
-
619
- const handleSave = async () => {
620
- setSaveError(null);
621
- setSaveOk(false);
622
- try {
623
- // Drop QA overlays and revert gray image placeholders to empty src so the
624
- // persisted document never carries the editor-only placeholder.
625
- const cleanJson = clearPlaceholderImageSrc(
626
- stripQaOverlayElements(store.toJSON() as CanvasJson),
627
- );
628
- await onSave({
629
- ...initialDocument,
630
- canvas_json: cleanJson,
631
- });
632
- setSaveOk(true);
633
- } catch (error) {
634
- setSaveError(error instanceof Error ? error.message : t("editor.saveError"));
635
- }
636
- };
637
-
638
- const handleRefreshQa = () => {
639
- setQaJson(stripQaOverlayElements(store.toJSON() as CanvasJson));
640
- };
641
-
642
- const handleSelectSlot = (slot: SlotInspection) => {
643
- if (slot.binding?.element_id) {
644
- store.selectElements([slot.binding.element_id]);
645
- }
646
- };
210
+ const documentRef = useRef(initialDocument);
211
+ documentRef.current = initialDocument;
212
+
213
+ const runSave = useCallback(
214
+ async (reason: SaveReason) => {
215
+ setSaveError(null);
216
+ setSaveOk(false);
217
+ try {
218
+ // 회색 자리표시 이미지는 빈 src 로 되돌려 저장한다 — 편집기에서만 쓰는 그림이다.
219
+ const cleanJson = clearPlaceholderImageSrc(store.toJSON() as CanvasJson);
220
+ await onSave({ ...documentRef.current, canvas_json: cleanJson }, { reason });
221
+ setSaveOk(true);
222
+ } catch (error) {
223
+ setSaveError(error instanceof Error ? error.message : t("editor.saveError"));
224
+ // 자동저장은 조용히 넘어가지만 실패는 알려 줘야 다음에 다시 보낸다.
225
+ if (reason !== "manual") throw error;
226
+ }
227
+ },
228
+ [onSave, store, t],
229
+ );
647
230
 
648
- const handleCopySummary = async () => {
649
- await navigator.clipboard.writeText(qaSnapshot.summary);
650
- setSummaryCopied(true);
651
- };
231
+ const handleSave = useCallback(() => void runSave("manual"), [runSave]);
652
232
 
653
- const handleDownloadSummary = () => {
654
- const blob = new Blob([qaSnapshot.summary], { type: "text/plain;charset=utf-8" });
655
- const url = URL.createObjectURL(blob);
656
- const link = document.createElement("a");
657
- link.href = url;
658
- link.download = `${initialDocument.template_id ?? "canvas-template"}-qa-summary.txt`;
659
- link.click();
660
- URL.revokeObjectURL(url);
661
- };
233
+ const unsaved = useAutoSave({ store, delayMs: autoSaveDelayMs, save: runSave });
662
234
 
663
235
  // The canvas subtree only depends on the stable store and sidebar
664
236
  // contract. Memoize it so QA/save/overlay updates never recreate the heavy
@@ -837,174 +409,6 @@ export function DetailPageEditor({
837
409
  dataGifCreditCost={dataGifCreditCost}
838
410
  />
839
411
  </div>
840
-
841
- {/* Template QA / slot inspector / lint — authoring tools, not seller-facing. */}
842
- <details className="border-t border-dpe-ink-200">
843
- <summary className="cursor-pointer p-4 text-xs font-dpe-semibold uppercase tracking-[0.06em] text-dpe-ink-400 hover:text-dpe-ink-600">
844
- 개발자 도구 · 템플릿 QA
845
- </summary>
846
- <div className="flex max-h-[55vh] min-h-0 flex-col overflow-y-auto">
847
- <div className="border-b border-dpe-ink-200 p-4">
848
- <div className="flex items-center justify-between gap-3">
849
- <div>
850
- <h2 className="flex items-center gap-2 text-sm font-dpe-semibold text-dpe-ink-950">
851
- <ListChecks aria-hidden="true" size={16} />
852
- {t("editor.qaTitle")}
853
- </h2>
854
- <p className="mt-1 text-xs text-dpe-ink-500">
855
- {qaSnapshot.counts.pages} pages / {qaSnapshot.counts.elements} elements /{" "}
856
- {qaSnapshot.counts.slots} slots
857
- </p>
858
- </div>
859
- <button
860
- type="button"
861
- onClick={handleRefreshQa}
862
- className="inline-flex h-8 w-8 items-center justify-center rounded-dpe-md border border-dpe-ink-200 text-dpe-ink-600 hover:bg-dpe-ink-50"
863
- title={t("editor.refreshQa")}
864
- >
865
- <RefreshCw aria-hidden="true" size={15} />
866
- </button>
867
- </div>
868
- <div className="mt-4 grid grid-cols-3 gap-2 text-center text-xs">
869
- <div className="rounded-dpe-md border border-dpe-ink-200 p-2">
870
- <div className="text-base font-dpe-semibold text-dpe-ink-950">{qaSnapshot.counts.slots}</div>
871
- <div className="text-dpe-ink-500">slots</div>
872
- </div>
873
- <div className="rounded-dpe-md border border-dpe-danger-100 bg-dpe-danger-50 p-2">
874
- <div className="text-base font-dpe-semibold text-dpe-danger-700">{qaSnapshot.counts.errors}</div>
875
- <div className="text-dpe-danger-600">errors</div>
876
- </div>
877
- <div className="rounded-dpe-md border border-dpe-warn-100 bg-dpe-warn-50 p-2">
878
- <div className="text-base font-dpe-semibold text-dpe-warn-700">{qaSnapshot.counts.warnings}</div>
879
- <div className="text-dpe-warn-600">warnings</div>
880
- </div>
881
- </div>
882
- <div className="mt-4 flex gap-2">
883
- <button
884
- type="button"
885
- onClick={() => setOverlayEnabled((value) => !value)}
886
- className="inline-flex h-9 flex-1 items-center justify-center gap-2 rounded-dpe-md border border-dpe-ink-200 text-xs font-dpe-semibold text-dpe-ink-700 hover:bg-dpe-ink-50"
887
- >
888
- {overlayEnabled ? <EyeOff aria-hidden="true" size={15} /> : <Eye aria-hidden="true" size={15} />}
889
- {overlayEnabled ? t("editor.overlayOff") : t("editor.overlayOn")}
890
- </button>
891
- <button
892
- type="button"
893
- onClick={handleCopySummary}
894
- className="inline-flex h-9 w-9 items-center justify-center rounded-dpe-md border border-dpe-ink-200 text-dpe-ink-700 hover:bg-dpe-ink-50"
895
- title={t("editor.copySummary")}
896
- >
897
- <Copy aria-hidden="true" size={15} />
898
- </button>
899
- <button
900
- type="button"
901
- onClick={handleDownloadSummary}
902
- className="inline-flex h-9 w-9 items-center justify-center rounded-dpe-md border border-dpe-ink-200 text-dpe-ink-700 hover:bg-dpe-ink-50"
903
- title={t("editor.downloadSummary")}
904
- >
905
- <Download aria-hidden="true" size={15} />
906
- </button>
907
- </div>
908
- {summaryCopied ? (
909
- <p className="mt-2 text-xs font-dpe-medium text-dpe-ok-600">{t("editor.summaryCopied")}</p>
910
- ) : null}
911
- </div>
912
-
913
- <div className="min-h-0 flex-1 overflow-y-auto p-4">
914
- <section>
915
- <h3 className="text-xs font-dpe-semibold uppercase tracking-[0.08em] text-dpe-ink-500">
916
- {t("editor.slotInspector")}
917
- </h3>
918
- <div className="mt-3 space-y-2">
919
- {qaSnapshot.slots.length ? (
920
- qaSnapshot.slots.map((slot) => (
921
- <button
922
- key={slot.slot}
923
- type="button"
924
- onClick={() => handleSelectSlot(slot)}
925
- className="w-full rounded-dpe-md border border-dpe-ink-200 p-3 text-left hover:border-dpe-ink-300 hover:bg-dpe-ink-50"
926
- >
927
- <div className="flex items-center justify-between gap-2">
928
- <span className="truncate text-sm font-dpe-semibold text-dpe-ink-950">{slot.slot}</span>
929
- <span
930
- className={
931
- slot.state === "error"
932
- ? "text-xs font-dpe-semibold text-dpe-danger-600"
933
- : slot.state === "warning"
934
- ? "text-xs font-dpe-semibold text-dpe-warn-600"
935
- : "text-xs font-dpe-semibold text-dpe-ok-600"
936
- }
937
- >
938
- {slot.state}
939
- </span>
940
- </div>
941
- <p className="mt-1 truncate text-xs text-dpe-ink-500">
942
- {slot.binding?.kind ?? "-"} / {slot.binding?.element_id ?? "-"}
943
- </p>
944
- <p className="mt-1 text-xs text-dpe-ink-600">{formatRect(slot.element)}</p>
945
- <p className="mt-2 text-xs text-dpe-ink-500">{slot.message}</p>
946
- </button>
947
- ))
948
- ) : (
949
- <p className="rounded-dpe-md border border-dpe-ink-200 p-3 text-sm text-dpe-ink-500">
950
- {t("editor.noSlots")}
951
- </p>
952
- )}
953
- </div>
954
- </section>
955
-
956
- <section className="mt-6">
957
- <h3 className="text-xs font-dpe-semibold uppercase tracking-[0.08em] text-dpe-ink-500">
958
- {t("editor.templateLint")}
959
- </h3>
960
- <div className="mt-3 space-y-2">
961
- {qaSnapshot.lint.length ? (
962
- qaSnapshot.lint.map((issue, index) => (
963
- <div
964
- key={`${issue.title}-${issue.slot ?? issue.elementId ?? index}`}
965
- className="rounded-dpe-md border border-dpe-ink-200 p-3"
966
- >
967
- <div className="flex items-center justify-between gap-2">
968
- <p className="text-sm font-dpe-semibold text-dpe-ink-950">{issue.title}</p>
969
- <span
970
- className={
971
- issue.level === "error"
972
- ? "text-xs font-dpe-semibold text-dpe-danger-600"
973
- : issue.level === "warning"
974
- ? "text-xs font-dpe-semibold text-dpe-warn-600"
975
- : "text-xs font-dpe-semibold text-dpe-active-600"
976
- }
977
- >
978
- {issue.level}
979
- </span>
980
- </div>
981
- <p className="mt-1 text-xs leading-5 text-dpe-ink-600">{issue.detail}</p>
982
- {issue.slot || issue.elementId ? (
983
- <p className="mt-2 truncate text-xs text-dpe-ink-400">
984
- {[issue.slot, issue.elementId].filter(Boolean).join(" / ")}
985
- </p>
986
- ) : null}
987
- </div>
988
- ))
989
- ) : (
990
- <div className="rounded-dpe-md border border-dpe-ok-100 bg-dpe-ok-50 p-3 text-sm font-dpe-medium text-dpe-ok-700">
991
- {t("editor.lintPassed")}
992
- </div>
993
- )}
994
- </div>
995
- </section>
996
-
997
- <section className="mt-6">
998
- <h3 className="text-xs font-dpe-semibold uppercase tracking-[0.08em] text-dpe-ink-500">
999
- {t("editor.designerSummary")}
1000
- </h3>
1001
- <pre className="mt-3 max-h-72 overflow-auto rounded-dpe-md border border-dpe-ink-200 bg-dpe-ink-50 p-3 text-xs leading-5 text-dpe-ink-700">
1002
- {qaSnapshot.summary}
1003
- </pre>
1004
- </section>
1005
- </div>
1006
- </div>
1007
- </details>
1008
412
  </aside>
1009
413
  );
1010
414
  // 인스펙터는 감싸 쓰는 경우가 더 많다 — 자기 크롬만 두르고 속은 그대로 둔다.
@@ -1018,7 +422,7 @@ export function DetailPageEditor({
1018
422
  <HeaderSlot
1019
423
  productName={productName?.trim() || t("editor.untitled")}
1020
424
  onBack={onBack}
1021
- save={{ run: handleSave, saving, ok: saveOk, error: saveError }}
425
+ save={{ run: handleSave, saving, ok: saveOk, error: saveError, unsaved }}
1022
426
  parts={{
1023
427
  history: historyPart,
1024
428
  download: downloadPart,
@@ -25,10 +25,12 @@ import { FONT_TAGS, type FontTag } from "../../lib/detail-page-canvas/font-tags"
25
25
 
26
26
  export function DetailPageFontPicker({
27
27
  value,
28
+ text,
28
29
  documentFamilies,
29
30
  onSelect,
30
31
  }: {
31
32
  value: string;
33
+ text: string;
32
34
  documentFamilies: string[];
33
35
  onSelect: (family: string) => Promise<void>;
34
36
  }) {
@@ -37,6 +39,7 @@ export function DetailPageFontPicker({
37
39
  const [loading, setLoading] = useState<string | null>(null);
38
40
  const [error, setError] = useState(false);
39
41
  const [tags, setTags] = useState<FontTag[]>([]);
42
+ const hasHangul = /\p{Script=Hangul}/u.test(text);
40
43
  /** 404 로 떨어진 미리보기. 한 번 떨어지면 다시 안 부른다. */
41
44
  const [previewsMissing, setPreviewsMissing] = useState<ReadonlySet<string>>(
42
45
  () => new Set(),
@@ -85,7 +88,7 @@ export function DetailPageFontPicker({
85
88
  key={`${font.source}:${font.id}`}
86
89
  value={`${font.label} ${font.labelEn} ${font.family}`}
87
90
  onSelect={() => void select(font.family)}
88
- disabled={loading !== null}
91
+ disabled={loading !== null || (hasHangul && font.latinOnly)}
89
92
  className="group min-h-[56px] gap-2 px-2 py-1"
90
93
  >
91
94
  <span className="flex h-11 min-w-0 flex-1 items-center overflow-hidden">
@@ -225,6 +228,11 @@ export function DetailPageFontPicker({
225
228
  ) : null}
226
229
  </CommandList>
227
230
  </Command>
231
+ {hasHangul ? (
232
+ <p className="border-t border-dpe-ink-100 px-3 py-2 text-xs text-dpe-ink-500">
233
+ {t("detailPage.properties.fontLatinOnlyUnavailable")}
234
+ </p>
235
+ ) : null}
228
236
  {error ? (
229
237
  <p role="alert" className="border-t border-dpe-danger-100 bg-dpe-danger-50 px-3 py-2 text-xs text-dpe-danger-600">
230
238
  {t("detailPage.properties.fontLoadFailed")}
@@ -482,9 +482,11 @@ export interface EditorHeaderSlotProps {
482
482
  save: {
483
483
  run: () => void;
484
484
  saving: boolean;
485
- /** 방금 저장됐다. 잠시 뒤 스스로 꺼진다. */
485
+ /** 방금 저장됐다. 다음 저장이 시작되면 꺼진다. */
486
486
  ok: boolean;
487
487
  error: string | null;
488
+ /** 아직 저장 안 된 변경이 남아 있다. 자동저장을 안 켠 화면에서는 언제나 false. */
489
+ unsaved: boolean;
488
490
  };
489
491
  /** 편집기가 만들어 주는 조각들. 자리만 정하면 된다. */
490
492
  parts: {
@@ -1,5 +1,7 @@
1
1
  "use client";
2
2
 
3
+ import { detailPageEditorProfile } from "../../lib/detail-page/editor-profile";
4
+
3
5
  import { useCallback, useState, useSyncExternalStore } from "react";
4
6
  import { observer } from "./canvas-observer";
5
7
  import { useTranslation } from "react-i18next";
@@ -252,10 +254,18 @@ export const DetailPagePagesPanel = observer(function DetailPagePagesPanel({
252
254
  [s],
253
255
  );
254
256
 
255
- const totalHeight = Math.round(
256
- s.pages.reduce((acc, p) => acc + p.computedHeight, 0),
257
- );
257
+ // 판이 고정 크기면(캐러셀) 높이를 «더하지 않는다». 상세페이지는 세로로 이어진 한 장이라
258
+ // 합계가 곧 문서 높이지만, 캐러셀은 판이 따로따로라 합계가 아무 뜻이 없다 —
259
+ // 8판짜리가 1080×10800 으로 보였다.
260
+ const profile = detailPageEditorProfile();
258
261
  const width = Math.round(s.pages[0]?.computedWidth ?? 0);
262
+ const shownHeight = profile.page.fixed
263
+ ? Math.round(
264
+ typeof profile.page.height === "number"
265
+ ? profile.page.height
266
+ : s.pages[0]?.computedHeight ?? 0,
267
+ )
268
+ : Math.round(s.pages.reduce((acc, p) => acc + p.computedHeight, 0));
259
269
 
260
270
  return (
261
271
  <div className="flex h-full flex-col">
@@ -264,7 +274,7 @@ export const DetailPagePagesPanel = observer(function DetailPagePagesPanel({
264
274
  {t("detailPage.pages.totalPages", { count: s.pages.length })}
265
275
  </p>
266
276
  <p className="text-xs text-dpe-ink-400">
267
- {width} × {totalHeight} px
277
+ {width} × {shownHeight} px
268
278
  </p>
269
279
  </div>
270
280
 
@@ -790,6 +790,7 @@ const TextInspector = observer(function TextInspector({
790
790
  <div className="grid grid-cols-[1fr_84px] gap-2">
791
791
  <DetailPageFontPicker
792
792
  value={fontFamily}
793
+ text={str(ref.text)}
793
794
  documentFamilies={fonts}
794
795
  onSelect={applyFontFamily}
795
796
  />
@@ -0,0 +1,105 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+
3
+ export type SaveReason = "auto" | "manual" | "leave";
4
+
5
+ /** 변경 알림만 받으면 되므로 스토어 전체를 요구하지 않는다. */
6
+ interface ChangeSource {
7
+ on(event: "change", listener: () => void): () => void;
8
+ }
9
+
10
+ /**
11
+ * 문서가 바뀌면 잠잠해진 뒤에 한 번 저장한다.
12
+ *
13
+ * 규칙 셋이 전부다.
14
+ *
15
+ * 1. **한 번에 하나만 보낸다.** 상세페이지 서버는 저장을 멱등키로 직렬화해서, 겹쳐
16
+ * 보내면 뒤엣것이 "처리 중"으로 거절당한다. 보내는 동안 또 바뀌었으면 끝난 뒤에
17
+ * 한 번 더 — 밀린 변경이 몇 번이든 요청은 하나로 접힌다.
18
+ * 2. **탭을 닫거나 숨기면 기다리지 않는다.** 디바운스를 기다리다 창이 닫히면 그
19
+ * 구간의 편집이 통째로 사라진다.
20
+ * 3. **실패는 삼키되 잊지 않는다.** dirty 를 되돌려 두면 다음 변경이나 다음 이탈에
21
+ * 다시 나간다. 자동저장이 실패했다고 편집을 막을 일은 아니고, 그렇다고 곧바로
22
+ * 다시 보내지도 않는다 — 서버가 죽어 있는 동안 요청을 쉬지 않고 때리게 된다.
23
+ *
24
+ * `delayMs` 가 없으면 아무것도 안 한다 — 자동저장을 아직 안 켠 화면이 그렇다.
25
+ *
26
+ * @returns 저장 안 된 변경이 남아 있는지. 헤더가 «변경됨»을 그리는 데 쓴다.
27
+ */
28
+ export function useAutoSave(options: {
29
+ store: ChangeSource;
30
+ delayMs?: number;
31
+ save: (reason: SaveReason) => Promise<void>;
32
+ }): boolean {
33
+ const { store, delayMs } = options;
34
+ // 저장 함수는 매 렌더 새로 온다. 구독을 그때마다 다시 걸면 디바운스가 초기화되므로
35
+ // 최신 것만 상자에 담아 둔다.
36
+ const saveRef = useRef(options.save);
37
+ saveRef.current = options.save;
38
+
39
+ const [dirty, setDirty] = useState(false);
40
+ const dirtyRef = useRef(false);
41
+ const runningRef = useRef(false);
42
+
43
+ useEffect(() => {
44
+ if (!delayMs) return;
45
+ let timer: ReturnType<typeof setTimeout> | null = null;
46
+ let live = true;
47
+
48
+ const mark = (next: boolean) => {
49
+ dirtyRef.current = next;
50
+ if (live) setDirty(next);
51
+ };
52
+
53
+ const flush = async (reason: SaveReason): Promise<void> => {
54
+ if (!dirtyRef.current || runningRef.current) return;
55
+ mark(false);
56
+ runningRef.current = true;
57
+ let sent = false;
58
+ try {
59
+ await saveRef.current(reason);
60
+ sent = true;
61
+ } catch {
62
+ mark(true);
63
+ } finally {
64
+ runningRef.current = false;
65
+ }
66
+ // 성공했을 때만 이어서 보낸다. 실패한 것을 곧바로 다시 보내면 서버가 죽어 있는
67
+ // 동안 요청을 쉬지 않고 때린다 — 다음 변경이나 다음 이탈까지 기다린다.
68
+ if (sent && dirtyRef.current) await flush(reason);
69
+ };
70
+
71
+ const off = store.on("change", () => {
72
+ mark(true);
73
+ if (timer) clearTimeout(timer);
74
+ timer = setTimeout(() => {
75
+ timer = null;
76
+ void flush("auto");
77
+ }, delayMs);
78
+ });
79
+
80
+ const leave = () => {
81
+ if (timer) {
82
+ clearTimeout(timer);
83
+ timer = null;
84
+ }
85
+ void flush("leave");
86
+ };
87
+ const onHidden = () => {
88
+ if (document.visibilityState === "hidden") leave();
89
+ };
90
+
91
+ window.addEventListener("pagehide", leave);
92
+ document.addEventListener("visibilitychange", onHidden);
93
+
94
+ return () => {
95
+ live = false;
96
+ off();
97
+ window.removeEventListener("pagehide", leave);
98
+ document.removeEventListener("visibilitychange", onHidden);
99
+ // 편집기를 떠나는 것도 이탈이다 — 다른 화면으로 넘어가는 길이 여기뿐이다.
100
+ leave();
101
+ };
102
+ }, [store, delayMs]);
103
+
104
+ return dirty;
105
+ }
package/i18n/en.json CHANGED
@@ -7,18 +7,6 @@
7
7
  "saved": "Saved",
8
8
  "saving": "Saving",
9
9
  "save": "Save",
10
- "qaTitle": "Template QA",
11
- "refreshQa": "Refresh QA",
12
- "overlayOff": "Overlay off",
13
- "overlayOn": "Overlay on",
14
- "copySummary": "Copy summary",
15
- "downloadSummary": "Download summary",
16
- "summaryCopied": "Summary copied",
17
- "slotInspector": "Slot Inspector",
18
- "noSlots": "No slot_bindings found.",
19
- "templateLint": "Template Lint",
20
- "lintPassed": "Template lint passed.",
21
- "designerSummary": "Designer Summary",
22
10
  "undo": "Undo",
23
11
  "redo": "Redo",
24
12
  "fontEmbeddingFailed": "Could not embed {{font}} in the file. Please try again.",
@@ -124,6 +112,7 @@
124
112
  "typeShape": "Shape",
125
113
  "selectionCount": "{{count}} selected",
126
114
  "fontLatinOnly": "Latin only",
115
+ "fontLatinOnlyUnavailable": "Latin-only fonts cannot be used for text containing Korean.",
127
116
  "fontLoading": "Loading font",
128
117
  "chooseFont": "Choose font",
129
118
  "fontTagFilter": "Font style",
package/i18n/ko.json CHANGED
@@ -7,18 +7,6 @@
7
7
  "saved": "저장됨",
8
8
  "saving": "저장 중",
9
9
  "save": "저장",
10
- "qaTitle": "템플릿 QA",
11
- "refreshQa": "QA 새로고침",
12
- "overlayOff": "오버레이 끄기",
13
- "overlayOn": "오버레이 켜기",
14
- "copySummary": "요약 복사",
15
- "downloadSummary": "요약 다운로드",
16
- "summaryCopied": "요약을 복사했습니다",
17
- "slotInspector": "슬롯 검사",
18
- "noSlots": "slot_bindings가 없습니다.",
19
- "templateLint": "템플릿 린트",
20
- "lintPassed": "템플릿 린트를 통과했습니다.",
21
- "designerSummary": "디자이너 요약",
22
10
  "undo": "실행 취소",
23
11
  "redo": "다시 실행",
24
12
  "fontEmbeddingFailed": "{{font}} 폰트를 파일에 포함하지 못했습니다. 잠시 후 다시 시도해 주세요.",
@@ -124,6 +112,7 @@
124
112
  "typeShape": "도형",
125
113
  "selectionCount": "{{count}}개 선택",
126
114
  "fontLatinOnly": "영문 전용",
115
+ "fontLatinOnlyUnavailable": "한글이 포함된 글자에는 영문 전용 글꼴을 쓸 수 없어요.",
127
116
  "fontLoading": "글꼴 불러오는 중",
128
117
  "chooseFont": "글꼴 선택",
129
118
  "fontTagFilter": "글꼴 종류",
package/index.ts CHANGED
@@ -22,6 +22,7 @@ export type * from "./types/detail-page-api";
22
22
  export type * from "./types/commerce";
23
23
 
24
24
  export { DetailPageEditor } from "./components/detail-page/detail-page-editor";
25
+ export type { SaveReason } from "./components/detail-page/use-auto-save";
25
26
 
26
27
  /**
27
28
  * 편집기가 부르는 주소(라우트 · 정적 자산)를 앱에 맞춘다.
@@ -54,6 +54,7 @@ export function getClosestSupportedFontWeight(
54
54
 
55
55
  const loadedFontFaces = new Set<string>();
56
56
  const loadedFamilyWeights = new Set<string>();
57
+ const warnedFallbackFaces = new Set<string>();
57
58
  const stylesheetPromises = new Map<string, Promise<void>>();
58
59
  const FONT_LOAD_SAMPLE = "가나다라마바사아자차카타파하 ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789";
59
60
  /**
@@ -220,7 +221,12 @@ function loadFontFacesOnce(requests: FontFaceRequest[]): Promise<void> {
220
221
  const descriptor = `${request.weight} ${request.size}px "${request.family}"`;
221
222
  const faces = await document.fonts.load(descriptor, request.sample);
222
223
  if (faces.length === 0) {
223
- throw new FontLoadError(`No bundled font face matched: ${descriptor}`);
224
+ if (!warnedFallbackFaces.has(descriptor)) {
225
+ warnedFallbackFaces.add(descriptor);
226
+ // eslint-disable-next-line no-console
227
+ console.warn(`[leviosa-canvas] ${descriptor}: bundled font did not match; using browser fallback`);
228
+ }
229
+ return;
224
230
  }
225
231
  const unloadedFace = faces.find((face) => face.status !== "loaded");
226
232
  if (unloadedFace) {
@@ -65,7 +65,6 @@ function fromCatalog(font: (typeof DETAIL_PAGE_FONTS)[number]): EditorFont {
65
65
  previewSrc: `${editorAssetBase("detailFontPreviews")}/${font.id}.webp`,
66
66
  tags: toFontTags(font.tags),
67
67
  weights: getDetailPageFontWeights(font),
68
- latinOnly: font.latinOnly,
69
68
  licenseName: font.licenseName,
70
69
  licenseUrl: font.licenseUrl,
71
70
  licenseNoteKey: font.licenseNoteKey,
@@ -82,6 +81,7 @@ function fromBundle(font: (typeof ALL_FONT_OPTIONS)[number]): EditorFont {
82
81
  previewSrc: `${editorAssetBase("cardnewsFontPreviews")}/${font.id}.webp`,
83
82
  tags: bundleFontTags(font.id),
84
83
  weights: [...font.weights].sort((a, b) => a - b),
84
+ latinOnly: (font as { latinOnly?: boolean }).latinOnly,
85
85
  licenseName: font.license.name,
86
86
  licenseUrl: font.license.url,
87
87
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviosa-ai/detail-page-editor",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Leviosa detail-page editor shell. Source-only: consumers compile it through Next transpilePackages. Every app coupling (sourcing server, brand store, cache keys, toast) arrives through the DetailPageHost interface, and every URL it fetches comes from configureDetailPageEditor.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -66,7 +66,7 @@
66
66
  "woff2-encoder": "2.0.0"
67
67
  },
68
68
  "peerDependencies": {
69
- "@leviosa-ai/canvas": "0.3.0",
69
+ "@leviosa-ai/canvas": "0.4.0",
70
70
  "@tanstack/react-query": "^5.100.3",
71
71
  "konva": "^10.0.0",
72
72
  "lucide-react": "^0.563.0 || ^1.0.0",