@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.
Files changed (190) hide show
  1. package/components/cardnews/color-input.tsx +380 -0
  2. package/components/detail-page/ai-generate-panel.tsx +763 -0
  3. package/components/detail-page/annotation-canvas.tsx +860 -0
  4. package/components/detail-page/annotation-dialog.tsx +397 -0
  5. package/components/detail-page/bubble-tail-overlay.tsx +209 -0
  6. package/components/detail-page/canvas-context-menu.tsx +234 -0
  7. package/components/detail-page/canvas-observer.tsx +68 -0
  8. package/components/detail-page/canvas-overlay-host.tsx +44 -0
  9. package/components/detail-page/canvas-store-facade.ts +51 -0
  10. package/components/detail-page/chart-inspector.tsx +531 -0
  11. package/components/detail-page/design-reference-picker.tsx +383 -0
  12. package/components/detail-page/detail-page-basic-shapes.tsx +180 -0
  13. package/components/detail-page/detail-page-brand-gifs-panel.tsx +261 -0
  14. package/components/detail-page/detail-page-brand-kit-panel.tsx +77 -0
  15. package/components/detail-page/detail-page-brand-panel-header.tsx +44 -0
  16. package/components/detail-page/detail-page-charts-panel.tsx +104 -0
  17. package/components/detail-page/detail-page-decorations-panel.tsx +125 -0
  18. package/components/detail-page/detail-page-download-dialog.tsx +524 -0
  19. package/components/detail-page/detail-page-editor.tsx +974 -0
  20. package/components/detail-page/detail-page-elements-panel.tsx +117 -0
  21. package/components/detail-page/detail-page-font-picker.tsx +231 -0
  22. package/components/detail-page/detail-page-history-buttons.tsx +87 -0
  23. package/components/detail-page/detail-page-host-context.tsx +465 -0
  24. package/components/detail-page/detail-page-icons-panel.tsx +251 -0
  25. package/components/detail-page/detail-page-konva-json-preview.tsx +474 -0
  26. package/components/detail-page/detail-page-layers-panel.tsx +618 -0
  27. package/components/detail-page/detail-page-my-images-panel.tsx +179 -0
  28. package/components/detail-page/detail-page-my-shapes-panel.tsx +174 -0
  29. package/components/detail-page/detail-page-page-toolbar.tsx +171 -0
  30. package/components/detail-page/detail-page-pages-panel.tsx +299 -0
  31. package/components/detail-page/detail-page-pages-timeline.tsx +79 -0
  32. package/components/detail-page/detail-page-photos-panel.tsx +257 -0
  33. package/components/detail-page/detail-page-properties-panel.tsx +2680 -0
  34. package/components/detail-page/detail-page-qr-panel.tsx +137 -0
  35. package/components/detail-page/detail-page-references-panel.tsx +166 -0
  36. package/components/detail-page/detail-page-selection.ts +44 -0
  37. package/components/detail-page/detail-page-shapes-panel.tsx +16 -0
  38. package/components/detail-page/detail-page-sidebar-sections.tsx +324 -0
  39. package/components/detail-page/detail-page-tables-panel.tsx +106 -0
  40. package/components/detail-page/detail-page-text-panel.tsx +179 -0
  41. package/components/detail-page/detail-page-thumbnail-bus.ts +42 -0
  42. package/components/detail-page/edit-quota-ui.tsx +150 -0
  43. package/components/detail-page/editor-history.ts +56 -0
  44. package/components/detail-page/editor-hotkeys.tsx +165 -0
  45. package/components/detail-page/element-recents-strip.tsx +105 -0
  46. package/components/detail-page/element-rects.ts +82 -0
  47. package/components/detail-page/fill-control.tsx +261 -0
  48. package/components/detail-page/find-replace-panel.tsx +252 -0
  49. package/components/detail-page/generation-progress-fill.tsx +57 -0
  50. package/components/detail-page/gif-animator.tsx +184 -0
  51. package/components/detail-page/gif-effect-picker.tsx +136 -0
  52. package/components/detail-page/group-drill-in.tsx +219 -0
  53. package/components/detail-page/group-prompt-edit-panel.tsx +154 -0
  54. package/components/detail-page/hover-highlight-overlay.tsx +111 -0
  55. package/components/detail-page/hovered-layer.ts +34 -0
  56. package/components/detail-page/inspector-controls.tsx +175 -0
  57. package/components/detail-page/leviosa-canvas-workspace.tsx +313 -0
  58. package/components/detail-page/nested-selection-overlay.tsx +364 -0
  59. package/components/detail-page/panel-search-input.tsx +49 -0
  60. package/components/detail-page/prompt-edit-panel.tsx +146 -0
  61. package/components/detail-page/section-height-handle.tsx +276 -0
  62. package/components/detail-page/section-reauthor-controller.tsx +210 -0
  63. package/components/detail-page/spec-prompt-edit-panel.tsx +178 -0
  64. package/components/detail-page/spec-resize-absorber.tsx +149 -0
  65. package/components/detail-page/svg-prompt-edit-panel.tsx +139 -0
  66. package/components/detail-page/table-canvas-overlay.tsx +707 -0
  67. package/components/detail-page/table-inspector.tsx +586 -0
  68. package/components/ui/button.tsx +64 -0
  69. package/components/ui/command.tsx +175 -0
  70. package/components/ui/dialog.tsx +167 -0
  71. package/components/ui/label.tsx +24 -0
  72. package/components/ui/select.tsx +190 -0
  73. package/components/ui/slider.tsx +63 -0
  74. package/components/ui/switch.tsx +35 -0
  75. package/components/ui/tooltip.tsx +57 -0
  76. package/config/detail-page-bundle-font-tags.json +39 -0
  77. package/config/detail-page-fonts.json +373 -0
  78. package/index.ts +24 -0
  79. package/lib/cardnews/font-loader.ts +306 -0
  80. package/lib/detail-page/animation-sniff.ts +101 -0
  81. package/lib/detail-page/asset-bytes-url.ts +70 -0
  82. package/lib/detail-page/basic-shapes.ts +603 -0
  83. package/lib/detail-page/canvas-menu.ts +182 -0
  84. package/lib/detail-page/chart/defaults.ts +283 -0
  85. package/lib/detail-page/chart/format.ts +66 -0
  86. package/lib/detail-page/chart/normalize.ts +149 -0
  87. package/lib/detail-page/chart/palette.ts +85 -0
  88. package/lib/detail-page/chart/paste.ts +82 -0
  89. package/lib/detail-page/chart/preview.ts +78 -0
  90. package/lib/detail-page/chart/render.ts +188 -0
  91. package/lib/detail-page/chart/renderers/bar-h.ts +103 -0
  92. package/lib/detail-page/chart/renderers/bar-v.ts +213 -0
  93. package/lib/detail-page/chart/renderers/donut.ts +186 -0
  94. package/lib/detail-page/chart/renderers/gauge.ts +96 -0
  95. package/lib/detail-page/chart/renderers/line.ts +143 -0
  96. package/lib/detail-page/chart/renderers/shared.ts +263 -0
  97. package/lib/detail-page/chart/renderers/stack.ts +121 -0
  98. package/lib/detail-page/chart/sync.ts +92 -0
  99. package/lib/detail-page/chart/types.ts +145 -0
  100. package/lib/detail-page/css-color.ts +34 -0
  101. package/lib/detail-page/data-gif-payload.ts +167 -0
  102. package/lib/detail-page/design-reference.ts +244 -0
  103. package/lib/detail-page/distribute.ts +89 -0
  104. package/lib/detail-page/editor-routes.ts +10 -0
  105. package/lib/detail-page/element-recents.ts +167 -0
  106. package/lib/detail-page/find-replace.ts +120 -0
  107. package/lib/detail-page/format-painter.ts +159 -0
  108. package/lib/detail-page/gif-frames.ts +216 -0
  109. package/lib/detail-page/gif-timeline.ts +65 -0
  110. package/lib/detail-page/group-action.ts +62 -0
  111. package/lib/detail-page/hangul-roman.ts +58 -0
  112. package/lib/detail-page/icon-curated.ts +290 -0
  113. package/lib/detail-page/icon-fuzzy.ts +250 -0
  114. package/lib/detail-page/icon-keywords.generated.ts +3766 -0
  115. package/lib/detail-page/icon-keywords.ko.ts +524 -0
  116. package/lib/detail-page/icon-search.ts +257 -0
  117. package/lib/detail-page/icons.ts +67 -0
  118. package/lib/detail-page/image-credit.ts +121 -0
  119. package/lib/detail-page/image-data-uri.ts +29 -0
  120. package/lib/detail-page/insert-image.ts +75 -0
  121. package/lib/detail-page/insert-shape.ts +49 -0
  122. package/lib/detail-page/korean-dictionary.ts +187 -0
  123. package/lib/detail-page/layer-move.ts +257 -0
  124. package/lib/detail-page/new-product-draft-store.ts +105 -0
  125. package/lib/detail-page/new-product-draft.ts +283 -0
  126. package/lib/detail-page/personal-images-refresh.ts +30 -0
  127. package/lib/detail-page/product-source.ts +587 -0
  128. package/lib/detail-page/qr-code.ts +214 -0
  129. package/lib/detail-page/reference-image.ts +103 -0
  130. package/lib/detail-page/reference-upload.ts +181 -0
  131. package/lib/detail-page/replace-page.ts +35 -0
  132. package/lib/detail-page/replace-with-gif.ts +251 -0
  133. package/lib/detail-page/section-height.ts +131 -0
  134. package/lib/detail-page/section-reauthor-bus.ts +61 -0
  135. package/lib/detail-page/shape-to-image.ts +219 -0
  136. package/lib/detail-page/spec-group/clipboard.ts +35 -0
  137. package/lib/detail-page/spec-group/sync.ts +377 -0
  138. package/lib/detail-page/spec-group/text-metrics.ts +48 -0
  139. package/lib/detail-page/stock-photos.ts +88 -0
  140. package/lib/detail-page/svg-colors.ts +74 -0
  141. package/lib/detail-page/table/defaults.ts +344 -0
  142. package/lib/detail-page/table/edit.ts +312 -0
  143. package/lib/detail-page/table/harvest.ts +103 -0
  144. package/lib/detail-page/table/normalize.ts +125 -0
  145. package/lib/detail-page/table/paste.ts +49 -0
  146. package/lib/detail-page/table/preview.ts +66 -0
  147. package/lib/detail-page/table/render.ts +388 -0
  148. package/lib/detail-page/table/sync.ts +107 -0
  149. package/lib/detail-page/table/types.ts +102 -0
  150. package/lib/detail-page/text-gif-layout.ts +245 -0
  151. package/lib/detail-page/text-gif-payload.ts +67 -0
  152. package/lib/detail-page/text-presets.ts +230 -0
  153. package/lib/detail-page/use-fake-progress.ts +67 -0
  154. package/lib/detail-page/z-order.ts +82 -0
  155. package/lib/detail-page-canvas/bubble-path.ts +190 -0
  156. package/lib/detail-page-canvas/canvas-key.ts +22 -0
  157. package/lib/detail-page-canvas/clip-rect.ts +65 -0
  158. package/lib/detail-page-canvas/custom-props-adapter.ts +219 -0
  159. package/lib/detail-page-canvas/editor-fonts.ts +161 -0
  160. package/lib/detail-page-canvas/export/ai.ts +413 -0
  161. package/lib/detail-page-canvas/export/color.ts +35 -0
  162. package/lib/detail-page-canvas/export/document-model.ts +78 -0
  163. package/lib/detail-page-canvas/export/export-files.ts +290 -0
  164. package/lib/detail-page-canvas/export/font-subset.ts +264 -0
  165. package/lib/detail-page-canvas/export/gif-export.ts +310 -0
  166. package/lib/detail-page-canvas/export/gif-plan.ts +95 -0
  167. package/lib/detail-page-canvas/export/mp4-encode.ts +158 -0
  168. package/lib/detail-page-canvas/export/mp4-support.ts +22 -0
  169. package/lib/detail-page-canvas/export/pdf/geometry.ts +309 -0
  170. package/lib/detail-page-canvas/export/pdf/inline-svg.ts +399 -0
  171. package/lib/detail-page-canvas/export/pdf/resources.ts +443 -0
  172. package/lib/detail-page-canvas/export/pdf/text-ops.ts +81 -0
  173. package/lib/detail-page-canvas/export/pdf/writer.ts +109 -0
  174. package/lib/detail-page-canvas/export/psd.ts +354 -0
  175. package/lib/detail-page-canvas/export/raster.ts +255 -0
  176. package/lib/detail-page-canvas/export/svg.ts +479 -0
  177. package/lib/detail-page-canvas/export/text-layout.ts +142 -0
  178. package/lib/detail-page-canvas/font-catalog.ts +177 -0
  179. package/lib/detail-page-canvas/font-tags.ts +47 -0
  180. package/lib/detail-page-canvas/gif-web-fonts.ts +90 -0
  181. package/lib/detail-page-canvas/konva-fallback.ts +477 -0
  182. package/lib/detail-page-canvas/text-background-plan.ts +124 -0
  183. package/lib/detail-page-canvas/text-highlight-bands.ts +127 -0
  184. package/lib/utils.ts +10 -0
  185. package/package.json +68 -0
  186. package/types/commerce.ts +171 -0
  187. package/types/detail-page-api.ts +200 -0
  188. package/types/detail-page-canvas.ts +20 -0
  189. package/types/fontkit.d.ts +42 -0
  190. package/types/gifenc.d.ts +51 -0
@@ -0,0 +1,477 @@
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
+ }
@@ -0,0 +1,124 @@
1
+ import { parseCssGradient, type ParsedGradient } from "./konva-fallback";
2
+
3
+ /**
4
+ * The Canvas SDK editor paints a text element's background as a per-line
5
+ * CONTOUR filled with a single solid ``backgroundColor`` (see
6
+ * ``canvas/canvas/text-element``). Two decomposed cases break on that:
7
+ *
8
+ * 1. **Highlight gradients** (#3) — a highlighter band drawn in CSS with
9
+ * ``background-image: linear-gradient(transparent 60%, <color> 60%)`` lands in
10
+ * ``custom.backgroundGradient`` with a transparent ``backgroundColor``. Canvas
11
+ * can only fill a solid colour, so the band vanishes.
12
+ * 2. **Multi-line badges** (#2) — when a badge's lines differ in width, the
13
+ * per-line contour steps in and out, notching the badge's bottom edge.
14
+ *
15
+ * Both are fixed by painting a clean rectangle BEHIND the stock text: a gradient
16
+ * rect restores the highlight, and a solid bounding rect (same colour) unions
17
+ * with the stock contour to fill the notch. This module is the pure planner so
18
+ * the geometry is unit-testable without a Konva canvas.
19
+ */
20
+
21
+ export type TextBackgroundModel = {
22
+ text?: unknown;
23
+ fontSize?: number;
24
+ lineHeight?: unknown;
25
+ backgroundEnabled?: boolean;
26
+ backgroundColor?: unknown;
27
+ backgroundCornerRadius?: number;
28
+ width?: number;
29
+ height?: number;
30
+ custom?: { backgroundGradient?: unknown } | null;
31
+ a?: {
32
+ x?: number;
33
+ y?: number;
34
+ width?: number;
35
+ height?: number;
36
+ rotation?: number;
37
+ opacity?: number;
38
+ fontSize?: number;
39
+ } | null;
40
+ };
41
+
42
+ export type TextBackgroundPlan =
43
+ | {
44
+ type: "gradient";
45
+ gradient: ParsedGradient;
46
+ width: number;
47
+ height: number;
48
+ cornerRadius: number;
49
+ }
50
+ | {
51
+ type: "solid";
52
+ fill: string;
53
+ width: number;
54
+ height: number;
55
+ cornerRadius: number;
56
+ }
57
+ | null;
58
+
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
+ /**
73
+ * Decide what background rect (if any) to paint behind a Canvas text element.
74
+ * Returns ``null`` when the stock per-line contour is already correct (e.g. a
75
+ * single-line solid pill) so those elements render untouched.
76
+ */
77
+ export function planTextBackground(
78
+ model: TextBackgroundModel,
79
+ ): TextBackgroundPlan {
80
+ const width = model.a?.width ?? model.width ?? 0;
81
+ const height = model.a?.height ?? model.height ?? 0;
82
+ if (width <= 0 || height <= 0) return null;
83
+
84
+ const fontSize = model.a?.fontSize ?? model.fontSize ?? 0;
85
+ const lineHeight =
86
+ typeof model.lineHeight === "number" ? model.lineHeight : 1.2;
87
+ // Canvas renders ``backgroundCornerRadius`` (already in fontSize-relative
88
+ // units after the adapter) back to pixels as ``value * fontSize*lineHeight/2``.
89
+ const factor = fontSize * lineHeight * 0.5;
90
+ const cornerRadius = Math.max(
91
+ 0,
92
+ Math.min(
93
+ (model.backgroundCornerRadius ?? 0) * factor,
94
+ Math.min(width, height) / 2,
95
+ ),
96
+ );
97
+
98
+ const gradient = parseCssGradient(model.custom?.backgroundGradient);
99
+ if (gradient) {
100
+ return { type: "gradient", gradient, width, height, cornerRadius };
101
+ }
102
+
103
+ // Only multi-line badges need the notch-filling rect; a single line already
104
+ // contours to a clean rectangle. The decomposer emits an explicit ``\n`` for
105
+ // every multi-line badge, so key off that — a padded single-line pill has a
106
+ // tall box too, and a height heuristic would wrongly treat it as multi-line.
107
+ const text = String(model.text ?? "");
108
+ const isMultiLine = text.includes("\n");
109
+ if (
110
+ model.backgroundEnabled === true &&
111
+ !isTransparentColor(model.backgroundColor) &&
112
+ isMultiLine
113
+ ) {
114
+ return {
115
+ type: "solid",
116
+ fill: String(model.backgroundColor),
117
+ width,
118
+ height,
119
+ cornerRadius,
120
+ };
121
+ }
122
+
123
+ return null;
124
+ }