@leviosa-ai/detail-page-editor 0.3.0 → 0.4.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/bubble-tail-overlay.tsx +1 -1
- package/components/detail-page/detail-page-konva-json-preview.tsx +3 -3
- package/lib/detail-page-canvas/export/ai.ts +1 -1
- package/lib/detail-page-canvas/export/psd.ts +1 -1
- package/lib/detail-page-canvas/export/raster.ts +1 -1
- package/lib/detail-page-canvas/export/svg.ts +1 -1
- package/lib/detail-page-canvas/text-background-plan.ts +2 -14
- package/package.json +2 -2
- package/lib/detail-page-canvas/bubble-path.ts +0 -190
- package/lib/detail-page-canvas/clip-rect.ts +0 -65
- package/lib/detail-page-canvas/konva-fallback.ts +0 -477
- package/lib/detail-page-canvas/text-highlight-bands.ts +0 -127
|
@@ -11,13 +11,13 @@ import {
|
|
|
11
11
|
Text,
|
|
12
12
|
} from "react-konva/es/ReactKonvaCore";
|
|
13
13
|
|
|
14
|
-
import { roundedRectPath } from "
|
|
14
|
+
import { roundedRectPath } from "@leviosa-ai/canvas/paint/clip-rect";
|
|
15
15
|
import {
|
|
16
16
|
normalizeCanvasJsonForKonva,
|
|
17
17
|
type ParsedGradient,
|
|
18
18
|
type CanvasFallbackElement,
|
|
19
|
-
} from "
|
|
20
|
-
import { computeHighlightBands } from "
|
|
19
|
+
} from "@leviosa-ai/canvas/paint/konva-fallback";
|
|
20
|
+
import { computeHighlightBands } from "@leviosa-ai/canvas/paint/text-highlight-bands";
|
|
21
21
|
import type { LeviosaCanvasDocument } from "../../types/detail-page-canvas";
|
|
22
22
|
|
|
23
23
|
/** Konva gradient/shadow/cornerRadius props derived from a fallback element. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseCssGradient, linearGradientKonvaProps, radialGradientKonvaProps } from "
|
|
1
|
+
import { parseCssGradient, linearGradientKonvaProps, radialGradientKonvaProps } from "@leviosa-ai/canvas/paint/konva-fallback";
|
|
2
2
|
import {
|
|
3
3
|
documentWidth,
|
|
4
4
|
pageHeight,
|
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
linearGradientKonvaProps,
|
|
3
3
|
parseCssGradient,
|
|
4
4
|
radialGradientKonvaProps,
|
|
5
|
-
} from "
|
|
5
|
+
} from "@leviosa-ai/canvas/paint/konva-fallback";
|
|
6
6
|
import type { ExportElement } from "./document-model";
|
|
7
7
|
import { cssFont, layoutText, normalizeFontWeight, transformText } from "./text-layout";
|
|
8
8
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { parseCssGradient, type ParsedGradient } from "
|
|
1
|
+
import { parseCssGradient, type ParsedGradient } from "@leviosa-ai/canvas/paint/konva-fallback";
|
|
2
|
+
import { isTransparentColor } from "@leviosa-ai/canvas/paint/text-highlight-bands";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* The Canvas SDK editor paints a text element's background as a per-line
|
|
@@ -56,19 +57,6 @@ export type TextBackgroundPlan =
|
|
|
56
57
|
}
|
|
57
58
|
| null;
|
|
58
59
|
|
|
59
|
-
/** True for ``"transparent"`` or any colour whose alpha channel is 0. */
|
|
60
|
-
export function isTransparentColor(value: unknown): boolean {
|
|
61
|
-
if (typeof value !== "string") return true;
|
|
62
|
-
const color = value.trim().toLowerCase();
|
|
63
|
-
if (!color || color === "transparent" || color === "none") return true;
|
|
64
|
-
const rgba = color.match(/^rgba?\(([^)]+)\)$/);
|
|
65
|
-
if (rgba) {
|
|
66
|
-
const parts = rgba[1].split(",").map((part) => part.trim());
|
|
67
|
-
if (parts.length === 4) return parseFloat(parts[3]) === 0;
|
|
68
|
-
}
|
|
69
|
-
return false;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
60
|
/**
|
|
73
61
|
* Decide what background rect (if any) to paint behind a Canvas text element.
|
|
74
62
|
* Returns ``null`` when the stock per-line contour is already correct (e.g. a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leviosa-ai/detail-page-editor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"woff2-encoder": "2.0.0"
|
|
63
63
|
},
|
|
64
64
|
"peerDependencies": {
|
|
65
|
-
"@leviosa-ai/canvas": "^0.1.
|
|
65
|
+
"@leviosa-ai/canvas": "^0.1.4",
|
|
66
66
|
"@leviosa-ai/konva": "^0.8.0",
|
|
67
67
|
"@tanstack/react-query": "^5.100.3",
|
|
68
68
|
"konva": "^10.0.0",
|
|
@@ -1,190 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,65 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,477 +0,0 @@
|
|
|
1
|
-
export type CanvasFallbackElementKind = "text" | "image" | "rect" | "unknown";
|
|
2
|
-
|
|
3
|
-
export type GradientStop = { offset: number; color: string };
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* A CSS gradient parsed into the values Konva needs. ``linear`` keeps the CSS
|
|
7
|
-
* angle (0deg = upward, clockwise); ``radial`` keeps the focal point as a
|
|
8
|
-
* 0..1 fraction of the element box.
|
|
9
|
-
*/
|
|
10
|
-
export type ParsedGradient =
|
|
11
|
-
| { type: "linear"; angle: number; stops: GradientStop[] }
|
|
12
|
-
| { type: "radial"; cx: number; cy: number; stops: GradientStop[] };
|
|
13
|
-
|
|
14
|
-
/** A CSS box-shadow parsed for Konva (spread is dropped — Konva has none). */
|
|
15
|
-
export type ParsedShadow = {
|
|
16
|
-
color: string;
|
|
17
|
-
offsetX: number;
|
|
18
|
-
offsetY: number;
|
|
19
|
-
blur: number;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
/** The rectangle a decorative element is clipped to (overflow:hidden parent). */
|
|
23
|
-
export type ClipRect = {
|
|
24
|
-
x: number;
|
|
25
|
-
y: number;
|
|
26
|
-
width: number;
|
|
27
|
-
height: number;
|
|
28
|
-
radius: number;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
export type CanvasFallbackElement = {
|
|
32
|
-
id: string;
|
|
33
|
-
kind: CanvasFallbackElementKind;
|
|
34
|
-
x: number;
|
|
35
|
-
y: number;
|
|
36
|
-
width: number;
|
|
37
|
-
height: number;
|
|
38
|
-
text: string;
|
|
39
|
-
src: string;
|
|
40
|
-
fill: string;
|
|
41
|
-
stroke: string;
|
|
42
|
-
strokeWidth: number;
|
|
43
|
-
opacity: number;
|
|
44
|
-
rotation: number;
|
|
45
|
-
fontSize: number;
|
|
46
|
-
fontFamily: string;
|
|
47
|
-
fontWeight: string;
|
|
48
|
-
lineHeight: number;
|
|
49
|
-
align: "left" | "center" | "right";
|
|
50
|
-
verticalAlign: "top" | "middle" | "bottom";
|
|
51
|
-
/** Canvas text badge background (decomposer emits these in pixels). */
|
|
52
|
-
backgroundEnabled: boolean;
|
|
53
|
-
backgroundColor: string;
|
|
54
|
-
backgroundPadding: number;
|
|
55
|
-
backgroundCornerRadius: number;
|
|
56
|
-
/**
|
|
57
|
-
* A CSS gradient highlight behind the text (decomposer's
|
|
58
|
-
* ``custom.backgroundGradient``, e.g. a highlighter band drawn with
|
|
59
|
-
* ``linear-gradient(transparent 60%, <color> 60%)``). the stock editor's solid
|
|
60
|
-
* ``backgroundColor`` cannot express it, so it is carried separately and
|
|
61
|
-
* painted as a gradient-filled rect behind the glyphs.
|
|
62
|
-
*/
|
|
63
|
-
backgroundGradient: ParsedGradient | null;
|
|
64
|
-
/**
|
|
65
|
-
* A user "marker" highlight (``custom.highlightColor``): painted as one band per
|
|
66
|
-
* wrapped line hugging that line's glyph width, not a single box. Owns the
|
|
67
|
-
* highlight instead of native ``backgroundEnabled`` so loose line-heights don't
|
|
68
|
-
* merge wrapped lines into a solid block.
|
|
69
|
-
*/
|
|
70
|
-
highlightColor: string | null;
|
|
71
|
-
slot: string;
|
|
72
|
-
/** Native-renderable decoration the Canvas SDK ignores (carried in custom). */
|
|
73
|
-
cornerRadius: number;
|
|
74
|
-
gradient: ParsedGradient | null;
|
|
75
|
-
shadow: ParsedShadow | null;
|
|
76
|
-
clipToRect: ClipRect | null;
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
export type CanvasFallbackPage = {
|
|
80
|
-
id: string;
|
|
81
|
-
width: number;
|
|
82
|
-
height: number;
|
|
83
|
-
background: string;
|
|
84
|
-
elements: CanvasFallbackElement[];
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
type UnknownRecord = Record<string, unknown>;
|
|
88
|
-
|
|
89
|
-
function asRecord(value: unknown): UnknownRecord {
|
|
90
|
-
return value && typeof value === "object" && !Array.isArray(value)
|
|
91
|
-
? (value as UnknownRecord)
|
|
92
|
-
: {};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function asArray(value: unknown): unknown[] {
|
|
96
|
-
return Array.isArray(value) ? value : [];
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function finiteNumber(value: unknown, fallback: number): number {
|
|
100
|
-
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function finiteString(value: unknown, fallback = ""): string {
|
|
104
|
-
return typeof value === "string" ? value : fallback;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function normalizeAlign(value: unknown): CanvasFallbackElement["align"] {
|
|
108
|
-
return value === "center" || value === "right" ? value : "left";
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function normalizeVerticalAlign(
|
|
112
|
-
value: unknown,
|
|
113
|
-
): CanvasFallbackElement["verticalAlign"] {
|
|
114
|
-
// The decomposer emits the stock editor's "center"; Konva's name for it is "middle".
|
|
115
|
-
if (value === "middle" || value === "center") return "middle";
|
|
116
|
-
if (value === "bottom") return "bottom";
|
|
117
|
-
return "top";
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function normalizeKind(element: UnknownRecord): CanvasFallbackElementKind {
|
|
121
|
-
if (element.type === "text") return "text";
|
|
122
|
-
if (element.type === "image") return "image";
|
|
123
|
-
if (element.type === "figure" && element.subType === "rect") return "rect";
|
|
124
|
-
if (element.type === "svg" && finiteString(element.src)) return "image";
|
|
125
|
-
return "unknown";
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Split on a separator that is not nested inside parentheses (e.g. rgb(…)). */
|
|
129
|
-
function splitTopLevel(value: string, separator: string): string[] {
|
|
130
|
-
const parts: string[] = [];
|
|
131
|
-
let depth = 0;
|
|
132
|
-
let current = "";
|
|
133
|
-
for (const char of value) {
|
|
134
|
-
if (char === "(") depth += 1;
|
|
135
|
-
else if (char === ")") depth = Math.max(0, depth - 1);
|
|
136
|
-
if (char === separator && depth === 0) {
|
|
137
|
-
parts.push(current);
|
|
138
|
-
current = "";
|
|
139
|
-
} else {
|
|
140
|
-
current += char;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
parts.push(current);
|
|
144
|
-
return parts.map((part) => part.trim()).filter(Boolean);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** "rgb(255, 0, 0) 55%" -> { color, offset: 0.55 } (offset null when absent). */
|
|
148
|
-
function parseColorStopToken(token: string): {
|
|
149
|
-
color: string;
|
|
150
|
-
offset: number | null;
|
|
151
|
-
} {
|
|
152
|
-
const match = token.match(/\s(-?\d+(?:\.\d+)?)%\s*$/);
|
|
153
|
-
if (match && match.index !== undefined) {
|
|
154
|
-
return {
|
|
155
|
-
color: token.slice(0, match.index).trim(),
|
|
156
|
-
offset: parseFloat(match[1]) / 100,
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
return { color: token.trim(), offset: null };
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** Spread color stops evenly across 0..1 when CSS omits explicit positions. */
|
|
163
|
-
function distributeStops(
|
|
164
|
-
raw: Array<{ color: string; offset: number | null }>,
|
|
165
|
-
): GradientStop[] {
|
|
166
|
-
const usable = raw.filter((stop) => stop.color);
|
|
167
|
-
const count = usable.length;
|
|
168
|
-
return usable.map((stop, index) => ({
|
|
169
|
-
color: stop.color,
|
|
170
|
-
offset: stop.offset ?? (count <= 1 ? 0 : index / (count - 1)),
|
|
171
|
-
}));
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** Map a CSS linear-gradient direction (angle or keywords) to degrees. */
|
|
175
|
-
function parseLinearAngle(direction: string): number {
|
|
176
|
-
const degMatch = direction.match(/(-?\d+(?:\.\d+)?)deg/);
|
|
177
|
-
if (degMatch) return parseFloat(degMatch[1]);
|
|
178
|
-
const dir = direction.toLowerCase();
|
|
179
|
-
const has = (keyword: string) => dir.includes(keyword);
|
|
180
|
-
if (has("top") && has("right")) return 45;
|
|
181
|
-
if (has("bottom") && has("right")) return 135;
|
|
182
|
-
if (has("bottom") && has("left")) return 225;
|
|
183
|
-
if (has("top") && has("left")) return 315;
|
|
184
|
-
if (has("right")) return 90;
|
|
185
|
-
if (has("bottom")) return 180;
|
|
186
|
-
if (has("left")) return 270;
|
|
187
|
-
if (has("top")) return 0;
|
|
188
|
-
return 180;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Split a possibly-layered CSS gradient value into its individual
|
|
193
|
-
* ``linear-gradient(...)``/``radial-gradient(...)`` functions. A compound value
|
|
194
|
-
* like ``radial-gradient(…), linear-gradient(…)`` (a radial glow over a linear
|
|
195
|
-
* base) must NOT be split on its top-level commas — those also separate colour
|
|
196
|
-
* stops — so we walk balanced parentheses and take one whole function per layer.
|
|
197
|
-
* Non-gradient layers (e.g. ``url(...)``) end the scan.
|
|
198
|
-
*/
|
|
199
|
-
function splitGradientLayers(value: string): string[] {
|
|
200
|
-
const layers: string[] = [];
|
|
201
|
-
let i = 0;
|
|
202
|
-
while (i < value.length) {
|
|
203
|
-
while (i < value.length && (value[i] === "," || /\s/.test(value[i]))) i += 1;
|
|
204
|
-
const head = value.slice(i).match(/^(?:linear|radial)-gradient\(/i);
|
|
205
|
-
if (!head) break;
|
|
206
|
-
const open = i + head[0].length - 1; // index of the "("
|
|
207
|
-
let depth = 1;
|
|
208
|
-
let j = open + 1;
|
|
209
|
-
for (; j < value.length; j += 1) {
|
|
210
|
-
const char = value[j];
|
|
211
|
-
if (char === "(") depth += 1;
|
|
212
|
-
else if (char === ")") {
|
|
213
|
-
depth -= 1;
|
|
214
|
-
if (depth === 0) break;
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
layers.push(value.slice(i, j + 1));
|
|
218
|
-
i = j + 1;
|
|
219
|
-
}
|
|
220
|
-
return layers;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** Parse a single ``linear-gradient``/``radial-gradient`` function for Konva. */
|
|
224
|
-
function parseSingleGradient(trimmed: string): ParsedGradient | null {
|
|
225
|
-
const linear = trimmed.match(/^linear-gradient\((.*)\)$/);
|
|
226
|
-
if (linear) {
|
|
227
|
-
const args = splitTopLevel(linear[1], ",");
|
|
228
|
-
if (!args.length) return null;
|
|
229
|
-
let angle = 180;
|
|
230
|
-
let stopTokens = args;
|
|
231
|
-
if (/deg\s*$/.test(args[0]) || /^to\s/i.test(args[0])) {
|
|
232
|
-
angle = parseLinearAngle(args[0]);
|
|
233
|
-
stopTokens = args.slice(1);
|
|
234
|
-
}
|
|
235
|
-
const stops = distributeStops(stopTokens.map(parseColorStopToken));
|
|
236
|
-
if (!stops.length) return null;
|
|
237
|
-
return { type: "linear", angle, stops };
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const radial = trimmed.match(/^radial-gradient\((.*)\)$/);
|
|
241
|
-
if (radial) {
|
|
242
|
-
const args = splitTopLevel(radial[1], ",");
|
|
243
|
-
if (!args.length) return null;
|
|
244
|
-
let cx = 0.5;
|
|
245
|
-
let cy = 0.5;
|
|
246
|
-
let stopTokens = args;
|
|
247
|
-
const first = args[0];
|
|
248
|
-
const isShape =
|
|
249
|
-
/(circle|ellipse|closest|farthest|\bat\b)/i.test(first) &&
|
|
250
|
-
!/^(rgb|rgba|hsl|hsla|#)/i.test(first);
|
|
251
|
-
if (isShape) {
|
|
252
|
-
const at = first.match(/at\s+(-?\d+(?:\.\d+)?)%\s+(-?\d+(?:\.\d+)?)%/i);
|
|
253
|
-
if (at) {
|
|
254
|
-
cx = parseFloat(at[1]) / 100;
|
|
255
|
-
cy = parseFloat(at[2]) / 100;
|
|
256
|
-
}
|
|
257
|
-
stopTokens = args.slice(1);
|
|
258
|
-
}
|
|
259
|
-
const stops = distributeStops(stopTokens.map(parseColorStopToken));
|
|
260
|
-
if (!stops.length) return null;
|
|
261
|
-
return { type: "radial", cx, cy, stops };
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
return null;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Parse every gradient layer of a CSS value, foreground-first (CSS paints the
|
|
269
|
-
* first-listed layer on top). Empty when the value is not a gradient.
|
|
270
|
-
*/
|
|
271
|
-
export function parseCssGradientLayers(value: unknown): ParsedGradient[] {
|
|
272
|
-
if (typeof value !== "string") return [];
|
|
273
|
-
const trimmed = value.trim();
|
|
274
|
-
return splitGradientLayers(trimmed)
|
|
275
|
-
.map(parseSingleGradient)
|
|
276
|
-
.filter((gradient): gradient is ParsedGradient => gradient !== null);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/** Parse the first CSS gradient layer for Konva (see ``parseCssGradientLayers``). */
|
|
280
|
-
export function parseCssGradient(value: unknown): ParsedGradient | null {
|
|
281
|
-
return parseCssGradientLayers(value)[0] ?? null;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/** Konva radial-gradient fill props for a parsed gradient in a w×h box. */
|
|
285
|
-
export function radialGradientKonvaProps(
|
|
286
|
-
gradient: Extract<ParsedGradient, { type: "radial" }>,
|
|
287
|
-
width: number,
|
|
288
|
-
height: number,
|
|
289
|
-
): {
|
|
290
|
-
fillRadialGradientStartPoint: { x: number; y: number };
|
|
291
|
-
fillRadialGradientStartRadius: number;
|
|
292
|
-
fillRadialGradientEndPoint: { x: number; y: number };
|
|
293
|
-
fillRadialGradientEndRadius: number;
|
|
294
|
-
fillRadialGradientColorStops: Array<number | string>;
|
|
295
|
-
} {
|
|
296
|
-
const cx = gradient.cx * width;
|
|
297
|
-
const cy = gradient.cy * height;
|
|
298
|
-
const endRadius = Math.max(
|
|
299
|
-
Math.hypot(cx, cy),
|
|
300
|
-
Math.hypot(width - cx, cy),
|
|
301
|
-
Math.hypot(cx, height - cy),
|
|
302
|
-
Math.hypot(width - cx, height - cy),
|
|
303
|
-
);
|
|
304
|
-
return {
|
|
305
|
-
fillRadialGradientStartPoint: { x: cx, y: cy },
|
|
306
|
-
fillRadialGradientStartRadius: 0,
|
|
307
|
-
fillRadialGradientEndPoint: { x: cx, y: cy },
|
|
308
|
-
fillRadialGradientEndRadius: endRadius,
|
|
309
|
-
fillRadialGradientColorStops: gradient.stops.flatMap((stop) => [
|
|
310
|
-
stop.offset,
|
|
311
|
-
stop.color,
|
|
312
|
-
]),
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/** Konva linear-gradient fill props for a parsed gradient in a w×h box. */
|
|
317
|
-
export function linearGradientKonvaProps(
|
|
318
|
-
gradient: Extract<ParsedGradient, { type: "linear" }>,
|
|
319
|
-
width: number,
|
|
320
|
-
height: number,
|
|
321
|
-
): {
|
|
322
|
-
fillLinearGradientStartPoint: { x: number; y: number };
|
|
323
|
-
fillLinearGradientEndPoint: { x: number; y: number };
|
|
324
|
-
fillLinearGradientColorStops: Array<number | string>;
|
|
325
|
-
} {
|
|
326
|
-
// CSS angle: 0deg points up, increasing clockwise. Project it across the box.
|
|
327
|
-
const radians = (gradient.angle * Math.PI) / 180;
|
|
328
|
-
const dx = Math.sin(radians);
|
|
329
|
-
const dy = -Math.cos(radians);
|
|
330
|
-
const length = Math.abs(width * dx) + Math.abs(height * dy);
|
|
331
|
-
const cx = width / 2;
|
|
332
|
-
const cy = height / 2;
|
|
333
|
-
return {
|
|
334
|
-
fillLinearGradientStartPoint: {
|
|
335
|
-
x: cx - (dx * length) / 2,
|
|
336
|
-
y: cy - (dy * length) / 2,
|
|
337
|
-
},
|
|
338
|
-
fillLinearGradientEndPoint: {
|
|
339
|
-
x: cx + (dx * length) / 2,
|
|
340
|
-
y: cy + (dy * length) / 2,
|
|
341
|
-
},
|
|
342
|
-
fillLinearGradientColorStops: gradient.stops.flatMap((stop) => [
|
|
343
|
-
stop.offset,
|
|
344
|
-
stop.color,
|
|
345
|
-
]),
|
|
346
|
-
};
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/** Parse a CSS ``box-shadow`` (color may lead or trail) for Konva. */
|
|
350
|
-
export function parseCssShadow(value: unknown): ParsedShadow | null {
|
|
351
|
-
if (typeof value !== "string") return null;
|
|
352
|
-
const trimmed = value.trim();
|
|
353
|
-
if (!trimmed || trimmed === "none") return null;
|
|
354
|
-
const colorMatch = trimmed.match(
|
|
355
|
-
/(rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-fA-F]{3,8})/,
|
|
356
|
-
);
|
|
357
|
-
const color = colorMatch ? colorMatch[0] : "#000000";
|
|
358
|
-
const rest = colorMatch ? trimmed.replace(colorMatch[0], " ") : trimmed;
|
|
359
|
-
const lengths = (rest.match(/-?\d+(?:\.\d+)?px/g) ?? []).map((token) =>
|
|
360
|
-
parseFloat(token),
|
|
361
|
-
);
|
|
362
|
-
if (!lengths.length) return null;
|
|
363
|
-
const [offsetX = 0, offsetY = 0, blur = 0] = lengths;
|
|
364
|
-
return { color, offsetX, offsetY, blur };
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/** Read the ``custom.clipToRect`` payload into a usable clip rectangle. */
|
|
368
|
-
function parseClipRect(value: unknown): ClipRect | null {
|
|
369
|
-
const rect = asRecord(value);
|
|
370
|
-
if (!Object.keys(rect).length) return null;
|
|
371
|
-
const width = finiteNumber(rect.width, 0);
|
|
372
|
-
const height = finiteNumber(rect.height, 0);
|
|
373
|
-
if (width <= 0 || height <= 0) return null;
|
|
374
|
-
return {
|
|
375
|
-
x: finiteNumber(rect.x, 0),
|
|
376
|
-
y: finiteNumber(rect.y, 0),
|
|
377
|
-
width,
|
|
378
|
-
height,
|
|
379
|
-
radius: Math.max(0, finiteNumber(rect.radius, 0)),
|
|
380
|
-
};
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
function flattenElements(elements: unknown[]): UnknownRecord[] {
|
|
384
|
-
const flattened: UnknownRecord[] = [];
|
|
385
|
-
for (const candidate of elements) {
|
|
386
|
-
const element = asRecord(candidate);
|
|
387
|
-
if (!Object.keys(element).length) continue;
|
|
388
|
-
flattened.push(element);
|
|
389
|
-
flattened.push(...flattenElements(asArray(element.children)));
|
|
390
|
-
}
|
|
391
|
-
return flattened;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
export function normalizeCanvasJsonForKonva(json: unknown): CanvasFallbackPage[] {
|
|
395
|
-
const root = asRecord(json);
|
|
396
|
-
const rootWidth = finiteNumber(root.width, 750);
|
|
397
|
-
const rootHeight = finiteNumber(root.height, 900);
|
|
398
|
-
const rootBackground = finiteString(
|
|
399
|
-
root.background ?? root.backgroundColor,
|
|
400
|
-
"#ffffff",
|
|
401
|
-
);
|
|
402
|
-
const pages = asArray(root.pages).map((candidate, pageIndex) => {
|
|
403
|
-
const page = asRecord(candidate);
|
|
404
|
-
const width = finiteNumber(page.width, rootWidth);
|
|
405
|
-
const height = finiteNumber(page.height, rootHeight);
|
|
406
|
-
const background = finiteString(
|
|
407
|
-
page.background ?? page.backgroundColor,
|
|
408
|
-
rootBackground,
|
|
409
|
-
);
|
|
410
|
-
const elements = flattenElements(asArray(page.children)).map(
|
|
411
|
-
(element, elementIndex): CanvasFallbackElement => {
|
|
412
|
-
const custom = asRecord(element.custom);
|
|
413
|
-
const widthValue = Math.max(1, finiteNumber(element.width, 1));
|
|
414
|
-
const heightValue = Math.max(1, finiteNumber(element.height, 1));
|
|
415
|
-
return {
|
|
416
|
-
id: finiteString(element.id, `page-${pageIndex + 1}-element-${elementIndex + 1}`),
|
|
417
|
-
kind: normalizeKind(element),
|
|
418
|
-
x: finiteNumber(element.x, 0),
|
|
419
|
-
y: finiteNumber(element.y, 0),
|
|
420
|
-
width: widthValue,
|
|
421
|
-
height: heightValue,
|
|
422
|
-
text: finiteString(element.text),
|
|
423
|
-
src: finiteString(element.src),
|
|
424
|
-
fill: finiteString(
|
|
425
|
-
element.fill ?? element.backgroundColor,
|
|
426
|
-
element.type === "text" ? "#111111" : "#f5f5f5",
|
|
427
|
-
),
|
|
428
|
-
stroke: finiteString(element.stroke, "#d4d4d4"),
|
|
429
|
-
strokeWidth: finiteNumber(element.strokeWidth, 0),
|
|
430
|
-
opacity: Math.max(0, Math.min(1, finiteNumber(element.opacity, 1))),
|
|
431
|
-
rotation: finiteNumber(element.rotation, 0),
|
|
432
|
-
fontSize: Math.max(6, finiteNumber(element.fontSize, 24)),
|
|
433
|
-
fontFamily: finiteString(element.fontFamily, "Pretendard"),
|
|
434
|
-
fontWeight: String(element.fontWeight ?? element.fontStyle ?? "normal"),
|
|
435
|
-
lineHeight: Math.max(0.5, finiteNumber(element.lineHeight, 1.2)),
|
|
436
|
-
align: normalizeAlign(element.align),
|
|
437
|
-
verticalAlign: normalizeVerticalAlign(element.verticalAlign),
|
|
438
|
-
backgroundEnabled: element.backgroundEnabled === true,
|
|
439
|
-
backgroundColor: finiteString(element.backgroundColor, "#000000"),
|
|
440
|
-
backgroundPadding: Math.max(0, finiteNumber(element.backgroundPadding, 0)),
|
|
441
|
-
backgroundCornerRadius: Math.max(
|
|
442
|
-
0,
|
|
443
|
-
finiteNumber(element.backgroundCornerRadius, 0),
|
|
444
|
-
),
|
|
445
|
-
backgroundGradient: parseCssGradient(custom.backgroundGradient),
|
|
446
|
-
highlightColor:
|
|
447
|
-
typeof custom.highlightColor === "string" && custom.highlightColor
|
|
448
|
-
? custom.highlightColor
|
|
449
|
-
: null,
|
|
450
|
-
slot: finiteString(custom.leviosaSlot),
|
|
451
|
-
cornerRadius: Math.max(0, finiteNumber(element.cornerRadius, 0)),
|
|
452
|
-
gradient: parseCssGradient(custom.gradient),
|
|
453
|
-
shadow: parseCssShadow(custom.shadow),
|
|
454
|
-
clipToRect: parseClipRect(custom.clipToRect),
|
|
455
|
-
};
|
|
456
|
-
},
|
|
457
|
-
);
|
|
458
|
-
return {
|
|
459
|
-
id: finiteString(page.id, `page-${pageIndex + 1}`),
|
|
460
|
-
width,
|
|
461
|
-
height,
|
|
462
|
-
background,
|
|
463
|
-
elements,
|
|
464
|
-
};
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
if (pages.length) return pages;
|
|
468
|
-
return [
|
|
469
|
-
{
|
|
470
|
-
id: "page-1",
|
|
471
|
-
width: rootWidth,
|
|
472
|
-
height: rootHeight,
|
|
473
|
-
background: rootBackground,
|
|
474
|
-
elements: [],
|
|
475
|
-
},
|
|
476
|
-
];
|
|
477
|
-
}
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
import Konva from "konva";
|
|
2
|
-
|
|
3
|
-
import { isTransparentColor } from "./text-background-plan";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* A per-line "marker" highlight: instead of one box behind the whole text (which,
|
|
7
|
-
* with a loose paragraph line-height, balloons into a solid block that swallows the
|
|
8
|
-
* gap between wrapped lines), we paint ONE band per visual line, each hugging that
|
|
9
|
-
* line's own text width. That reads as a highlighter pen — bands differ in width and
|
|
10
|
-
* a gap shows between lines.
|
|
11
|
-
*
|
|
12
|
-
* The line breaks MUST match what the editor/preview actually render, so we measure
|
|
13
|
-
* with an offscreen ``Konva.Text`` configured identically (same font, width, wrap,
|
|
14
|
-
* line-height). Konva populates ``textArr`` (its wrapped-line layout) in the
|
|
15
|
-
* constructor without needing a stage, so this is synchronous and deterministic —
|
|
16
|
-
* and, being the same engine, it wraps Korean no-space runs by character exactly as
|
|
17
|
-
* the visible text does.
|
|
18
|
-
*
|
|
19
|
-
* The band is stored on the element as ``custom.highlightColor`` (a single source of
|
|
20
|
-
* truth); the stock editor's native ``backgroundEnabled`` box is left off so the two never
|
|
21
|
-
* double up. Both renderers — the SDK editor (``BackgroundAwareText``) and the Konva
|
|
22
|
-
* export/preview — draw bands from the same computation here.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
export type HighlightBand = {
|
|
26
|
-
x: number;
|
|
27
|
-
y: number;
|
|
28
|
-
width: number;
|
|
29
|
-
height: number;
|
|
30
|
-
cornerRadius: number;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
export type HighlightBandInput = {
|
|
34
|
-
text: string;
|
|
35
|
-
fontSize: number;
|
|
36
|
-
fontFamily?: string;
|
|
37
|
-
fontWeight?: unknown;
|
|
38
|
-
fontStyle?: unknown;
|
|
39
|
-
/** The text box width the visible text wraps against. */
|
|
40
|
-
boxWidth: number;
|
|
41
|
-
/** Unitless line-height ratio (px strings must be resolved by the caller). */
|
|
42
|
-
lineHeightRatio: number;
|
|
43
|
-
align?: string;
|
|
44
|
-
color?: unknown;
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
// Band geometry, all relative to fontSize so it scales with the text.
|
|
48
|
-
const PAD_X_RATIO = 0.16; // horizontal overhang past the glyphs on each side
|
|
49
|
-
const BAND_H_RATIO = 1.3; // band height around the glyphs (tight, not the line box)
|
|
50
|
-
|
|
51
|
-
/** Resolve a Canvas/CSS line-height (number ratio or ``"30.78px"``) to a ratio. */
|
|
52
|
-
export function lineHeightRatioFor(raw: unknown, fontSize: number): number {
|
|
53
|
-
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) return raw;
|
|
54
|
-
if (typeof raw === "string") {
|
|
55
|
-
const px = /^(-?\d*\.?\d+)px$/.exec(raw.trim());
|
|
56
|
-
if (px && fontSize > 0) {
|
|
57
|
-
const ratio = parseFloat(px[1]) / fontSize;
|
|
58
|
-
if (Number.isFinite(ratio) && ratio > 0) return ratio;
|
|
59
|
-
}
|
|
60
|
-
const n = parseFloat(raw);
|
|
61
|
-
if (Number.isFinite(n) && n > 0) return n;
|
|
62
|
-
}
|
|
63
|
-
return 1.2;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function konvaFontStyle(fontWeight: unknown, fontStyle: unknown): string {
|
|
67
|
-
const bold = /(bold|[6-9]00)/i.test(String(fontWeight ?? ""));
|
|
68
|
-
const italic = String(fontStyle ?? "") === "italic";
|
|
69
|
-
return [bold ? "bold" : "", italic ? "italic" : ""].join(" ").trim() || "normal";
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Wrapped lines with their pixel widths, via an offscreen Konva.Text. */
|
|
73
|
-
export function measureHighlightLines(
|
|
74
|
-
input: HighlightBandInput,
|
|
75
|
-
): { text: string; width: number }[] {
|
|
76
|
-
const node = new Konva.Text({
|
|
77
|
-
text: String(input.text ?? ""),
|
|
78
|
-
fontSize: input.fontSize,
|
|
79
|
-
fontFamily: input.fontFamily || "Arial",
|
|
80
|
-
fontStyle: konvaFontStyle(input.fontWeight, input.fontStyle),
|
|
81
|
-
lineHeight: input.lineHeightRatio,
|
|
82
|
-
width: input.boxWidth > 0 ? input.boxWidth : undefined,
|
|
83
|
-
wrap: input.boxWidth > 0 ? "word" : "none",
|
|
84
|
-
});
|
|
85
|
-
const arr = (node.textArr ?? []) as Array<{ text?: string; width?: number }>;
|
|
86
|
-
const lines = arr.map((l) => ({
|
|
87
|
-
text: String(l.text ?? ""),
|
|
88
|
-
width: Number(l.width) || 0,
|
|
89
|
-
}));
|
|
90
|
-
const fallbackWidth = (() => {
|
|
91
|
-
try {
|
|
92
|
-
return node.getTextWidth();
|
|
93
|
-
} catch {
|
|
94
|
-
return 0;
|
|
95
|
-
}
|
|
96
|
-
})();
|
|
97
|
-
node.destroy();
|
|
98
|
-
return lines.length
|
|
99
|
-
? lines
|
|
100
|
-
: [{ text: String(input.text ?? ""), width: fallbackWidth }];
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Per-line marker bands in the text's LOCAL coordinate space (origin at the text
|
|
105
|
-
* box's top-left, before the element's own x/y/rotation transform). Blank lines
|
|
106
|
-
* produce no band. Returns [] when there is nothing to paint.
|
|
107
|
-
*/
|
|
108
|
-
export function computeHighlightBands(input: HighlightBandInput): HighlightBand[] {
|
|
109
|
-
const fs = input.fontSize;
|
|
110
|
-
if (!(fs > 0) || isTransparentColor(input.color)) return [];
|
|
111
|
-
const lineH = fs * input.lineHeightRatio;
|
|
112
|
-
const padX = fs * PAD_X_RATIO;
|
|
113
|
-
const bandH = Math.min(fs * BAND_H_RATIO, lineH); // never taller than the line box
|
|
114
|
-
const cornerRadius = Math.min(bandH * 0.28, 6);
|
|
115
|
-
const align = String(input.align ?? "left").toLowerCase();
|
|
116
|
-
const boxWidth = input.boxWidth;
|
|
117
|
-
|
|
118
|
-
return measureHighlightLines(input).flatMap((line, i) => {
|
|
119
|
-
if (!line.text.trim()) return [];
|
|
120
|
-
const w = Math.max(0, line.width) + padX * 2;
|
|
121
|
-
let x = -padX; // left / start
|
|
122
|
-
if (align === "center") x = (boxWidth - line.width) / 2 - padX;
|
|
123
|
-
else if (align === "right" || align === "end") x = boxWidth - line.width - padX;
|
|
124
|
-
const yCenter = i * lineH + lineH / 2;
|
|
125
|
-
return [{ x, y: yCenter - bandH / 2, width: w, height: bandH, cornerRadius }];
|
|
126
|
-
});
|
|
127
|
-
}
|