@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,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 말풍선 = 몸통 + 꼬리가 이어진 **하나의 닫힌 path**.
|
|
3
|
+
*
|
|
4
|
+
* 왜 하나의 path인가
|
|
5
|
+
* ------------------
|
|
6
|
+
* 꼬리를 별도 도형으로 얹고 몸통의 테두리를 fill로 "덮는" 방식은 반드시 깨진다.
|
|
7
|
+
* 라운드·스트로크·안티에일리어싱이 조금만 어긋나도 이음매가 드러난다. 몸통과 꼬리를
|
|
8
|
+
* 한 path로 뽑으면 fill/stroke가 그냥 이어지므로 덮을 테두리도 죽일 라운드도 없다.
|
|
9
|
+
*
|
|
10
|
+
* 꼬리는 각도가 아니라 **끝점(tip)** 으로 잡는다
|
|
11
|
+
* ---------------------------------------------
|
|
12
|
+
* tip을 자유 좌표로 두고, 둘레에서 tip에 가장 가까운 지점을 찾아 그 자리에 꼬리를
|
|
13
|
+
* 끼워 넣는다. tip을 360도 어디로 끌어도 꼬리는 항상 몸통에 붙어 있고, 모서리에
|
|
14
|
+
* 걸치면 두 변에 걸친 부리가 된다 — Canva/미리캔버스의 꼬리 드래그가 이 방식이다.
|
|
15
|
+
*
|
|
16
|
+
* 이 파일은 디컴포저 쪽 ``bubble_path.py``의 쌍둥이다. 두 구현이 같은 파라미터
|
|
17
|
+
* (``custom.bubble``)로 같은 path를 내야 편집기 렌더가 원본 문서와 어긋나지 않는다.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type Pt = [number, number];
|
|
21
|
+
|
|
22
|
+
export type BubbleParams = {
|
|
23
|
+
/** 몸통 크기 (꼬리·스트로크 여백 pad는 제외한 순수 몸통). */
|
|
24
|
+
w: number;
|
|
25
|
+
h: number;
|
|
26
|
+
/** 몸통 모서리 라운드. */
|
|
27
|
+
r: number;
|
|
28
|
+
/** 꼬리·스트로크가 삐져나갈 여백. SVG 좌표계는 (-pad,-pad)에서 시작한다. */
|
|
29
|
+
pad: number;
|
|
30
|
+
fill: string;
|
|
31
|
+
stroke?: string | null;
|
|
32
|
+
strokeWidth?: number;
|
|
33
|
+
/** 꼬리 끝점(몸통 로컬 좌표). null이거나 몸통 안이면 꼬리 없음. */
|
|
34
|
+
tip?: Pt | null;
|
|
35
|
+
/** 꼬리 밑변의 둘레 위 길이 [선행, 후행]. 비대칭이어야 부리처럼 보인다. */
|
|
36
|
+
base?: [number, number];
|
|
37
|
+
/** 꼬리 후행 변을 몸통 쪽으로 판 깊이. 0이면 모서리를 비스듬히 깎은 모양이 된다. */
|
|
38
|
+
notch?: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const dist = (a: Pt, b: Pt) => Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
42
|
+
|
|
43
|
+
/** 둥근 사각형 둘레를 촘촘한 폴리라인으로 샘플링(시계방향). */
|
|
44
|
+
function perimeter(w: number, h: number, r0: number, step = 1.5): Pt[] {
|
|
45
|
+
const r = Math.max(0, Math.min(r0, w / 2, h / 2));
|
|
46
|
+
const pts: Pt[] = [];
|
|
47
|
+
const line = (a: Pt, b: Pt) => {
|
|
48
|
+
const n = Math.max(1, Math.round(dist(a, b) / step));
|
|
49
|
+
for (let i = 0; i < n; i++) {
|
|
50
|
+
const t = i / n; // 끝점은 다음 구간이 이어받는다(중복 방지)
|
|
51
|
+
pts.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const arc = (cx: number, cy: number, a0: number, a1: number) => {
|
|
55
|
+
const n = Math.max(2, Math.round((Math.abs(a1 - a0) * r) / step));
|
|
56
|
+
for (let i = 0; i < n; i++) {
|
|
57
|
+
const t = a0 + ((a1 - a0) * i) / n;
|
|
58
|
+
pts.push([cx + r * Math.cos(t), cy + r * Math.sin(t)]);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const P = Math.PI;
|
|
62
|
+
line([r, 0], [w - r, 0]);
|
|
63
|
+
arc(w - r, r, -P / 2, 0);
|
|
64
|
+
line([w, r], [w, h - r]);
|
|
65
|
+
arc(w - r, h - r, 0, P / 2);
|
|
66
|
+
line([w - r, h], [r, h]);
|
|
67
|
+
arc(r, h - r, P / 2, P);
|
|
68
|
+
line([0, h - r], [0, r]);
|
|
69
|
+
arc(r, r, P, (3 * P) / 2);
|
|
70
|
+
return pts;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** tip이 몸통 안이면 꼬리를 그리지 않는다(끝점을 안으로 끌면 꼬리가 사라지는 UX). */
|
|
74
|
+
export function insideBody(w: number, h: number, r: number, p: Pt): boolean {
|
|
75
|
+
const [x, y] = p;
|
|
76
|
+
if (x < 0 || x > w || y < 0 || y > h) return false;
|
|
77
|
+
const cornerX = x < r || x > w - r;
|
|
78
|
+
const cornerY = y < r || y > h - r;
|
|
79
|
+
if (!cornerX || !cornerY) return true;
|
|
80
|
+
const cx = x < r ? r : w - r;
|
|
81
|
+
const cy = y < r ? r : h - r;
|
|
82
|
+
return dist([x, y], [cx, cy]) <= r;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function bubblePathD(p: BubbleParams): string {
|
|
86
|
+
const { w, h, r } = p;
|
|
87
|
+
const pts = perimeter(w, h, r);
|
|
88
|
+
const tip = p.tip ?? null;
|
|
89
|
+
const d = (list: Pt[]) =>
|
|
90
|
+
list
|
|
91
|
+
.map(([x, y], i) => `${i === 0 ? "M" : "L"}${x.toFixed(1)} ${y.toFixed(1)}`)
|
|
92
|
+
.join(" ") + " Z";
|
|
93
|
+
|
|
94
|
+
if (!tip || insideBody(w, h, r, tip)) return d(pts);
|
|
95
|
+
|
|
96
|
+
const n = pts.length;
|
|
97
|
+
const s: number[] = [0];
|
|
98
|
+
for (let i = 1; i < n; i++) s.push(s[i - 1] + dist(pts[i - 1], pts[i]));
|
|
99
|
+
const total = s[n - 1] + dist(pts[n - 1], pts[0]);
|
|
100
|
+
|
|
101
|
+
let anchor = 0;
|
|
102
|
+
let best = Infinity;
|
|
103
|
+
for (let i = 0; i < n; i++) {
|
|
104
|
+
const dd = dist(pts[i], tip);
|
|
105
|
+
if (dd < best) {
|
|
106
|
+
best = dd;
|
|
107
|
+
anchor = i;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 앵커에서 둘레를 따라 dist만큼 떨어진 점의 인덱스(래핑). s는 단조증가 → 이분탐색. */
|
|
112
|
+
const at = (delta: number): number => {
|
|
113
|
+
const target = ((((s[anchor] + delta) % total) + total) % total) as number;
|
|
114
|
+
let lo = 0;
|
|
115
|
+
let hi = n - 1;
|
|
116
|
+
while (lo < hi) {
|
|
117
|
+
const mid = (lo + hi) >> 1;
|
|
118
|
+
if (s[mid] < target) lo = mid + 1;
|
|
119
|
+
else hi = mid;
|
|
120
|
+
}
|
|
121
|
+
return lo % n;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const [before, after] = p.base ?? [34, 34];
|
|
125
|
+
const a = at(-Math.min(before, total / 4));
|
|
126
|
+
const b = at(Math.min(after, total / 4));
|
|
127
|
+
|
|
128
|
+
// b에서 시계방향으로 돌아 a까지가 몸통. a..b 구간을 꼬리가 대체한다.
|
|
129
|
+
const body: Pt[] = [];
|
|
130
|
+
for (let k = b; ; k = (k + 1) % n) {
|
|
131
|
+
body.push(pts[k]);
|
|
132
|
+
if (k === a) break;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const tail: Pt[] = [tip];
|
|
136
|
+
const notch = p.notch ?? 0;
|
|
137
|
+
if (notch > 0) {
|
|
138
|
+
// tip → b 변의 중간을 몸통 중심 쪽으로 당겨 오목하게 만든다.
|
|
139
|
+
const mx = (tip[0] + pts[b][0]) / 2;
|
|
140
|
+
const my = (tip[1] + pts[b][1]) / 2;
|
|
141
|
+
const vx = w / 2 - mx;
|
|
142
|
+
const vy = h / 2 - my;
|
|
143
|
+
const len = Math.hypot(vx, vy) || 1;
|
|
144
|
+
tail.push([mx + (vx / len) * notch, my + (vy / len) * notch]);
|
|
145
|
+
}
|
|
146
|
+
return d([...body, ...tail]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function bubbleSvgMarkup(p: BubbleParams): string {
|
|
150
|
+
const { w, h, pad } = p;
|
|
151
|
+
const st = p.stroke
|
|
152
|
+
? ` stroke="${p.stroke}" stroke-width="${p.strokeWidth ?? 2}" stroke-linejoin="round"`
|
|
153
|
+
: "";
|
|
154
|
+
return (
|
|
155
|
+
`<svg viewBox="${-pad} ${-pad} ${w + 2 * pad} ${h + 2 * pad}" ` +
|
|
156
|
+
`width="${w + 2 * pad}" height="${h + 2 * pad}" xmlns="http://www.w3.org/2000/svg">` +
|
|
157
|
+
`<path d="${bubblePathD(p)}" fill="${p.fill}"${st}/></svg>`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 스톡 편집기의 svg 요소는 ``src``로 그린다 — 마크업을 data URL로 굽는다. */
|
|
162
|
+
export function bubbleSvgDataUrl(p: BubbleParams): string {
|
|
163
|
+
return `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(bubbleSvgMarkup(p))))}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** ``custom.bubble``을 안전하게 읽는다(형식이 어긋나면 null → 일반 svg로 취급). */
|
|
167
|
+
export function readBubbleParams(custom: unknown): BubbleParams | null {
|
|
168
|
+
if (!custom || typeof custom !== "object") return null;
|
|
169
|
+
const b = (custom as Record<string, unknown>).bubble;
|
|
170
|
+
if (!b || typeof b !== "object") return null;
|
|
171
|
+
const o = b as Record<string, unknown>;
|
|
172
|
+
const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
|
173
|
+
const w = num(o.w);
|
|
174
|
+
const h = num(o.h);
|
|
175
|
+
if (w === null || h === null || w <= 0 || h <= 0) return null;
|
|
176
|
+
const tip = Array.isArray(o.tip) && o.tip.length === 2 ? (o.tip.map(Number) as Pt) : null;
|
|
177
|
+
const base = Array.isArray(o.base) && o.base.length === 2 ? (o.base.map(Number) as [number, number]) : undefined;
|
|
178
|
+
return {
|
|
179
|
+
w,
|
|
180
|
+
h,
|
|
181
|
+
r: num(o.r) ?? 0,
|
|
182
|
+
pad: num(o.pad) ?? 0,
|
|
183
|
+
fill: typeof o.fill === "string" ? o.fill : "#fff",
|
|
184
|
+
stroke: typeof o.stroke === "string" ? o.stroke : null,
|
|
185
|
+
strokeWidth: num(o.strokeWidth) ?? 2,
|
|
186
|
+
tip: tip && tip.every(Number.isFinite) ? tip : null,
|
|
187
|
+
base,
|
|
188
|
+
notch: num(o.notch) ?? 0,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 엔진 사용 키를 한 자리에서 넣는다.
|
|
3
|
+
*
|
|
4
|
+
* 키는 **보호 수단이 아니라 계량기**다(패키지의 `license.ts` 머리말). 우리 도메인과
|
|
5
|
+
* localhost에서는 엔진이 아예 안 묻고, 키가 없어도 편집기는 똑같이 돈다. 그래도 넣는
|
|
6
|
+
* 이유는 누가 얼마나 쓰는지 셀 자리를 지금부터 열어 두기 위해서다 — 나중에 외부에 팔
|
|
7
|
+
* 때 스위치를 새로 만들지 않아도 된다.
|
|
8
|
+
*
|
|
9
|
+
* `createCanvasStore`가 아니라 여기인 것도 그 문서의 결정이다. 스토어를 만들 때마다
|
|
10
|
+
* 키를 들고 다니면 호출부 전부가 키를 알아야 한다.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { configureCanvas } from "@leviosa-ai/canvas";
|
|
14
|
+
|
|
15
|
+
let configured = false;
|
|
16
|
+
|
|
17
|
+
/** 편집기를 여는 자리에서 부른다. 두 번 불러도 안전하다. */
|
|
18
|
+
export function ensureCanvasKey(): void {
|
|
19
|
+
if (configured) return;
|
|
20
|
+
configured = true;
|
|
21
|
+
configureCanvas({ key: process.env.NEXT_PUBLIC_LEVIOSA_CANVAS_KEY });
|
|
22
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for clipping HTML-decomposed decoration to its owning card
|
|
3
|
+
* rectangle. Kept free of React/Canvas/Konva imports so both the native Konva
|
|
4
|
+
* preview and the Canvas SDK clip-aware shape component can reuse them, and so
|
|
5
|
+
* they stay trivially unit-testable.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type ClipRectShape = {
|
|
9
|
+
x: number;
|
|
10
|
+
y: number;
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
radius?: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** A minimal Konva.Context — only the path ops the clip needs. */
|
|
17
|
+
export type ClipContext = {
|
|
18
|
+
beginPath(): void;
|
|
19
|
+
moveTo(x: number, y: number): void;
|
|
20
|
+
arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): void;
|
|
21
|
+
closePath(): void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Trace a rounded rectangle path used as the Konva clip region. */
|
|
25
|
+
export function roundedRectPath(
|
|
26
|
+
ctx: ClipContext,
|
|
27
|
+
x: number,
|
|
28
|
+
y: number,
|
|
29
|
+
width: number,
|
|
30
|
+
height: number,
|
|
31
|
+
radius: number,
|
|
32
|
+
): void {
|
|
33
|
+
const r = Math.max(0, Math.min(radius, width / 2, height / 2));
|
|
34
|
+
ctx.beginPath();
|
|
35
|
+
ctx.moveTo(x + r, y);
|
|
36
|
+
ctx.arcTo(x + width, y, x + width, y + height, r);
|
|
37
|
+
ctx.arcTo(x + width, y + height, x, y + height, r);
|
|
38
|
+
ctx.arcTo(x, y + height, x, y, r);
|
|
39
|
+
ctx.arcTo(x, y, x + width, y, r);
|
|
40
|
+
ctx.closePath();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Read a usable ``clipToRect`` off a Canvas element's ``custom`` payload. */
|
|
44
|
+
export function readClipRect(element: unknown): ClipRectShape | null {
|
|
45
|
+
const custom =
|
|
46
|
+
element && typeof element === "object"
|
|
47
|
+
? (element as { custom?: unknown }).custom
|
|
48
|
+
: null;
|
|
49
|
+
const clip =
|
|
50
|
+
custom && typeof custom === "object"
|
|
51
|
+
? (custom as { clipToRect?: unknown }).clipToRect
|
|
52
|
+
: null;
|
|
53
|
+
if (!clip || typeof clip !== "object") return null;
|
|
54
|
+
const rect = clip as Record<string, unknown>;
|
|
55
|
+
const width = typeof rect.width === "number" ? rect.width : 0;
|
|
56
|
+
const height = typeof rect.height === "number" ? rect.height : 0;
|
|
57
|
+
if (!(width > 0) || !(height > 0)) return null;
|
|
58
|
+
return {
|
|
59
|
+
x: typeof rect.x === "number" ? rect.x : 0,
|
|
60
|
+
y: typeof rect.y === "number" ? rect.y : 0,
|
|
61
|
+
width,
|
|
62
|
+
height,
|
|
63
|
+
radius: typeof rect.radius === "number" ? rect.radius : 0,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 문서를 싣고 내릴 때 앱이 하는 손질 두 가지. **렌더러의 일이 아니다.**
|
|
3
|
+
*
|
|
4
|
+
* 이 파일은 원래 Canvas 어댑터였다 — SDK가 못 읽는 `custom.*` 장식(그라데이션·그림자)을
|
|
5
|
+
* 대표 단색으로 낮춰 주고, 사진 액자를 요소 상자를 옮겨 흉내 내고, 인라인 런 간격을
|
|
6
|
+
* 마운트된 Konva 노드로 다시 재던 층. SDK가 사라지면서 그 셋(`adaptLeviosaCustomProps`
|
|
7
|
+
* `applyCoverCropToImages` `applyRunReflow`)도 같이 사라졌다. 우리 렌더러는 원본 필드를
|
|
8
|
+
* 그대로 읽고(`leviosa-canvas/render/attrs.ts`), 액자는 그리는 자리에서 잡는다
|
|
9
|
+
* (`render/image-frame.ts`).
|
|
10
|
+
*
|
|
11
|
+
* 남은 둘은 엔진과 무관하게 **앱이 해야 하는 일**이다.
|
|
12
|
+
*
|
|
13
|
+
* * `applyTextLineFit` — 디컴포저가 몇 px 좁게 잰 텍스트 상자를 실제 글꼴로 다시 재
|
|
14
|
+
* 그만큼만 넓힌다. 글꼴이 다 온 뒤에 돌아야 한다.
|
|
15
|
+
* * `clearPlaceholderImageSrc` — 저장할 때 자리표시자 src 를 `""` 로 되돌린다.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
type UnknownRecord = Record<string, unknown>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Transparent 1x1 image used for image slots that have no source yet — a
|
|
22
|
+
* transparent pixel lets the decorative background behind the slot (e.g. the
|
|
23
|
+
* hero's radial gradient) show through, matching the decomposer output. The
|
|
24
|
+
* element stays a real ``image`` so it remains selectable/transformable and an
|
|
25
|
+
* AI-generated image drops in by simply replacing ``src``.
|
|
26
|
+
* ``clearPlaceholderImageSrc`` reverts it to ``""`` on save so the placeholder
|
|
27
|
+
* is never persisted.
|
|
28
|
+
*/
|
|
29
|
+
export const EMPTY_IMAGE_PLACEHOLDER_SRC =
|
|
30
|
+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
|
|
31
|
+
|
|
32
|
+
function asRecord(value: unknown): UnknownRecord {
|
|
33
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
34
|
+
? (value as UnknownRecord)
|
|
35
|
+
: {};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function asArray(value: unknown): unknown[] {
|
|
39
|
+
return Array.isArray(value) ? value : [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Pull the URL out of a CSS ``background-image`` value like
|
|
44
|
+
* ``url("/x/y.png")``. The decomposer stores each slot's *mockup* image there
|
|
45
|
+
* (``custom.placeholderBgImage``); a plain gradient (``linear-gradient(...)``)
|
|
46
|
+
* has no URL and returns null. Quotes are optional per the CSS grammar.
|
|
47
|
+
*/
|
|
48
|
+
function extractCssUrl(value: unknown): string | null {
|
|
49
|
+
if (typeof value !== "string") return null;
|
|
50
|
+
const match = value.match(/url\(\s*(['"]?)([^'")]+)\1\s*\)/i);
|
|
51
|
+
return match ? match[2].trim() || null : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the decomposer's ``lineHeight`` — a CSS px string ("36.1px"), a
|
|
56
|
+
* unitless ratio ("1.9" / 1.9), an absolute-px number (36.1), or absent — to a
|
|
57
|
+
* unitless ratio for the given font size. Falls back to 1.2 (the canvas
|
|
58
|
+
* default) when it cannot be parsed. A value < 4 is treated as an existing
|
|
59
|
+
* ratio; anything larger is an absolute pixel height divided back to a ratio.
|
|
60
|
+
*/
|
|
61
|
+
function resolveLineHeightRatio(raw: unknown, fontSize: number): number {
|
|
62
|
+
const fs = fontSize > 0 ? fontSize : 1;
|
|
63
|
+
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
|
|
64
|
+
return raw < 4 ? raw : raw / fs;
|
|
65
|
+
}
|
|
66
|
+
if (typeof raw === "string") {
|
|
67
|
+
const s = raw.trim();
|
|
68
|
+
const value = parseFloat(s);
|
|
69
|
+
if (Number.isFinite(value) && value > 0) {
|
|
70
|
+
return s.endsWith("px") || value >= 4 ? value / fs : value;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return 1.2;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Widest rendered line of a text element, in pixels, for the element's font.
|
|
78
|
+
* Returns 0 when no canvas is available (SSR / jsdom) so callers leave the box
|
|
79
|
+
* untouched. Over-estimates harmlessly when the font has not loaded yet — a
|
|
80
|
+
* wider box still left-aligns the same text, it just never wraps.
|
|
81
|
+
*/
|
|
82
|
+
function measureTextLineWidth(element: UnknownRecord): number {
|
|
83
|
+
if (typeof document === "undefined") return 0;
|
|
84
|
+
const ctx = document.createElement("canvas").getContext("2d");
|
|
85
|
+
if (!ctx) return 0;
|
|
86
|
+
const fontSize = Number(element.fontSize) || 0;
|
|
87
|
+
if (fontSize <= 0) return 0;
|
|
88
|
+
const weight = String(element.fontWeight ?? "normal");
|
|
89
|
+
const family = String(element.fontFamily ?? "sans-serif");
|
|
90
|
+
ctx.font = `${weight} ${fontSize}px "${family}"`;
|
|
91
|
+
// 캔버스가 letterSpacing 을 EM 비율로 그리는 것에 맞춘다
|
|
92
|
+
// (letterSpacing * fontSize px). The decomposer already sized the box WITH the
|
|
93
|
+
// source 자간, so measuring WITHOUT it over-estimates and widens the box
|
|
94
|
+
// needlessly — which, for centred colour-split fragments, shoves the text into
|
|
95
|
+
// its neighbour ("만족도" over "100%"). Apply the same spacing so ``needed``
|
|
96
|
+
// matches what actually renders.
|
|
97
|
+
const letterSpacingEm = Number(element.letterSpacing) || 0;
|
|
98
|
+
const ctxWithSpacing = ctx as CanvasRenderingContext2D & {
|
|
99
|
+
letterSpacing?: string;
|
|
100
|
+
};
|
|
101
|
+
if (letterSpacingEm && "letterSpacing" in ctxWithSpacing) {
|
|
102
|
+
ctxWithSpacing.letterSpacing = `${letterSpacingEm * fontSize}px`;
|
|
103
|
+
}
|
|
104
|
+
let widest = 0;
|
|
105
|
+
for (const line of String(element.text ?? "").split("\n")) {
|
|
106
|
+
widest = Math.max(widest, ctx.measureText(line).width);
|
|
107
|
+
}
|
|
108
|
+
// A touch of slack absorbs metric drift between measurement and render.
|
|
109
|
+
return widest > 0 ? Math.ceil(widest + fontSize * 0.15) : 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 문서를 훑어 자리표시자 image src 를 빈 값으로 되돌린다. */
|
|
113
|
+
export function clearPlaceholderImageSrc<T>(json: T): T {
|
|
114
|
+
const clone = JSON.parse(JSON.stringify(json ?? {})) as UnknownRecord;
|
|
115
|
+
const visit = (element: UnknownRecord) => {
|
|
116
|
+
if (element.type === "image") {
|
|
117
|
+
// Revert both the transparent placeholder and a promoted mockup image
|
|
118
|
+
// (older documents carry the slot's own placeholderBgImage as the preview
|
|
119
|
+
// src) so an unfilled slot never persists a src.
|
|
120
|
+
const mockup = extractCssUrl(asRecord(element.custom).placeholderBgImage);
|
|
121
|
+
if (
|
|
122
|
+
element.src === EMPTY_IMAGE_PLACEHOLDER_SRC ||
|
|
123
|
+
(mockup !== null && element.src === mockup)
|
|
124
|
+
) {
|
|
125
|
+
element.src = "";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const child of asArray(element.children)) visit(asRecord(child));
|
|
129
|
+
};
|
|
130
|
+
for (const page of asArray(clone.pages)) {
|
|
131
|
+
for (const child of asArray(asRecord(page).children)) visit(asRecord(child));
|
|
132
|
+
}
|
|
133
|
+
return clone as unknown as T;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
type StoreTextElement = {
|
|
137
|
+
type?: string;
|
|
138
|
+
text?: string;
|
|
139
|
+
x?: number;
|
|
140
|
+
width?: number;
|
|
141
|
+
height?: number;
|
|
142
|
+
fontSize?: number;
|
|
143
|
+
align?: string;
|
|
144
|
+
children?: unknown[];
|
|
145
|
+
set?: (props: Record<string, number>) => void;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Widen text boxes that wrap by a hair, measuring with the *loaded* webfont.
|
|
150
|
+
*
|
|
151
|
+
* The decomposer sizes each text box from getBoundingClientRect in its own
|
|
152
|
+
* headless Chromium, but the editor's self-hosted Pretendard has slightly
|
|
153
|
+
* different metrics — a line the decomposer measured at 254px renders at 256px,
|
|
154
|
+
* 2px past the box, so the canvas wraps an explicit line onto a second row (the
|
|
155
|
+
* chat bubbles pile up an extra line and spill out of the bubble). This must run
|
|
156
|
+
* against the live store *after* ``document.fonts.ready`` — measuring against the
|
|
157
|
+
* fallback face over-estimates past the drift cap and skips the fix entirely.
|
|
158
|
+
*
|
|
159
|
+
* Only a small overflow is corrected (drift, not a genuine wrap). Each
|
|
160
|
+
* decomposer text carries explicit ``\n`` at the source's own line breaks, so a
|
|
161
|
+
* widest-line overflow of a few px means the box is a touch too tight; a large
|
|
162
|
+
* overflow would mean the text is a flowing paragraph meant to wrap, which is
|
|
163
|
+
* left alone. Widening keeps the visual anchor: a centred box grows
|
|
164
|
+
* symmetrically, a right-aligned box leftward. Idempotent — a box already wide
|
|
165
|
+
* enough is untouched.
|
|
166
|
+
*/
|
|
167
|
+
export function applyTextLineFit(store: unknown): void {
|
|
168
|
+
const storeRecord = asRecord(store);
|
|
169
|
+
const visit = (element: StoreTextElement) => {
|
|
170
|
+
if (element.type === "text" && typeof element.set === "function") {
|
|
171
|
+
const width = Number(element.width) || 0;
|
|
172
|
+
const fontSize = Number(element.fontSize) || 0;
|
|
173
|
+
// A FLOWING paragraph — no explicit line breaks and a box taller than one
|
|
174
|
+
// line — is meant to WRAP to its width. measureTextLineWidth measures its
|
|
175
|
+
// whole text as a single line, so a SHORT paragraph that almost fits one
|
|
176
|
+
// line reports an overflow UNDER the cap below; widening it then collapses
|
|
177
|
+
// the intended two-line wrap into one line that runs off the section (the
|
|
178
|
+
// FAQ answers / warranty note overflowing). The cap alone can't tell this
|
|
179
|
+
// apart from a genuine hard-line that drifted a few px, so skip flowing
|
|
180
|
+
// paragraphs outright — only hard "\n" lines and genuinely single-line
|
|
181
|
+
// boxes get the sub-cap drift correction.
|
|
182
|
+
const text = String(element.text ?? "");
|
|
183
|
+
const height = Number(element.height) || 0;
|
|
184
|
+
const lineRatio = resolveLineHeightRatio(
|
|
185
|
+
(element as UnknownRecord).lineHeight,
|
|
186
|
+
fontSize,
|
|
187
|
+
);
|
|
188
|
+
const flowingWrap =
|
|
189
|
+
!text.includes("\n") &&
|
|
190
|
+
fontSize > 0 &&
|
|
191
|
+
height > fontSize * lineRatio * 1.6;
|
|
192
|
+
if (width > 0 && fontSize > 0 && !flowingWrap) {
|
|
193
|
+
const needed = measureTextLineWidth(element as UnknownRecord);
|
|
194
|
+
const grow = needed - width;
|
|
195
|
+
// Cap the correction so a genuine wrap-paragraph (stored as one long
|
|
196
|
+
// line) is never unwrapped into a page-overflowing single line.
|
|
197
|
+
const maxGrow = Math.max(fontSize * 1.5, width * 0.08);
|
|
198
|
+
if (grow > 0 && grow <= maxGrow) {
|
|
199
|
+
const align = String(element.align ?? "left");
|
|
200
|
+
const patch: Record<string, number> = { width: needed };
|
|
201
|
+
if (align === "center") {
|
|
202
|
+
patch.x = (Number(element.x) || 0) - grow / 2;
|
|
203
|
+
} else if (align === "right" || align === "end") {
|
|
204
|
+
patch.x = (Number(element.x) || 0) - grow;
|
|
205
|
+
}
|
|
206
|
+
element.set?.(patch);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
for (const child of asArray(element.children)) {
|
|
211
|
+
visit(asRecord(child) as StoreTextElement);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
for (const page of asArray(storeRecord.pages)) {
|
|
215
|
+
for (const child of asArray(asRecord(page).children)) {
|
|
216
|
+
visit(asRecord(child) as StoreTextElement);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One list for the detail-page font picker, over two very different sources.
|
|
3
|
+
*
|
|
4
|
+
* catalog — src/config/detail-page-fonts.json. Whole woff2 files fetched from a
|
|
5
|
+
* public CDN on demand, one file per (family, weight).
|
|
6
|
+
* bundle — the konva font catalog, frozen under /render-fonts. It contains both
|
|
7
|
+
* Google's unicode-range slices and licensed whole files, all served
|
|
8
|
+
* from our origin. These are the bytes the server-side renderer draws with.
|
|
9
|
+
*
|
|
10
|
+
* The editor already loaded both — the bundle families just had no way into the
|
|
11
|
+
* picker, so they were reachable only by a template naming them. This module is
|
|
12
|
+
* the join: same shape, same weight lookup, one loader.
|
|
13
|
+
*
|
|
14
|
+
* Caveat: `.ai`/PSD export embeds glyphs for catalog families only — `font-subset`
|
|
15
|
+
* needs one whole file per face, and a bundle family is ~90 unicode-range slices.
|
|
16
|
+
* A bundle family exports with Illustrator's fallback, exactly as it already did
|
|
17
|
+
* when a template named one; the picker just makes it reachable by hand too.
|
|
18
|
+
*
|
|
19
|
+
* Families are unique across the merged list. When both sources ship the same
|
|
20
|
+
* family (Paperozi, Presentation, Diphylleia), the catalog entry wins — it is the
|
|
21
|
+
* one `getDetailPageFont` already resolves first everywhere else, so preferring it
|
|
22
|
+
* keeps the picker honest about which bytes the canvas will actually draw.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { ALL_FONT_OPTIONS, loadFontFaces } from "../cardnews/font-loader";
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
DETAIL_PAGE_FONTS,
|
|
29
|
+
getDetailPageFontWeights,
|
|
30
|
+
loadDetailPageFont,
|
|
31
|
+
normalizeFontWeight,
|
|
32
|
+
type FontCatalogStore,
|
|
33
|
+
} from "./font-catalog";
|
|
34
|
+
import { bundleFontTags, toFontTags, type FontTag } from "./font-tags";
|
|
35
|
+
|
|
36
|
+
export type EditorFontSource = "catalog" | "bundle";
|
|
37
|
+
|
|
38
|
+
export type EditorFont = {
|
|
39
|
+
source: EditorFontSource;
|
|
40
|
+
id: string;
|
|
41
|
+
family: string;
|
|
42
|
+
label: string;
|
|
43
|
+
/** Searchable Latin name — the CDN catalog carries one, the bundle uses `family`. */
|
|
44
|
+
labelEn: string;
|
|
45
|
+
/** Static WebP rendered at build time; the picker never downloads font bytes. */
|
|
46
|
+
previewSrc: string;
|
|
47
|
+
/** 피커 칩이 거르는 분류. 카탈로그는 항목 안에, 번들은 사이드카에 적혀 있다. */
|
|
48
|
+
tags: FontTag[];
|
|
49
|
+
weights: number[];
|
|
50
|
+
/** No Hangul glyphs — applying it to Korean silently falls back to a system font. */
|
|
51
|
+
latinOnly?: boolean;
|
|
52
|
+
licenseName: string;
|
|
53
|
+
licenseUrl: string;
|
|
54
|
+
licenseNoteKey?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function fromCatalog(font: (typeof DETAIL_PAGE_FONTS)[number]): EditorFont {
|
|
58
|
+
return {
|
|
59
|
+
source: "catalog",
|
|
60
|
+
id: font.id,
|
|
61
|
+
family: font.family,
|
|
62
|
+
label: font.label,
|
|
63
|
+
labelEn: font.labelEn,
|
|
64
|
+
previewSrc: `/detail-font-previews/${font.id}.webp`,
|
|
65
|
+
tags: toFontTags(font.tags),
|
|
66
|
+
weights: getDetailPageFontWeights(font),
|
|
67
|
+
latinOnly: font.latinOnly,
|
|
68
|
+
licenseName: font.licenseName,
|
|
69
|
+
licenseUrl: font.licenseUrl,
|
|
70
|
+
licenseNoteKey: font.licenseNoteKey,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function fromBundle(font: (typeof ALL_FONT_OPTIONS)[number]): EditorFont {
|
|
75
|
+
return {
|
|
76
|
+
source: "bundle",
|
|
77
|
+
id: font.id,
|
|
78
|
+
family: font.family,
|
|
79
|
+
label: font.label,
|
|
80
|
+
labelEn: font.family,
|
|
81
|
+
previewSrc: `/cardnews-font-previews/${font.id}.webp`,
|
|
82
|
+
tags: bundleFontTags(font.id),
|
|
83
|
+
weights: [...font.weights].sort((a, b) => a - b),
|
|
84
|
+
licenseName: font.license.name,
|
|
85
|
+
licenseUrl: font.license.url,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const catalogFonts = DETAIL_PAGE_FONTS.map(fromCatalog);
|
|
90
|
+
const catalogFamilies = new Set(catalogFonts.map((font) => font.family));
|
|
91
|
+
|
|
92
|
+
export const EDITOR_CATALOG_FONTS: EditorFont[] = catalogFonts;
|
|
93
|
+
|
|
94
|
+
export const EDITOR_BUNDLE_FONTS: EditorFont[] = ALL_FONT_OPTIONS.filter(
|
|
95
|
+
(font) => !catalogFamilies.has(font.family),
|
|
96
|
+
).map(fromBundle);
|
|
97
|
+
|
|
98
|
+
export const EDITOR_FONTS: EditorFont[] = [
|
|
99
|
+
...EDITOR_CATALOG_FONTS,
|
|
100
|
+
...EDITOR_BUNDLE_FONTS,
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* AND, not OR — chips are for narrowing. "제목용" alone is still 20-odd fonts;
|
|
105
|
+
* "제목용 + 둥근" is the handful the user actually meant. An empty selection is
|
|
106
|
+
* the whole list, so the picker never has to special-case "no filter".
|
|
107
|
+
*/
|
|
108
|
+
export function filterFontsByTags(
|
|
109
|
+
fonts: EditorFont[],
|
|
110
|
+
tags: readonly FontTag[],
|
|
111
|
+
): EditorFont[] {
|
|
112
|
+
if (!tags.length) return fonts;
|
|
113
|
+
return fonts.filter((font) => tags.every((tag) => font.tags.includes(tag)));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const byFamily = new Map(EDITOR_FONTS.map((font) => [font.family, font] as const));
|
|
117
|
+
|
|
118
|
+
export function getEditorFont(family: string): EditorFont | undefined {
|
|
119
|
+
return byFamily.get(family);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function closestEditorFontWeight(
|
|
123
|
+
font: EditorFont,
|
|
124
|
+
requested: unknown,
|
|
125
|
+
): number {
|
|
126
|
+
const target = normalizeFontWeight(requested);
|
|
127
|
+
return font.weights.reduce((best, weight) =>
|
|
128
|
+
Math.abs(weight - target) < Math.abs(best - target) ? weight : best,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Registers the one face about to be drawn. Catalog fonts also get pushed into the
|
|
134
|
+
* Canvas store so the family survives a save/reload; bundle families are already
|
|
135
|
+
* declared by the frozen stylesheet, so installing the face is all they need.
|
|
136
|
+
*/
|
|
137
|
+
export async function loadEditorFont({
|
|
138
|
+
family,
|
|
139
|
+
weight,
|
|
140
|
+
sample,
|
|
141
|
+
store,
|
|
142
|
+
}: {
|
|
143
|
+
family: string;
|
|
144
|
+
weight: unknown;
|
|
145
|
+
sample?: string;
|
|
146
|
+
store?: FontCatalogStore;
|
|
147
|
+
}): Promise<void> {
|
|
148
|
+
const font = getEditorFont(family);
|
|
149
|
+
if (!font) return;
|
|
150
|
+
if (font.source === "catalog") {
|
|
151
|
+
await loadDetailPageFont({ family, weight, sample, store });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
await loadFontFaces([
|
|
155
|
+
{
|
|
156
|
+
family,
|
|
157
|
+
weight: String(closestEditorFontWeight(font, weight)),
|
|
158
|
+
sample: sample || undefined,
|
|
159
|
+
},
|
|
160
|
+
]);
|
|
161
|
+
}
|