@leviosa-ai/detail-page-editor 0.1.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/cardnews/color-input.tsx +380 -0
- package/components/detail-page/ai-generate-panel.tsx +763 -0
- package/components/detail-page/annotation-canvas.tsx +860 -0
- package/components/detail-page/annotation-dialog.tsx +397 -0
- package/components/detail-page/bubble-tail-overlay.tsx +209 -0
- package/components/detail-page/canvas-context-menu.tsx +234 -0
- package/components/detail-page/canvas-observer.tsx +68 -0
- package/components/detail-page/canvas-overlay-host.tsx +44 -0
- package/components/detail-page/canvas-store-facade.ts +51 -0
- package/components/detail-page/chart-inspector.tsx +531 -0
- package/components/detail-page/design-reference-picker.tsx +383 -0
- package/components/detail-page/detail-page-basic-shapes.tsx +180 -0
- package/components/detail-page/detail-page-brand-gifs-panel.tsx +261 -0
- package/components/detail-page/detail-page-brand-kit-panel.tsx +77 -0
- package/components/detail-page/detail-page-brand-panel-header.tsx +44 -0
- package/components/detail-page/detail-page-charts-panel.tsx +104 -0
- package/components/detail-page/detail-page-decorations-panel.tsx +125 -0
- package/components/detail-page/detail-page-download-dialog.tsx +524 -0
- package/components/detail-page/detail-page-editor.tsx +974 -0
- package/components/detail-page/detail-page-elements-panel.tsx +117 -0
- package/components/detail-page/detail-page-font-picker.tsx +231 -0
- package/components/detail-page/detail-page-history-buttons.tsx +87 -0
- package/components/detail-page/detail-page-host-context.tsx +465 -0
- package/components/detail-page/detail-page-icons-panel.tsx +251 -0
- package/components/detail-page/detail-page-konva-json-preview.tsx +474 -0
- package/components/detail-page/detail-page-layers-panel.tsx +618 -0
- package/components/detail-page/detail-page-my-images-panel.tsx +179 -0
- package/components/detail-page/detail-page-my-shapes-panel.tsx +174 -0
- package/components/detail-page/detail-page-page-toolbar.tsx +171 -0
- package/components/detail-page/detail-page-pages-panel.tsx +299 -0
- package/components/detail-page/detail-page-pages-timeline.tsx +79 -0
- package/components/detail-page/detail-page-photos-panel.tsx +257 -0
- package/components/detail-page/detail-page-properties-panel.tsx +2680 -0
- package/components/detail-page/detail-page-qr-panel.tsx +137 -0
- package/components/detail-page/detail-page-references-panel.tsx +166 -0
- package/components/detail-page/detail-page-selection.ts +44 -0
- package/components/detail-page/detail-page-shapes-panel.tsx +16 -0
- package/components/detail-page/detail-page-sidebar-sections.tsx +324 -0
- package/components/detail-page/detail-page-tables-panel.tsx +106 -0
- package/components/detail-page/detail-page-text-panel.tsx +179 -0
- package/components/detail-page/detail-page-thumbnail-bus.ts +42 -0
- package/components/detail-page/edit-quota-ui.tsx +150 -0
- package/components/detail-page/editor-history.ts +56 -0
- package/components/detail-page/editor-hotkeys.tsx +165 -0
- package/components/detail-page/element-recents-strip.tsx +105 -0
- package/components/detail-page/element-rects.ts +82 -0
- package/components/detail-page/fill-control.tsx +261 -0
- package/components/detail-page/find-replace-panel.tsx +252 -0
- package/components/detail-page/generation-progress-fill.tsx +57 -0
- package/components/detail-page/gif-animator.tsx +184 -0
- package/components/detail-page/gif-effect-picker.tsx +136 -0
- package/components/detail-page/group-drill-in.tsx +219 -0
- package/components/detail-page/group-prompt-edit-panel.tsx +154 -0
- package/components/detail-page/hover-highlight-overlay.tsx +111 -0
- package/components/detail-page/hovered-layer.ts +34 -0
- package/components/detail-page/inspector-controls.tsx +175 -0
- package/components/detail-page/leviosa-canvas-workspace.tsx +313 -0
- package/components/detail-page/nested-selection-overlay.tsx +364 -0
- package/components/detail-page/panel-search-input.tsx +49 -0
- package/components/detail-page/prompt-edit-panel.tsx +146 -0
- package/components/detail-page/section-height-handle.tsx +276 -0
- package/components/detail-page/section-reauthor-controller.tsx +210 -0
- package/components/detail-page/spec-prompt-edit-panel.tsx +178 -0
- package/components/detail-page/spec-resize-absorber.tsx +149 -0
- package/components/detail-page/svg-prompt-edit-panel.tsx +139 -0
- package/components/detail-page/table-canvas-overlay.tsx +707 -0
- package/components/detail-page/table-inspector.tsx +586 -0
- package/components/ui/button.tsx +64 -0
- package/components/ui/command.tsx +175 -0
- package/components/ui/dialog.tsx +167 -0
- package/components/ui/label.tsx +24 -0
- package/components/ui/select.tsx +190 -0
- package/components/ui/slider.tsx +63 -0
- package/components/ui/switch.tsx +35 -0
- package/components/ui/tooltip.tsx +57 -0
- package/config/detail-page-bundle-font-tags.json +39 -0
- package/config/detail-page-fonts.json +373 -0
- package/index.ts +24 -0
- package/lib/cardnews/font-loader.ts +306 -0
- package/lib/detail-page/animation-sniff.ts +101 -0
- package/lib/detail-page/asset-bytes-url.ts +70 -0
- package/lib/detail-page/basic-shapes.ts +603 -0
- package/lib/detail-page/canvas-menu.ts +182 -0
- package/lib/detail-page/chart/defaults.ts +283 -0
- package/lib/detail-page/chart/format.ts +66 -0
- package/lib/detail-page/chart/normalize.ts +149 -0
- package/lib/detail-page/chart/palette.ts +85 -0
- package/lib/detail-page/chart/paste.ts +82 -0
- package/lib/detail-page/chart/preview.ts +78 -0
- package/lib/detail-page/chart/render.ts +188 -0
- package/lib/detail-page/chart/renderers/bar-h.ts +103 -0
- package/lib/detail-page/chart/renderers/bar-v.ts +213 -0
- package/lib/detail-page/chart/renderers/donut.ts +186 -0
- package/lib/detail-page/chart/renderers/gauge.ts +96 -0
- package/lib/detail-page/chart/renderers/line.ts +143 -0
- package/lib/detail-page/chart/renderers/shared.ts +263 -0
- package/lib/detail-page/chart/renderers/stack.ts +121 -0
- package/lib/detail-page/chart/sync.ts +92 -0
- package/lib/detail-page/chart/types.ts +145 -0
- package/lib/detail-page/css-color.ts +34 -0
- package/lib/detail-page/data-gif-payload.ts +167 -0
- package/lib/detail-page/design-reference.ts +244 -0
- package/lib/detail-page/distribute.ts +89 -0
- package/lib/detail-page/editor-routes.ts +10 -0
- package/lib/detail-page/element-recents.ts +167 -0
- package/lib/detail-page/find-replace.ts +120 -0
- package/lib/detail-page/format-painter.ts +159 -0
- package/lib/detail-page/gif-frames.ts +216 -0
- package/lib/detail-page/gif-timeline.ts +65 -0
- package/lib/detail-page/group-action.ts +62 -0
- package/lib/detail-page/hangul-roman.ts +58 -0
- package/lib/detail-page/icon-curated.ts +290 -0
- package/lib/detail-page/icon-fuzzy.ts +250 -0
- package/lib/detail-page/icon-keywords.generated.ts +3766 -0
- package/lib/detail-page/icon-keywords.ko.ts +524 -0
- package/lib/detail-page/icon-search.ts +257 -0
- package/lib/detail-page/icons.ts +67 -0
- package/lib/detail-page/image-credit.ts +121 -0
- package/lib/detail-page/image-data-uri.ts +29 -0
- package/lib/detail-page/insert-image.ts +75 -0
- package/lib/detail-page/insert-shape.ts +49 -0
- package/lib/detail-page/korean-dictionary.ts +187 -0
- package/lib/detail-page/layer-move.ts +257 -0
- package/lib/detail-page/new-product-draft-store.ts +105 -0
- package/lib/detail-page/new-product-draft.ts +283 -0
- package/lib/detail-page/personal-images-refresh.ts +30 -0
- package/lib/detail-page/product-source.ts +587 -0
- package/lib/detail-page/qr-code.ts +214 -0
- package/lib/detail-page/reference-image.ts +103 -0
- package/lib/detail-page/reference-upload.ts +181 -0
- package/lib/detail-page/replace-page.ts +35 -0
- package/lib/detail-page/replace-with-gif.ts +251 -0
- package/lib/detail-page/section-height.ts +131 -0
- package/lib/detail-page/section-reauthor-bus.ts +61 -0
- package/lib/detail-page/shape-to-image.ts +219 -0
- package/lib/detail-page/spec-group/clipboard.ts +35 -0
- package/lib/detail-page/spec-group/sync.ts +377 -0
- package/lib/detail-page/spec-group/text-metrics.ts +48 -0
- package/lib/detail-page/stock-photos.ts +88 -0
- package/lib/detail-page/svg-colors.ts +74 -0
- package/lib/detail-page/table/defaults.ts +344 -0
- package/lib/detail-page/table/edit.ts +312 -0
- package/lib/detail-page/table/harvest.ts +103 -0
- package/lib/detail-page/table/normalize.ts +125 -0
- package/lib/detail-page/table/paste.ts +49 -0
- package/lib/detail-page/table/preview.ts +66 -0
- package/lib/detail-page/table/render.ts +388 -0
- package/lib/detail-page/table/sync.ts +107 -0
- package/lib/detail-page/table/types.ts +102 -0
- package/lib/detail-page/text-gif-layout.ts +245 -0
- package/lib/detail-page/text-gif-payload.ts +67 -0
- package/lib/detail-page/text-presets.ts +230 -0
- package/lib/detail-page/use-fake-progress.ts +67 -0
- package/lib/detail-page/z-order.ts +82 -0
- package/lib/detail-page-canvas/bubble-path.ts +190 -0
- package/lib/detail-page-canvas/canvas-key.ts +22 -0
- package/lib/detail-page-canvas/clip-rect.ts +65 -0
- package/lib/detail-page-canvas/custom-props-adapter.ts +219 -0
- package/lib/detail-page-canvas/editor-fonts.ts +161 -0
- package/lib/detail-page-canvas/export/ai.ts +413 -0
- package/lib/detail-page-canvas/export/color.ts +35 -0
- package/lib/detail-page-canvas/export/document-model.ts +78 -0
- package/lib/detail-page-canvas/export/export-files.ts +290 -0
- package/lib/detail-page-canvas/export/font-subset.ts +264 -0
- package/lib/detail-page-canvas/export/gif-export.ts +310 -0
- package/lib/detail-page-canvas/export/gif-plan.ts +95 -0
- package/lib/detail-page-canvas/export/mp4-encode.ts +158 -0
- package/lib/detail-page-canvas/export/mp4-support.ts +22 -0
- package/lib/detail-page-canvas/export/pdf/geometry.ts +309 -0
- package/lib/detail-page-canvas/export/pdf/inline-svg.ts +399 -0
- package/lib/detail-page-canvas/export/pdf/resources.ts +443 -0
- package/lib/detail-page-canvas/export/pdf/text-ops.ts +81 -0
- package/lib/detail-page-canvas/export/pdf/writer.ts +109 -0
- package/lib/detail-page-canvas/export/psd.ts +354 -0
- package/lib/detail-page-canvas/export/raster.ts +255 -0
- package/lib/detail-page-canvas/export/svg.ts +479 -0
- package/lib/detail-page-canvas/export/text-layout.ts +142 -0
- package/lib/detail-page-canvas/font-catalog.ts +177 -0
- package/lib/detail-page-canvas/font-tags.ts +47 -0
- package/lib/detail-page-canvas/gif-web-fonts.ts +90 -0
- package/lib/detail-page-canvas/konva-fallback.ts +477 -0
- package/lib/detail-page-canvas/text-background-plan.ts +124 -0
- package/lib/detail-page-canvas/text-highlight-bands.ts +127 -0
- package/lib/utils.ts +10 -0
- package/package.json +68 -0
- package/types/commerce.ts +171 -0
- package/types/detail-page-api.ts +200 -0
- package/types/detail-page-canvas.ts +20 -0
- package/types/fontkit.d.ts +42 -0
- package/types/gifenc.d.ts +51 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic Google Fonts loader for marketing editors (cardnews / templates).
|
|
3
|
+
*
|
|
4
|
+
* Loads font CSS via <link> tag on first call; subsequent calls with the same
|
|
5
|
+
* families are no-ops. Returns a promise that resolves when all requested
|
|
6
|
+
* font faces have finished loading.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
FONT_CATALOG,
|
|
11
|
+
LEVIOSA_KONVA_VERSION,
|
|
12
|
+
catalogFont,
|
|
13
|
+
fontLoadSampleForText,
|
|
14
|
+
resolveFontFamily,
|
|
15
|
+
type CatalogFont,
|
|
16
|
+
} from "@leviosa-ai/konva";
|
|
17
|
+
|
|
18
|
+
export type FontOption = CatalogFont;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The picker list. Derived from the konva catalog (fonts/catalog.json), which is the
|
|
22
|
+
* same file the frozen woff2 bundle is built from — so a font can never appear here
|
|
23
|
+
* without its bytes existing, and adding one means editing exactly one place.
|
|
24
|
+
*/
|
|
25
|
+
export const ALL_FONT_OPTIONS: FontOption[] = FONT_CATALOG;
|
|
26
|
+
|
|
27
|
+
const DEFAULT_FONT_WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900];
|
|
28
|
+
|
|
29
|
+
export function getSupportedFontWeights(fontFamily?: string | null): number[] {
|
|
30
|
+
const weights = catalogFont(fontFamily || "Pretendard")?.weights;
|
|
31
|
+
return weights?.length ? weights : DEFAULT_FONT_WEIGHTS;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getClosestSupportedFontWeight(
|
|
35
|
+
fontFamily: string | undefined | null,
|
|
36
|
+
fontWeight: unknown,
|
|
37
|
+
): string {
|
|
38
|
+
const supportedWeights = getSupportedFontWeights(fontFamily);
|
|
39
|
+
const requestedWeight =
|
|
40
|
+
fontWeight === "bold" ? 700
|
|
41
|
+
: fontWeight === "normal" ? 400
|
|
42
|
+
: Number(fontWeight);
|
|
43
|
+
const fallbackWeight = supportedWeights.includes(400) ? 400 : supportedWeights[0];
|
|
44
|
+
if (!Number.isFinite(requestedWeight)) return String(fallbackWeight);
|
|
45
|
+
const closestWeight = supportedWeights.reduce((closest, weight) => (
|
|
46
|
+
Math.abs(weight - requestedWeight) < Math.abs(closest - requestedWeight) ? weight : closest
|
|
47
|
+
), fallbackWeight);
|
|
48
|
+
return String(closestWeight);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Loader ───────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
const loadedFontFaces = new Set<string>();
|
|
54
|
+
const loadedFamilyWeights = new Set<string>();
|
|
55
|
+
const stylesheetPromises = new Map<string, Promise<void>>();
|
|
56
|
+
const FONT_LOAD_SAMPLE = "가나다라마바사아자차카타파하 ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789";
|
|
57
|
+
/**
|
|
58
|
+
* 자주 쓰는 한글. 구글의 한글 조각은 빈도순으로 묶여 있어서, 이 정도만 미리 받아두면
|
|
59
|
+
* 타이핑 중에 "방금 친 글자만 뒤늦게 폰트가 바뀌는" 일이 대부분 사라진다.
|
|
60
|
+
* 통짜(굵기당 ~750KB)를 받는 대신 조각 몇 개(수십 KB)로 때우는 타협.
|
|
61
|
+
*/
|
|
62
|
+
const COMMON_HANGUL_SAMPLE =
|
|
63
|
+
"가각간갈감갑값강같개객거건걸검것게겨결경계고곡곤골곱공과관광교구국군굴권귀그근글금급기긴길김까꼭끝" +
|
|
64
|
+
"나난날남내네녀년노논놀농높다단달담답당대댓더던데도독돈동되된두들등디따때또" +
|
|
65
|
+
"라락란람래러런럴럼렁레려력련령로록론료루류르른를름리린립" +
|
|
66
|
+
"마막만많말맛망매머먹면명모목몰못무문물미민밀바박반받발밤밥방배백버번벌범법베변별병보복본볼봄부북분불브비빛" +
|
|
67
|
+
"사산살삼상새색생서석선설섬성세소속손솔송수숙순술스승시식신실심십싶" +
|
|
68
|
+
"아악안않알암앞애야약양어억언얼업없에여역연열영예오옥온올와완왕외요용우운울원월위유육으은을음의이인일임입있" +
|
|
69
|
+
"자작잔잘잠장재저적전절점정제조족존종주준줄중즈지직진질집짓차착찬참창채책처천철첫청체초촌총최추축출춤충취치친칠침" +
|
|
70
|
+
"카칼커컴케코콘크큰클키타탁탄탈탐태택터턴테토톤통투트특틀티파판팔패퍼편평포표푸품풍프피필" +
|
|
71
|
+
"하학한할함합항해핵행향허현혈형혜호혹혼홀화확환활황회획효후훈휴흐흑희히힘";
|
|
72
|
+
/**
|
|
73
|
+
* The font stylesheets are cached at the CDN edge, but their filenames are family
|
|
74
|
+
* slugs — they do not change when the bundle does. A re-freeze rewrites the woff2
|
|
75
|
+
* filenames (they are hashes of the source URL), so an edge-cached stylesheet ends up
|
|
76
|
+
* pointing at slice files that no longer exist: every one 404s and the font fails to
|
|
77
|
+
* load. Stamping the bundle version into the URL makes each bundle its own cache key,
|
|
78
|
+
* so a deploy can never be shadowed by the previous bundle's stylesheet.
|
|
79
|
+
*/
|
|
80
|
+
function bundleUrl(path: string): string {
|
|
81
|
+
return `${path}?v=${LEVIOSA_KONVA_VERSION}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const LOCAL_FONT_CSS_URL = bundleUrl("/render-fonts/font-css.css");
|
|
85
|
+
|
|
86
|
+
export class FontLoadError extends Error {
|
|
87
|
+
constructor(message: string) {
|
|
88
|
+
super(message);
|
|
89
|
+
this.name = "FontLoadError";
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function slugifyFamily(family: string): string {
|
|
94
|
+
return family
|
|
95
|
+
.toLowerCase()
|
|
96
|
+
.trim()
|
|
97
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
98
|
+
.replace(/^-+|-+$/g, "");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const LOCAL_FONT_FAMILY_SLUGS = new Set(
|
|
102
|
+
ALL_FONT_OPTIONS.map((font) => slugifyFamily(font.family)),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
function ensureStylesheet(href: string): Promise<void> {
|
|
106
|
+
if (typeof document === "undefined") return Promise.resolve();
|
|
107
|
+
|
|
108
|
+
const existing = stylesheetPromises.get(href);
|
|
109
|
+
if (existing) return existing;
|
|
110
|
+
|
|
111
|
+
const linkElement = document.createElement("link");
|
|
112
|
+
linkElement.rel = "stylesheet";
|
|
113
|
+
linkElement.href = href;
|
|
114
|
+
const promise = new Promise<void>((resolve, reject) => {
|
|
115
|
+
linkElement.addEventListener("load", () => resolve(), { once: true });
|
|
116
|
+
linkElement.addEventListener("error", () => {
|
|
117
|
+
// 실패한 약속을 캐시에 남기면 이후 호출이 전부 같은 실패를 돌려받는다(재시도 불가).
|
|
118
|
+
stylesheetPromises.delete(href);
|
|
119
|
+
reject(new Error(`Font stylesheet failed to load: ${href}`));
|
|
120
|
+
}, { once: true });
|
|
121
|
+
});
|
|
122
|
+
stylesheetPromises.set(href, promise);
|
|
123
|
+
document.head.appendChild(linkElement);
|
|
124
|
+
return promise;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Installs the @font-face rules for the faces about to be drawn.
|
|
129
|
+
*
|
|
130
|
+
* Asks for the family+weight file rather than the whole family: a Korean family is
|
|
131
|
+
* ~90 unicode-range slices per weight, so the family file is nine times the CSS for
|
|
132
|
+
* the one weight a slide actually uses. Falls back family-wide, then bundle-wide.
|
|
133
|
+
*/
|
|
134
|
+
function ensureFontFaceStylesheets(
|
|
135
|
+
requests: Array<{ family: string; weight: string }>,
|
|
136
|
+
): Promise<void> {
|
|
137
|
+
const seen = new Set<string>();
|
|
138
|
+
const hrefs: string[] = [];
|
|
139
|
+
for (const { family, weight } of requests) {
|
|
140
|
+
const slug = slugifyFamily(family.trim());
|
|
141
|
+
if (!slug || !LOCAL_FONT_FAMILY_SLUGS.has(slug)) {
|
|
142
|
+
hrefs.push(LOCAL_FONT_CSS_URL);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const key = `${slug}\n${weight}`;
|
|
146
|
+
if (seen.has(key)) continue;
|
|
147
|
+
seen.add(key);
|
|
148
|
+
hrefs.push(bundleUrl(`/render-fonts/family-css/${slug}-${weight}.css`));
|
|
149
|
+
}
|
|
150
|
+
return Promise.all(
|
|
151
|
+
hrefs.map((href) =>
|
|
152
|
+
ensureStylesheet(href).catch(() => {
|
|
153
|
+
const slug = href.match(/family-css\/(.+)-\d+\.css\?/)?.[1];
|
|
154
|
+
return slug
|
|
155
|
+
? ensureStylesheet(bundleUrl(`/render-fonts/family-css/${slug}.css`)).catch(() =>
|
|
156
|
+
ensureStylesheet(LOCAL_FONT_CSS_URL),
|
|
157
|
+
)
|
|
158
|
+
: ensureStylesheet(LOCAL_FONT_CSS_URL);
|
|
159
|
+
}),
|
|
160
|
+
),
|
|
161
|
+
).then(() => {});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface FontFaceRequest {
|
|
165
|
+
family?: string | null;
|
|
166
|
+
weight?: string | number | null;
|
|
167
|
+
size?: string | number | null;
|
|
168
|
+
sample?: string | null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
interface NormalizedFontFaceRequest {
|
|
172
|
+
family: string;
|
|
173
|
+
weight: string;
|
|
174
|
+
size: number;
|
|
175
|
+
sample: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 한 번 실패하면 편집기는 검은 캔버스에 "폰트를 불러오지 못했습니다"만 띄운 채 멈춘다
|
|
180
|
+
* (새로고침 전까지 복구 없음). 실패는 대개 일시적(같은 시점에 이미지 여러 장을 동시에
|
|
181
|
+
* 받느라 CSS 요청이 밀리는 등)이라 한 번 더 시도한다.
|
|
182
|
+
* ponytail: 재시도 1회 고정. 계속 실패하면 진짜 문제라 그대로 에러를 낸다.
|
|
183
|
+
*/
|
|
184
|
+
export function loadFontFaces(requests: FontFaceRequest[]): Promise<void> {
|
|
185
|
+
return loadFontFacesOnce(requests).catch(() => loadFontFacesOnce(requests));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function loadFontFacesOnce(requests: FontFaceRequest[]): Promise<void> {
|
|
189
|
+
if (typeof document === "undefined") return Promise.resolve();
|
|
190
|
+
if (!document.fonts) return Promise.resolve();
|
|
191
|
+
|
|
192
|
+
const stylesheetRequests: Array<{ family: string; weight: string }> = [];
|
|
193
|
+
const pendingRequests: NormalizedFontFaceRequest[] = [];
|
|
194
|
+
for (const request of requests) {
|
|
195
|
+
// 블록에 저장된 값은 폰트명이 아니라 계열/형태 딱지("gothic/round")일 수 있다.
|
|
196
|
+
// 그리는 쪽과 같은 함수로 실제 패밀리를 정해야 "받은 폰트 ≠ 그리는 폰트"가 안 생긴다.
|
|
197
|
+
const family = resolveFontFamily(request.family);
|
|
198
|
+
// Snap to a weight the family actually ships, so the family+weight stylesheet
|
|
199
|
+
// we ask for is one that exists (a 404 would cost a round trip to discover).
|
|
200
|
+
const weight = getClosestSupportedFontWeight(family, request.weight ?? "400");
|
|
201
|
+
const size = Number(request.size || 16);
|
|
202
|
+
stylesheetRequests.push({ family, weight });
|
|
203
|
+
const rawSample = (request.sample || FONT_LOAD_SAMPLE).trim() || FONT_LOAD_SAMPLE;
|
|
204
|
+
const sample = fontLoadSampleForText(family, rawSample, FONT_LOAD_SAMPLE);
|
|
205
|
+
const key = `${family}\n${weight}\n${size}\n${sample}`;
|
|
206
|
+
if (loadedFontFaces.has(key)) continue;
|
|
207
|
+
pendingRequests.push({ family, weight, size, sample });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (pendingRequests.length === 0) return document.fonts.ready.then(() => {});
|
|
211
|
+
return ensureFontFaceStylesheets(stylesheetRequests)
|
|
212
|
+
.then(() => Promise.all(
|
|
213
|
+
pendingRequests.map(async (request) => {
|
|
214
|
+
const descriptor = `${request.weight} ${request.size}px "${request.family}"`;
|
|
215
|
+
const faces = await document.fonts.load(descriptor, request.sample);
|
|
216
|
+
if (faces.length === 0) {
|
|
217
|
+
throw new FontLoadError(`No bundled font face matched: ${descriptor}`);
|
|
218
|
+
}
|
|
219
|
+
const unloadedFace = faces.find((face) => face.status !== "loaded");
|
|
220
|
+
if (unloadedFace) {
|
|
221
|
+
throw new FontLoadError(`Font face failed to load: ${descriptor}`);
|
|
222
|
+
}
|
|
223
|
+
if (!document.fonts.check(descriptor, request.sample)) {
|
|
224
|
+
throw new FontLoadError(`Font face is not ready for rendering: ${descriptor}`);
|
|
225
|
+
}
|
|
226
|
+
}),
|
|
227
|
+
))
|
|
228
|
+
.then(() => document.fonts.ready)
|
|
229
|
+
.then(() => {
|
|
230
|
+
for (const request of pendingRequests) {
|
|
231
|
+
loadedFontFaces.add(`${request.family}\n${request.weight}\n${request.size}\n${request.sample}`);
|
|
232
|
+
loadedFamilyWeights.add(`${request.family}\n${request.weight}`);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 이 패밀리+굵기를 이미 한 번이라도 그려본 적이 있는지.
|
|
239
|
+
* 캔버스가 "지금 이 폰트로 그리면 대체 폰트가 아니라 진짜 폰트가 나온다"를 판단하는 데 쓴다.
|
|
240
|
+
* 글자 단위 조각까지 보장하지는 않는다 — 그건 COMMON_HANGUL_SAMPLE 예열이 덮는다.
|
|
241
|
+
*/
|
|
242
|
+
export function isFontFamilyWeightLoaded(family: string, weight: string): boolean {
|
|
243
|
+
return loadedFamilyWeights.has(`${family.trim()}\n${weight}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 화면에 적용된 폰트를 다 받은 뒤에 뒤에서 조용히 받아두는 것들.
|
|
248
|
+
* - 쓰고 있는 패밀리+굵기의 자주 쓰는 한글 (타이핑 중 튐 방지)
|
|
249
|
+
* - 같은 패밀리의 다른 굵기 (굵게 토글이 즉시 먹히게), 샘플은 지금 화면의 글자
|
|
250
|
+
* 실패는 무시하고, 보이는 폰트 요청과 대역폭을 다투지 않게 한 건씩 차례로 받는다.
|
|
251
|
+
*/
|
|
252
|
+
export function prefetchFontVariants(requests: FontFaceRequest[]): void {
|
|
253
|
+
if (typeof document === "undefined" || !document.fonts) return;
|
|
254
|
+
|
|
255
|
+
const samplesByFamily = new Map<string, string>();
|
|
256
|
+
const usedFaces = new Set<string>();
|
|
257
|
+
for (const request of requests) {
|
|
258
|
+
const family = resolveFontFamily(request.family);
|
|
259
|
+
const weight = getClosestSupportedFontWeight(family, request.weight ?? "400");
|
|
260
|
+
usedFaces.add(`${family}\n${weight}`);
|
|
261
|
+
const sample = (request.sample || "").trim();
|
|
262
|
+
if (sample) samplesByFamily.set(family, `${samplesByFamily.get(family) ?? ""}${sample}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const jobs: FontFaceRequest[] = [];
|
|
266
|
+
for (const face of usedFaces) {
|
|
267
|
+
const [family, weight] = face.split("\n");
|
|
268
|
+
jobs.push({ family, weight, sample: COMMON_HANGUL_SAMPLE });
|
|
269
|
+
for (const other of getSupportedFontWeights(family)) {
|
|
270
|
+
if (String(other) === weight) continue;
|
|
271
|
+
jobs.push({ family, weight: String(other), sample: samplesByFamily.get(family) || FONT_LOAD_SAMPLE });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ponytail: 순차 1건씩. 병렬로 풀면 다음 슬라이드 폰트 요청을 밀어낸다.
|
|
276
|
+
void jobs.reduce(
|
|
277
|
+
(chain, job) => chain.then(() => loadFontFaces([job]).catch(() => {})),
|
|
278
|
+
Promise.resolve(),
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Keeps the historical API name for editor/font-picker callers.
|
|
284
|
+
* This now only installs the local font stylesheet; actual canvas rendering
|
|
285
|
+
* loads the font faces it uses through `loadFontFaces`.
|
|
286
|
+
*/
|
|
287
|
+
export function loadAllGoogleFonts(): Promise<void> {
|
|
288
|
+
return ensureStylesheet(LOCAL_FONT_CSS_URL).catch(() => {});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function loadDefaultCardnewsFonts(): Promise<void> {
|
|
292
|
+
return loadFontFaces([
|
|
293
|
+
{ family: "Pretendard", weight: "100", sample: FONT_LOAD_SAMPLE },
|
|
294
|
+
{ family: "Pretendard", weight: "200", sample: FONT_LOAD_SAMPLE },
|
|
295
|
+
{ family: "Pretendard", weight: "300", sample: FONT_LOAD_SAMPLE },
|
|
296
|
+
{ family: "Pretendard", weight: "400", sample: FONT_LOAD_SAMPLE },
|
|
297
|
+
{ family: "Pretendard", weight: "500", sample: FONT_LOAD_SAMPLE },
|
|
298
|
+
{ family: "Pretendard", weight: "600", sample: FONT_LOAD_SAMPLE },
|
|
299
|
+
{ family: "Pretendard", weight: "700", sample: FONT_LOAD_SAMPLE },
|
|
300
|
+
{ family: "Pretendard", weight: "800", sample: FONT_LOAD_SAMPLE },
|
|
301
|
+
{ family: "Pretendard", weight: "900", sample: FONT_LOAD_SAMPLE },
|
|
302
|
+
{ family: "Noto Sans KR", weight: "400", sample: FONT_LOAD_SAMPLE },
|
|
303
|
+
{ family: "Noto Sans KR", weight: "700", sample: FONT_LOAD_SAMPLE },
|
|
304
|
+
{ family: "Noto Sans KR", weight: "900", sample: FONT_LOAD_SAMPLE },
|
|
305
|
+
]);
|
|
306
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte-level animation detection. No dependencies — the upload panel needs the
|
|
3
|
+
* answer without pulling in the heavy decoders.
|
|
4
|
+
*
|
|
5
|
+
* Why bytes: a ``.gif`` suffix used to be proof of animation, but the default
|
|
6
|
+
* output format is animated WebP now and **``.webp`` is shared by still and
|
|
7
|
+
* animated files**. Stills already use it widely (references, covers), so a
|
|
8
|
+
* suffix or MIME check would tag half the library as animated. The RIFF
|
|
9
|
+
* ``ANIM`` chunk is the only real evidence.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Container format from the leading bytes, or null if it is neither. */
|
|
13
|
+
export function sniffAnimationType(buffer: ArrayBuffer): "gif" | "webp" | null {
|
|
14
|
+
const head = new Uint8Array(buffer, 0, Math.min(12, buffer.byteLength));
|
|
15
|
+
// "GIF"
|
|
16
|
+
if (head.length >= 4 && head[0] === 0x47 && head[1] === 0x49 && head[2] === 0x46) {
|
|
17
|
+
return "gif";
|
|
18
|
+
}
|
|
19
|
+
// "RIFF" .... "WEBP"
|
|
20
|
+
if (
|
|
21
|
+
head.length >= 12 &&
|
|
22
|
+
head[0] === 0x52 &&
|
|
23
|
+
head[1] === 0x49 &&
|
|
24
|
+
head[2] === 0x46 &&
|
|
25
|
+
head[3] === 0x46 &&
|
|
26
|
+
head[8] === 0x57 &&
|
|
27
|
+
head[9] === 0x45 &&
|
|
28
|
+
head[10] === 0x42 &&
|
|
29
|
+
head[11] === 0x50
|
|
30
|
+
) {
|
|
31
|
+
return "webp";
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether a RIFF/WEBP buffer carries an ``ANIM`` chunk.
|
|
38
|
+
*
|
|
39
|
+
* RIFF is a flat list of ``fourcc(4) + size(4, little endian) + payload``, with
|
|
40
|
+
* odd payloads padded to even. Animated files put ``ANIM`` right after ``VP8X``,
|
|
41
|
+
* but we walk rather than assume an order — and always advance at least 8 bytes
|
|
42
|
+
* so a truncated file cannot spin forever.
|
|
43
|
+
*/
|
|
44
|
+
export function isAnimatedWebp(buffer: ArrayBuffer): boolean {
|
|
45
|
+
if (buffer.byteLength < 16) return false;
|
|
46
|
+
if (sniffAnimationType(buffer) !== "webp") return false;
|
|
47
|
+
const view = new DataView(buffer);
|
|
48
|
+
let offset = 12;
|
|
49
|
+
while (offset + 8 <= buffer.byteLength) {
|
|
50
|
+
const fourcc =
|
|
51
|
+
String.fromCharCode(view.getUint8(offset)) +
|
|
52
|
+
String.fromCharCode(view.getUint8(offset + 1)) +
|
|
53
|
+
String.fromCharCode(view.getUint8(offset + 2)) +
|
|
54
|
+
String.fromCharCode(view.getUint8(offset + 3));
|
|
55
|
+
if (fourcc === "ANIM") return true;
|
|
56
|
+
const size = view.getUint32(offset + 4, true);
|
|
57
|
+
offset += 8 + size + (size % 2);
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether a GIF buffer holds more than one image descriptor (0x2C).
|
|
64
|
+
*
|
|
65
|
+
* Extension blocks can contain a 0x2C byte too, so this is an approximation —
|
|
66
|
+
* but it only has to answer "more than one frame?", and a false positive just
|
|
67
|
+
* animates a still, which renders as its first frame anyway.
|
|
68
|
+
*/
|
|
69
|
+
export function isAnimatedGif(buffer: ArrayBuffer): boolean {
|
|
70
|
+
if (sniffAnimationType(buffer) !== "gif") return false;
|
|
71
|
+
const bytes = new Uint8Array(buffer);
|
|
72
|
+
let count = 0;
|
|
73
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
74
|
+
if (bytes[i] === 0x2c && ++count > 1) return true;
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether these bytes are an animation we can play on the canvas. */
|
|
80
|
+
export function isAnimatedBuffer(buffer: ArrayBuffer): boolean {
|
|
81
|
+
return isAnimatedWebp(buffer) || isAnimatedGif(buffer);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether an uploaded file is an animation.
|
|
86
|
+
*
|
|
87
|
+
* Only the head is read — enough to reach the ``ANIM`` chunk of a WebP without
|
|
88
|
+
* pulling a large file into memory. GIFs need the whole buffer because the
|
|
89
|
+
* second image descriptor can sit anywhere.
|
|
90
|
+
*/
|
|
91
|
+
export async function isAnimatedFile(file: File): Promise<boolean> {
|
|
92
|
+
try {
|
|
93
|
+
const head = await file.slice(0, 4096).arrayBuffer();
|
|
94
|
+
const kind = sniffAnimationType(head);
|
|
95
|
+
if (kind === "webp") return isAnimatedWebp(head);
|
|
96
|
+
if (kind === "gif") return isAnimatedGif(await file.arrayBuffer());
|
|
97
|
+
return false;
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 브랜드 자산을 **바이트로 읽을 때** 쓸 주소로 바꾼다.
|
|
3
|
+
*
|
|
4
|
+
* 문서에 박히는 브랜드 자산 주소(``/api/v1/brands/assets/file/{id}?sig=``)는 그 순간의
|
|
5
|
+
* presigned S3 URL 로 302 리다이렉트한다. 리다이렉트가 **교차 출처**로 나가는 순간
|
|
6
|
+
* 브라우저는 다음 요청의 Origin 을 ``null`` 로 바꿔 보내고, 버킷의 CORS 는 우리 도메인만
|
|
7
|
+
* 허용하므로(와일드카드가 아니다) 그 요청은 거부된다.
|
|
8
|
+
*
|
|
9
|
+
* 그래서 이 주소는 **CORS 를 켜고 읽는 모든 소비자에게 막혀 있다.** 실제 자산으로 재면
|
|
10
|
+
* 이렇다:
|
|
11
|
+
*
|
|
12
|
+
* | 읽는 방법 | 결과 |
|
|
13
|
+
* |---|---|
|
|
14
|
+
* | 302 경로 + ``crossOrigin`` | 막힘 |
|
|
15
|
+
* | 302 경로, CORS 없이 | 뜬다 |
|
|
16
|
+
* | ``raw=1`` + ``crossOrigin`` | 뜬다 |
|
|
17
|
+
*
|
|
18
|
+
* 처음엔 디코더(``fetch``)만 막힌 줄 알았지만, 캔버스는 내보내기 때문에 이미지를 항상
|
|
19
|
+
* ``crossOrigin`` 으로 읽는다 — 그래서 편집기 이미지 자체가 안 뜨고 점선 빈 상자로 남는다.
|
|
20
|
+
* 백엔드의 ``raw=1``(리다이렉트 없이 바이트) 로 돌리면 동일 출처 응답이라 둘 다 풀린다.
|
|
21
|
+
*
|
|
22
|
+
* 다른 주소(S3 presigned 직결·data URI·정적 파일)는 손대지 않는다.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const BRAND_ASSET_FILE_PATH = "/api/v1/brands/assets/file/";
|
|
26
|
+
|
|
27
|
+
export function assetBytesUrl(src: string): string {
|
|
28
|
+
const url = String(src ?? "");
|
|
29
|
+
if (!url) return url;
|
|
30
|
+
// 절대 주소로 와도 경로만 보면 된다(같은 오리진으로 서빙되므로).
|
|
31
|
+
const path = url.split("?")[0];
|
|
32
|
+
if (!path.includes(BRAND_ASSET_FILE_PATH)) return url;
|
|
33
|
+
if (/[?&]raw=/.test(url)) return url;
|
|
34
|
+
return `${url}${url.includes("?") ? "&" : "?"}raw=1`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type ElementLike = {
|
|
38
|
+
src?: unknown;
|
|
39
|
+
clipSrc?: unknown;
|
|
40
|
+
maskSrc?: unknown;
|
|
41
|
+
children?: unknown;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const SRC_KEYS = ["src", "clipSrc", "maskSrc"] as const;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 이미 저장된 문서 안의 자산 주소를 바이트 경로로 옮긴다(제자리 수정, 멱등).
|
|
48
|
+
*
|
|
49
|
+
* 새로 꽂는 것만 고치면 **어제까지 저장해 둔 문서는 계속 빈 상자로 열린다.** 열 때 한 번
|
|
50
|
+
* 훑는 편이 문서를 일괄 마이그레이션하는 것보다 안전하다 — 되돌릴 것이 없고, 브랜드 자산
|
|
51
|
+
* 경로가 아닌 주소는 그대로 지나간다.
|
|
52
|
+
*/
|
|
53
|
+
export function normalizeDocumentAssetSrcs<T>(doc: T): T {
|
|
54
|
+
const walk = (nodes: unknown): void => {
|
|
55
|
+
if (!Array.isArray(nodes)) return;
|
|
56
|
+
for (const node of nodes) {
|
|
57
|
+
if (!node || typeof node !== "object") continue;
|
|
58
|
+
const el = node as ElementLike;
|
|
59
|
+
for (const key of SRC_KEYS) {
|
|
60
|
+
if (typeof el[key] === "string") el[key] = assetBytesUrl(el[key] as string);
|
|
61
|
+
}
|
|
62
|
+
walk(el.children);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const pages = (doc as { pages?: unknown })?.pages;
|
|
66
|
+
if (Array.isArray(pages)) {
|
|
67
|
+
for (const page of pages) walk((page as { children?: unknown })?.children);
|
|
68
|
+
}
|
|
69
|
+
return doc;
|
|
70
|
+
}
|