@leviosa-ai/detail-page-editor 0.7.4 → 0.9.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.
@@ -20,6 +20,10 @@ import {
20
20
  import type { ExportDocument } from "../../lib/detail-page-canvas/export/document-model";
21
21
  import { detectGifPages } from "../../lib/detail-page-canvas/export/gif-plan";
22
22
  import { isMp4EncodeSupported } from "../../lib/detail-page-canvas/export/mp4-support";
23
+ import {
24
+ detailPageEditorProfile,
25
+ type DetailPageEditorFormat,
26
+ } from "../../lib/detail-page/editor-profile";
23
27
 
24
28
  /**
25
29
  * hookable-style 다운로드 팝오버 — 다운로드 버튼 바로 아래에 떠서 파일 형식 / 등록
@@ -62,7 +66,7 @@ const PLATFORMS = [
62
66
  { value: "general", label: "일반(범용)" },
63
67
  ] as const;
64
68
 
65
- type Format = "png" | "jpeg" | "psd" | "svg" | "ai";
69
+ type Format = DetailPageEditorFormat;
66
70
  type Scope = "all" | "current";
67
71
  type AnimationFormat = "webp" | "gif" | "mp4";
68
72
 
@@ -134,8 +138,9 @@ export const DetailPageDownloadDialog = observer(function DetailPageDownloadDial
134
138
  const { t } = useTranslation("branding");
135
139
  const host = useDetailPageHost();
136
140
  const s = store as ExportStoreLike;
141
+ const profile = detailPageEditorProfile();
137
142
  const [open, setOpen] = useState(false);
138
- const [format, setFormat] = useState<Format>("png");
143
+ const [format, setFormat] = useState<Format>(profile.exports[0]);
139
144
  const [platform, setPlatform] = useState<string>("naver");
140
145
  const [scope, setScope] = useState<Scope>("all");
141
146
  const [single, setSingle] = useState(true);
@@ -366,11 +371,15 @@ export const DetailPageDownloadDialog = observer(function DetailPageDownloadDial
366
371
  </span>
367
372
  </SelectTrigger>
368
373
  <SelectContent>
369
- <SelectItem value="png">PNG</SelectItem>
370
- <SelectItem value="jpeg">JPG</SelectItem>
371
- <SelectItem value="psd">{t("editor.formatPsd")}</SelectItem>
372
- <SelectItem value="ai">{t("editor.formatAi")}</SelectItem>
373
- <SelectItem value="svg">{t("editor.formatSvg")}</SelectItem>
374
+ {profile.exports.map((value) => (
375
+ <SelectItem key={value} value={value}>
376
+ {value === "png"
377
+ ? "PNG"
378
+ : value === "jpeg"
379
+ ? "JPG"
380
+ : t(`editor.format${value[0].toUpperCase()}${value.slice(1)}`)}
381
+ </SelectItem>
382
+ ))}
374
383
  </SelectContent>
375
384
  </Select>
376
385
  </div>
@@ -30,6 +30,7 @@ import { collectFontRequests } from "@leviosa-ai/canvas/render/use-document-font
30
30
  import { ensureCanvasKey } from "../../lib/detail-page-canvas/canvas-key";
31
31
  import { loadEditorFont } from "../../lib/detail-page-canvas/editor-fonts";
32
32
  import { normalizeDocumentAssetSrcs } from "../../lib/detail-page/asset-bytes-url";
33
+ import { selectDetailPageEditorProfile } from "../../lib/detail-page/editor-profile";
33
34
  import type { DocumentJson } from "@leviosa-ai/canvas/types";
34
35
  import { buildDetailPageSections } from "./detail-page-sidebar-sections";
35
36
  import { DetailPageProperties } from "./detail-page-properties-panel";
@@ -250,6 +251,7 @@ function buildQaSnapshot(
250
251
  document: LeviosaCanvasDocument,
251
252
  json: CanvasJson,
252
253
  ): TemplateQaSnapshot {
254
+ const slotBindings = document.slot_bindings ?? {};
253
255
  const cleanJson = stripQaOverlayElements(json);
254
256
  const pages = asArray(cleanJson.pages) as Array<Record<string, unknown>>;
255
257
  const elementById = new Map<string, { element: CanvasJsonElement; pageId?: string }>();
@@ -267,14 +269,14 @@ function buildQaSnapshot(
267
269
  }
268
270
  });
269
271
 
270
- for (const [slot, binding] of Object.entries(document.slot_bindings)) {
272
+ for (const [slot, binding] of Object.entries(slotBindings)) {
271
273
  const list = slotByElementId.get(binding.element_id) ?? [];
272
274
  list.push(slot);
273
275
  slotByElementId.set(binding.element_id, list);
274
276
  }
275
277
 
276
278
  const lint: TemplateLintIssue[] = [];
277
- const slots = Object.entries(document.slot_bindings)
279
+ const slots = Object.entries(slotBindings)
278
280
  .sort(([a], [b]) => a.localeCompare(b))
279
281
  .map(([slot, binding]) => {
280
282
  const target = elementById.get(binding.element_id);
@@ -365,7 +367,7 @@ function buildQaSnapshot(
365
367
  }
366
368
 
367
369
  for (const [slot, target] of slotsFromElements) {
368
- if (!document.slot_bindings[slot]) {
370
+ if (!slotBindings[slot]) {
369
371
  lint.push({
370
372
  level: "info",
371
373
  title: "Unregistered slot marker",
@@ -450,6 +452,7 @@ export function DetailPageEditor({
450
452
  onBack,
451
453
  headerActions,
452
454
  }: DetailPageEditorProps) {
455
+ selectDetailPageEditorProfile(initialDocument);
453
456
  const { t } = useTranslation("branding");
454
457
  // 요금제 모달은 호스트가 꽂는다 — 편집기가 열지만 무엇을 얼마에 파는지는 앱이 안다.
455
458
  const { slots } = useDetailPageHost();
@@ -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")}
@@ -18,6 +18,7 @@ import {
18
18
  requestSectionReauthor,
19
19
  subscribeSectionReauthorAvailability,
20
20
  } from "../../lib/detail-page/section-reauthor-bus";
21
+ import { detailPageEditorProfile } from "../../lib/detail-page/editor-profile";
21
22
 
22
23
  /**
23
24
  * hookable-style per-section (per-page) floating quick toolbar.
@@ -94,17 +95,25 @@ export const DetailPagePageToolbar = observer(function DetailPagePageToolbar({
94
95
  );
95
96
  const s = store as StoreLike;
96
97
  const p = page as PageLike;
98
+ const profile = detailPageEditorProfile();
97
99
  const index = s.pages.findIndex((pg) => pg.id === p.id);
98
100
  const isFirst = index <= 0;
99
101
  const isLast = index === s.pages.length - 1;
100
102
  const canDelete = s.pages.length > 1;
103
+ const canAdd = s.pages.length < profile.maxPages;
101
104
 
102
105
  const addAfter = () => {
106
+ if (!canAdd) return;
103
107
  const ref = s.activePage ?? s.pages[index];
104
108
  const next = s.addPage({
105
109
  bleed: ref?.bleed ?? 0,
106
- width: ref?.width ?? "auto",
107
- height: ref?.height ?? "auto",
110
+ width:
111
+ profile.page.width === "document"
112
+ ? ref?.width ?? "auto"
113
+ : profile.page.width,
114
+ height: profile.page.fixed
115
+ ? profile.page.height
116
+ : ref?.height ?? profile.page.height,
108
117
  });
109
118
  next.setZIndex(index + 1);
110
119
  };
@@ -153,10 +162,18 @@ export const DetailPagePageToolbar = observer(function DetailPagePageToolbar({
153
162
  <IconButton label={t("detailPage.pageToolbar.moveDown")} onClick={() => p.setZIndex(index + 1)} disabled={isLast}>
154
163
  <ChevronDown size={17} />
155
164
  </IconButton>
156
- <IconButton label={t("detailPage.pageToolbar.duplicate")} onClick={() => p.clone()}>
165
+ <IconButton
166
+ label={t("detailPage.pageToolbar.duplicate")}
167
+ onClick={() => p.clone()}
168
+ disabled={!canAdd}
169
+ >
157
170
  <Copy size={16} />
158
171
  </IconButton>
159
- <IconButton label={t("detailPage.pageToolbar.addBelow")} onClick={addAfter}>
172
+ <IconButton
173
+ label={t("detailPage.pageToolbar.addBelow")}
174
+ onClick={addAfter}
175
+ disabled={!canAdd}
176
+ >
160
177
  <Plus size={17} />
161
178
  </IconButton>
162
179
 
@@ -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
 
@@ -94,6 +94,7 @@ import {
94
94
  } from "./ai-generate-panel";
95
95
  import { toHexColor } from "../../lib/detail-page/css-color";
96
96
  import { editorAssetBase } from "../../lib/detail-page/runtime-config";
97
+ import { detailPageEditorProfile } from "../../lib/detail-page/editor-profile";
97
98
  import { setZ as setElementZ, zOrderOf } from "../../lib/detail-page/z-order";
98
99
  import {
99
100
  canDistribute,
@@ -789,6 +790,7 @@ const TextInspector = observer(function TextInspector({
789
790
  <div className="grid grid-cols-[1fr_84px] gap-2">
790
791
  <DetailPageFontPicker
791
792
  value={fontFamily}
793
+ text={str(ref.text)}
792
794
  documentFamilies={fonts}
793
795
  onSelect={applyFontFamily}
794
796
  />
@@ -2173,6 +2175,7 @@ const PageHeightSection = observer(function PageHeightSection({
2173
2175
  page?: PageLike;
2174
2176
  }) {
2175
2177
  const { t } = useTranslation("branding");
2178
+ const profile = detailPageEditorProfile();
2176
2179
  if (!page) return null;
2177
2180
  const height = Math.round(num(page.computedHeight, MIN_SECTION_HEIGHT));
2178
2181
  const contentBottom = sectionContentBottom(page);
@@ -2180,7 +2183,13 @@ const PageHeightSection = observer(function PageHeightSection({
2180
2183
  // "사라진" 것처럼 보인다 — 지운 게 아니라 화면 밖으로 나간 것이다.
2181
2184
  const overflow = contentBottom > height;
2182
2185
  return (
2183
- <Section title={t("detailPage.properties.pageHeight")}>
2186
+ <Section
2187
+ title={t(
2188
+ profile.wording === "section"
2189
+ ? "detailPage.properties.pageHeight"
2190
+ : "detailPage.properties.plateHeight",
2191
+ )}
2192
+ >
2184
2193
  <div className="flex items-center gap-1.5">
2185
2194
  <div className="min-w-0 flex-1">
2186
2195
  <NumberField
@@ -2222,11 +2231,12 @@ const PageInspector = observer(function PageInspector({
2222
2231
  dataGifCreditCost?: number;
2223
2232
  }) {
2224
2233
  const { t } = useTranslation("branding");
2234
+ const profile = detailPageEditorProfile();
2225
2235
  const page = store.activePage ?? store.pages[0];
2226
2236
  const bg = str(page?.background, "#ffffff");
2227
2237
  return (
2228
2238
  <>
2229
- <PageHeightSection page={page} />
2239
+ {profile.page.fixed ? null : <PageHeightSection page={page} />}
2230
2240
  <Section title={t("detailPage.properties.pageBackground")}>
2231
2241
  <FillControl value={bg} onChange={(c) => page?.set?.({ background: c })} />
2232
2242
  <p className="mt-2 text-xs text-dpe-ink-400">
@@ -35,5 +35,13 @@
35
35
  "samlip-hopang-outline": ["heading", "outline", "rounded"],
36
36
  "paperlogy": ["heading", "body", "gothic", "angular"],
37
37
  "freesentation": ["body", "gothic"],
38
- "diphylleia": ["heading", "myeongjo"]
38
+ "diphylleia": ["heading", "myeongjo"],
39
+ "jost": ["heading", "gothic", "rounded"],
40
+ "bebas-neue": ["heading", "angular"],
41
+ "anton": ["heading", "angular"],
42
+ "archivo-black": ["heading", "gothic"],
43
+ "oswald": ["heading", "gothic", "angular"],
44
+ "playfair-display": ["heading", "myeongjo", "angular"],
45
+ "eb-garamond": ["body", "myeongjo"],
46
+ "parisienne": ["handwriting", "rounded"]
39
47
  }
package/i18n/en.json CHANGED
@@ -112,6 +112,7 @@
112
112
  "saveToMyShapes": "Save to brand shapes",
113
113
  "aiShapeEdit": "AI shape editing",
114
114
  "pageHeight": "Section height",
115
+ "plateHeight": "Plate height",
115
116
  "pageHeightFit": "Fit to content",
116
117
  "pageHeightOverflow": "Content reaches {{px}}px and overflows the bottom of this section.",
117
118
  "pageHeightHint": "You can also drag the handle below the canvas.",
@@ -123,6 +124,7 @@
123
124
  "typeShape": "Shape",
124
125
  "selectionCount": "{{count}} selected",
125
126
  "fontLatinOnly": "Latin only",
127
+ "fontLatinOnlyUnavailable": "Latin-only fonts cannot be used for text containing Korean.",
126
128
  "fontLoading": "Loading font",
127
129
  "chooseFont": "Choose font",
128
130
  "fontTagFilter": "Font style",
package/i18n/ko.json CHANGED
@@ -112,6 +112,7 @@
112
112
  "saveToMyShapes": "브랜드 도형에 저장",
113
113
  "aiShapeEdit": "AI 도형 편집",
114
114
  "pageHeight": "섹션 높이",
115
+ "plateHeight": "판 높이",
115
116
  "pageHeightFit": "내용에 맞추기",
116
117
  "pageHeightOverflow": "내용이 {{px}}px까지 내려가 화면 아래로 넘칩니다.",
117
118
  "pageHeightHint": "캔버스 아래 손잡이를 끌어서도 바꿀 수 있어요.",
@@ -123,6 +124,7 @@
123
124
  "typeShape": "도형",
124
125
  "selectionCount": "{{count}}개 선택",
125
126
  "fontLatinOnly": "영문 전용",
127
+ "fontLatinOnlyUnavailable": "한글이 포함된 글자에는 영문 전용 글꼴을 쓸 수 없어요.",
126
128
  "fontLoading": "글꼴 불러오는 중",
127
129
  "chooseFont": "글꼴 선택",
128
130
  "fontTagFilter": "글꼴 종류",
@@ -8,12 +8,12 @@
8
8
 
9
9
  import {
10
10
  FONT_CATALOG,
11
- LEVIOSA_KONVA_VERSION,
11
+ LEVIOSA_CANVAS_FONTS_VERSION,
12
12
  catalogFont,
13
13
  fontLoadSampleForText,
14
14
  resolveFontFamily,
15
15
  type CatalogFont,
16
- } from "@leviosa-ai/konva";
16
+ } from "@leviosa-ai/canvas/fonts";
17
17
 
18
18
  import { editorAssetBase } from "../detail-page/runtime-config";
19
19
 
@@ -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
  /**
@@ -84,7 +85,7 @@ const COMMON_HANGUL_SAMPLE =
84
85
  * `/render-fonts` 라, 아무것도 안 부른 소비자에게는 주소가 그대로다.
85
86
  */
86
87
  function bundleUrl(path: string): string {
87
- return `${editorAssetBase("fontBundle")}${path}?v=${LEVIOSA_KONVA_VERSION}`;
88
+ return `${editorAssetBase("fontBundle")}${path}?v=${LEVIOSA_CANVAS_FONTS_VERSION}`;
88
89
  }
89
90
 
90
91
  const localFontCssUrl = () => bundleUrl("/font-css.css");
@@ -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) {
@@ -0,0 +1,43 @@
1
+ import type { LeviosaCanvasDocument } from "../../types/detail-page-canvas";
2
+
3
+ export type DetailPageEditorFormat = "png" | "jpeg" | "psd" | "svg" | "ai";
4
+
5
+ export type DetailPageEditorProfile = {
6
+ page: {
7
+ width: "document" | number;
8
+ height: "auto" | number;
9
+ fixed: boolean;
10
+ };
11
+ maxPages: number;
12
+ exports: readonly DetailPageEditorFormat[];
13
+ wording: "section" | "plate";
14
+ };
15
+
16
+ const DETAIL_PAGE_PROFILE: DetailPageEditorProfile = {
17
+ page: { width: "document", height: "auto", fixed: false },
18
+ maxPages: Number.POSITIVE_INFINITY,
19
+ exports: ["png", "jpeg", "psd", "ai", "svg"],
20
+ wording: "section",
21
+ };
22
+
23
+ const CAROUSEL_PROFILE: DetailPageEditorProfile = {
24
+ page: { width: 1080, height: 1350, fixed: true },
25
+ maxPages: 10,
26
+ exports: ["jpeg"],
27
+ wording: "plate",
28
+ };
29
+
30
+ let current = DETAIL_PAGE_PROFILE;
31
+
32
+ /** 문서 종류를 읽는 유일한 자리. 종류가 없으면 기존 상세페이지 설정을 쓴다. */
33
+ export function selectDetailPageEditorProfile(
34
+ document: Pick<LeviosaCanvasDocument, "kind">,
35
+ ): DetailPageEditorProfile {
36
+ current = document.kind === "carousel" ? CAROUSEL_PROFILE : DETAIL_PAGE_PROFILE;
37
+ return current;
38
+ }
39
+
40
+ /** 편집기 입구에서 고른 설정표. 안쪽 코드는 문서 종류 대신 이 값만 읽는다. */
41
+ export function detailPageEditorProfile(): DetailPageEditorProfile {
42
+ return current;
43
+ }
@@ -41,7 +41,7 @@ export type DetailPageEditorEndpoints = {
41
41
 
42
42
  export type DetailPageEditorAssets = {
43
43
  /**
44
- * `@leviosa-ai/konva` 의 `leviosa-konva-fonts` 가 구워 두는 폰트 번들의 뿌리.
44
+ * `@leviosa-ai/canvas` 의 `leviosa-konva-fonts` 가 구워 두는 폰트 번들의 뿌리.
45
45
  * 기본 `${basePath}/render-fonts`.
46
46
  */
47
47
  fontBundle?: string;
@@ -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
  };
@@ -13,7 +13,7 @@
13
13
  * 그냥 비워 보낸다 — 그 경우 서버의 폴백 폰트로 그려진다.
14
14
  */
15
15
 
16
- import { LEVIOSA_KONVA_VERSION } from "@leviosa-ai/konva";
16
+ import { LEVIOSA_CANVAS_FONTS_VERSION } from "@leviosa-ai/canvas/fonts";
17
17
 
18
18
  import {
19
19
  closestDetailPageFontWeight,
@@ -78,7 +78,7 @@ export function resolveGifWebFonts(
78
78
  const weight = closestEditorFontWeight(editorFont, face.weight);
79
79
  const url = fontBundleAbsoluteUrl(
80
80
  origin,
81
- `/family-css/${slug}-${weight}.css?v=${LEVIOSA_KONVA_VERSION}`,
81
+ `/family-css/${slug}-${weight}.css?v=${LEVIOSA_CANVAS_FONTS_VERSION}`,
82
82
  );
83
83
  // 번들이 상대 경로로 설정돼 있고 오리진도 http 면 서버가 못 받아간다 — 건너뛴다.
84
84
  if (!url) continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviosa-ai/detail-page-editor",
3
- "version": "0.7.4",
3
+ "version": "0.9.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,8 +66,7 @@
66
66
  "woff2-encoder": "2.0.0"
67
67
  },
68
68
  "peerDependencies": {
69
- "@leviosa-ai/canvas": "^0.1.4",
70
- "@leviosa-ai/konva": "^0.8.0",
69
+ "@leviosa-ai/canvas": "0.4.0",
71
70
  "@tanstack/react-query": "^5.100.3",
72
71
  "konva": "^10.0.0",
73
72
  "lucide-react": "^0.563.0 || ^1.0.0",
@@ -7,13 +7,14 @@ export type CanvasSlotBinding = {
7
7
  export type LeviosaCanvasDocument = {
8
8
  schema_version: "leviosa-canvas-detail-page-v1";
9
9
  renderer: "leviosa_canvas_detail_page";
10
+ kind?: "detail-page" | "carousel";
10
11
  template_id?: string | null;
11
12
  template_version?: number | null;
12
13
  canvas: {
13
14
  width: number;
14
15
  background: string;
15
16
  };
16
- slot_bindings: Record<string, CanvasSlotBinding>;
17
+ slot_bindings?: Record<string, CanvasSlotBinding>;
17
18
  canvas_json: Record<string, unknown>;
18
19
  fonts: Array<Record<string, unknown>>;
19
20
  source: "leviosa_canvas_editor";