@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.
- package/components/detail-page/detail-page-download-dialog.tsx +16 -7
- package/components/detail-page/detail-page-editor.tsx +6 -3
- package/components/detail-page/detail-page-font-picker.tsx +9 -1
- package/components/detail-page/detail-page-page-toolbar.tsx +21 -4
- package/components/detail-page/detail-page-pages-panel.tsx +14 -4
- package/components/detail-page/detail-page-properties-panel.tsx +12 -2
- package/config/detail-page-bundle-font-tags.json +9 -1
- package/i18n/en.json +2 -0
- package/i18n/ko.json +2 -0
- package/lib/cardnews/font-loader.ts +10 -4
- package/lib/detail-page/editor-profile.ts +43 -0
- package/lib/detail-page/runtime-config.ts +1 -1
- package/lib/detail-page-canvas/editor-fonts.ts +1 -1
- package/lib/detail-page-canvas/gif-web-fonts.ts +2 -2
- package/package.json +2 -3
- package/types/detail-page-canvas.ts +2 -1
|
@@ -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 =
|
|
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>(
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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(
|
|
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(
|
|
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 (!
|
|
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:
|
|
107
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
256
|
-
|
|
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} × {
|
|
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
|
|
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
|
-
|
|
11
|
+
LEVIOSA_CANVAS_FONTS_VERSION,
|
|
12
12
|
catalogFont,
|
|
13
13
|
fontLoadSampleForText,
|
|
14
14
|
resolveFontFamily,
|
|
15
15
|
type CatalogFont,
|
|
16
|
-
} from "@leviosa-ai/
|
|
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=${
|
|
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
|
-
|
|
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/
|
|
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 {
|
|
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=${
|
|
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.
|
|
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": "
|
|
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
|
|
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";
|