@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,474 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import { useEffect, useMemo, useState } from "react";
5
+ import {
6
+ Group,
7
+ Image as KonvaImage,
8
+ Layer,
9
+ Rect,
10
+ Stage,
11
+ Text,
12
+ } from "react-konva/es/ReactKonvaCore";
13
+
14
+ import { roundedRectPath } from "../../lib/detail-page-canvas/clip-rect";
15
+ import {
16
+ normalizeCanvasJsonForKonva,
17
+ type ParsedGradient,
18
+ type CanvasFallbackElement,
19
+ } from "../../lib/detail-page-canvas/konva-fallback";
20
+ import { computeHighlightBands } from "../../lib/detail-page-canvas/text-highlight-bands";
21
+ import type { LeviosaCanvasDocument } from "../../types/detail-page-canvas";
22
+
23
+ /** Konva gradient/shadow/cornerRadius props derived from a fallback element. */
24
+ type KonvaDecorationProps = {
25
+ fill?: string;
26
+ fillLinearGradientStartPoint?: { x: number; y: number };
27
+ fillLinearGradientEndPoint?: { x: number; y: number };
28
+ fillLinearGradientColorStops?: Array<number | string>;
29
+ fillRadialGradientStartPoint?: { x: number; y: number };
30
+ fillRadialGradientStartRadius?: number;
31
+ fillRadialGradientEndPoint?: { x: number; y: number };
32
+ fillRadialGradientEndRadius?: number;
33
+ fillRadialGradientColorStops?: Array<number | string>;
34
+ shadowColor?: string;
35
+ shadowBlur?: number;
36
+ shadowOffsetX?: number;
37
+ shadowOffsetY?: number;
38
+ shadowOpacity?: number;
39
+ shadowEnabled?: boolean;
40
+ cornerRadius?: number;
41
+ };
42
+
43
+ /** Build Konva fill props from a parsed gradient, in the element's local box. */
44
+ function gradientFillProps(
45
+ gradient: ParsedGradient,
46
+ width: number,
47
+ height: number,
48
+ ): KonvaDecorationProps {
49
+ const colorStops = gradient.stops.flatMap((stop) => [stop.offset, stop.color]);
50
+ if (gradient.type === "linear") {
51
+ // CSS angle: 0deg points up, increasing clockwise.
52
+ const radians = (gradient.angle * Math.PI) / 180;
53
+ const dx = Math.sin(radians);
54
+ const dy = -Math.cos(radians);
55
+ const length = Math.abs(width * dx) + Math.abs(height * dy);
56
+ const cx = width / 2;
57
+ const cy = height / 2;
58
+ return {
59
+ fillLinearGradientStartPoint: {
60
+ x: cx - (dx * length) / 2,
61
+ y: cy - (dy * length) / 2,
62
+ },
63
+ fillLinearGradientEndPoint: {
64
+ x: cx + (dx * length) / 2,
65
+ y: cy + (dy * length) / 2,
66
+ },
67
+ fillLinearGradientColorStops: colorStops,
68
+ };
69
+ }
70
+ const cx = gradient.cx * width;
71
+ const cy = gradient.cy * height;
72
+ const endRadius = Math.max(
73
+ Math.hypot(cx, cy),
74
+ Math.hypot(width - cx, cy),
75
+ Math.hypot(cx, height - cy),
76
+ Math.hypot(width - cx, height - cy),
77
+ );
78
+ return {
79
+ fillRadialGradientStartPoint: { x: cx, y: cy },
80
+ fillRadialGradientStartRadius: 0,
81
+ fillRadialGradientEndPoint: { x: cx, y: cy },
82
+ fillRadialGradientEndRadius: endRadius,
83
+ fillRadialGradientColorStops: colorStops,
84
+ };
85
+ }
86
+
87
+ /** Collect gradient/shadow/cornerRadius Konva props for a scaled element. */
88
+ function decorationProps(
89
+ element: CanvasFallbackElement,
90
+ scale: number,
91
+ width: number,
92
+ height: number,
93
+ ): KonvaDecorationProps {
94
+ const props: KonvaDecorationProps = {};
95
+ if (element.gradient) {
96
+ Object.assign(props, gradientFillProps(element.gradient, width, height));
97
+ }
98
+ if (element.shadow) {
99
+ props.shadowEnabled = true;
100
+ props.shadowColor = element.shadow.color;
101
+ props.shadowBlur = element.shadow.blur * scale;
102
+ props.shadowOffsetX = element.shadow.offsetX * scale;
103
+ props.shadowOffsetY = element.shadow.offsetY * scale;
104
+ props.shadowOpacity = 1;
105
+ }
106
+ if (element.cornerRadius > 0) {
107
+ // 999-style pills resolve to a half-min-side radius.
108
+ props.cornerRadius = Math.min(element.cornerRadius * scale, Math.min(width, height) / 2);
109
+ }
110
+ return props;
111
+ }
112
+
113
+ /** Clip children to the element's owning rectangle (overflow:hidden parent). */
114
+ function ClipToRect({
115
+ element,
116
+ scale,
117
+ children,
118
+ }: {
119
+ element: CanvasFallbackElement;
120
+ scale: number;
121
+ children: ReactNode;
122
+ }) {
123
+ const clip = element.clipToRect;
124
+ if (!clip) return <>{children}</>;
125
+ return (
126
+ <Group
127
+ clipFunc={(ctx) =>
128
+ roundedRectPath(
129
+ ctx,
130
+ clip.x * scale,
131
+ clip.y * scale,
132
+ clip.width * scale,
133
+ clip.height * scale,
134
+ clip.radius * scale,
135
+ )
136
+ }
137
+ >
138
+ {children}
139
+ </Group>
140
+ );
141
+ }
142
+
143
+ type DetailPageKonvaJsonPreviewProps = {
144
+ document: LeviosaCanvasDocument;
145
+ slotData?: Record<string, unknown>;
146
+ };
147
+
148
+ function useRemoteImage(src: string) {
149
+ const [loaded, setLoaded] = useState<{
150
+ src: string;
151
+ image: HTMLImageElement | null;
152
+ }>({ src: "", image: null });
153
+
154
+ useEffect(() => {
155
+ if (!src) return;
156
+ const nextImage = new window.Image();
157
+ nextImage.crossOrigin = "anonymous";
158
+ nextImage.onload = () => setLoaded({ src, image: nextImage });
159
+ nextImage.onerror = () => setLoaded({ src, image: null });
160
+ nextImage.src = src;
161
+ return () => {
162
+ nextImage.onload = null;
163
+ nextImage.onerror = null;
164
+ };
165
+ }, [src]);
166
+
167
+ return loaded.src === src ? loaded.image : null;
168
+ }
169
+
170
+ function slotValue(slotData: Record<string, unknown>, element: CanvasFallbackElement) {
171
+ if (!element.slot) return null;
172
+ const value = slotData[element.slot];
173
+ return typeof value === "string" && value.trim() ? value.trim() : null;
174
+ }
175
+
176
+ function NativeImageElement({
177
+ element,
178
+ scale,
179
+ slotData,
180
+ }: {
181
+ element: CanvasFallbackElement;
182
+ scale: number;
183
+ slotData: Record<string, unknown>;
184
+ }) {
185
+ const resolvedSrc = slotValue(slotData, element) ?? element.src;
186
+ const image = useRemoteImage(resolvedSrc);
187
+ const x = element.x * scale;
188
+ const y = element.y * scale;
189
+ const width = element.width * scale;
190
+ const height = element.height * scale;
191
+ const decoration = decorationProps(element, scale, width, height);
192
+
193
+ if (image) {
194
+ return (
195
+ <KonvaImage
196
+ image={image}
197
+ x={x}
198
+ y={y}
199
+ width={width}
200
+ height={height}
201
+ opacity={element.opacity}
202
+ rotation={element.rotation}
203
+ cornerRadius={decoration.cornerRadius}
204
+ shadowEnabled={decoration.shadowEnabled}
205
+ shadowColor={decoration.shadowColor}
206
+ shadowBlur={decoration.shadowBlur}
207
+ shadowOffsetX={decoration.shadowOffsetX}
208
+ shadowOffsetY={decoration.shadowOffsetY}
209
+ shadowOpacity={decoration.shadowOpacity}
210
+ />
211
+ );
212
+ }
213
+
214
+ return (
215
+ <>
216
+ <Rect
217
+ x={x}
218
+ y={y}
219
+ width={width}
220
+ height={height}
221
+ fill="#f4f4f5"
222
+ stroke="#d4d4d8"
223
+ strokeWidth={1}
224
+ />
225
+ <Text
226
+ x={x + 12}
227
+ y={y + Math.max(12, height / 2 - 18)}
228
+ width={Math.max(40, width - 24)}
229
+ height={36}
230
+ text={resolvedSrc ? "image loading failed" : "image slot empty"}
231
+ fontSize={Math.max(10, 13 * scale)}
232
+ fill="#71717a"
233
+ align="center"
234
+ />
235
+ </>
236
+ );
237
+ }
238
+
239
+ function NativeElement({
240
+ element,
241
+ scale,
242
+ slotData,
243
+ showSlots,
244
+ }: {
245
+ element: CanvasFallbackElement;
246
+ scale: number;
247
+ slotData: Record<string, unknown>;
248
+ showSlots: boolean;
249
+ }) {
250
+ const x = element.x * scale;
251
+ const y = element.y * scale;
252
+ const width = element.width * scale;
253
+ const height = element.height * scale;
254
+ const slotText = element.slot ? ` ${element.slot}` : "";
255
+
256
+ if (element.kind === "image") {
257
+ return (
258
+ <ClipToRect element={element} scale={scale}>
259
+ <NativeImageElement element={element} scale={scale} slotData={slotData} />
260
+ {showSlots && element.slot ? (
261
+ <Rect x={x} y={y} width={width} height={height} stroke="#0f766e" strokeWidth={2} dash={[6, 5]} />
262
+ ) : null}
263
+ </ClipToRect>
264
+ );
265
+ }
266
+
267
+ if (element.kind === "text") {
268
+ const resolvedText = slotValue(slotData, element) ?? element.text;
269
+ // The decomposer splits each visual line into its own element and sizes the
270
+ // box to that line. A box only one line tall must never wrap: the frontend's
271
+ // font metrics differ slightly from the decomposer's, so wrapping would push
272
+ // a word onto a second line that the one-line height then clips. Let such
273
+ // text overflow horizontally instead. Genuinely multi-line bodies (taller
274
+ // boxes, no explicit breaks) keep wrapping to their width.
275
+ const isSingleLine =
276
+ element.height <= element.fontSize * element.lineHeight * 1.6;
277
+ const pad = element.backgroundEnabled ? element.backgroundPadding * scale : 0;
278
+ // A user marker highlight: one band per wrapped line, hugging each line's
279
+ // glyph width (measured with the same Konva engine, then scaled). Takes
280
+ // precedence over the native box so loose line-heights don't merge lines.
281
+ const bands = element.highlightColor
282
+ ? computeHighlightBands({
283
+ text: resolvedText,
284
+ fontSize: element.fontSize,
285
+ fontFamily: element.fontFamily,
286
+ fontWeight: element.fontWeight,
287
+ boxWidth: element.width,
288
+ lineHeightRatio: element.lineHeight,
289
+ align: element.align,
290
+ color: element.highlightColor,
291
+ })
292
+ : [];
293
+ return (
294
+ <ClipToRect element={element} scale={scale}>
295
+ {bands.length > 0 && element.highlightColor ? (
296
+ bands.map((b, i) => (
297
+ <Rect
298
+ key={i}
299
+ x={x + b.x * scale}
300
+ y={y + b.y * scale}
301
+ width={b.width * scale}
302
+ height={b.height * scale}
303
+ cornerRadius={b.cornerRadius * scale}
304
+ fill={element.highlightColor ?? undefined}
305
+ opacity={element.opacity}
306
+ rotation={element.rotation}
307
+ />
308
+ ))
309
+ ) : element.backgroundEnabled || element.backgroundGradient ? (
310
+ <Rect
311
+ x={x}
312
+ y={y}
313
+ width={width}
314
+ height={height}
315
+ // A gradient highlight (e.g. a highlighter band) cannot be a solid
316
+ // ``backgroundColor`` — paint it with the parsed CSS gradient and drop
317
+ // the (usually transparent) solid fill so the band shows through.
318
+ fill={element.backgroundGradient ? undefined : element.backgroundColor}
319
+ {...(element.backgroundGradient
320
+ ? gradientFillProps(element.backgroundGradient, width, height)
321
+ : {})}
322
+ cornerRadius={Math.min(
323
+ element.backgroundCornerRadius * scale,
324
+ Math.min(width, height) / 2,
325
+ )}
326
+ opacity={element.opacity}
327
+ rotation={element.rotation}
328
+ />
329
+ ) : null}
330
+ <Text
331
+ x={x + pad}
332
+ y={y}
333
+ // A single-line box must not constrain width: Konva clips a no-wrap
334
+ // line to ``width`` rather than letting it overflow, so we drop the
335
+ // width and let the text size to its content (it is left-anchored at
336
+ // ``x``). Multi-line bodies keep their width to wrap against.
337
+ width={isSingleLine ? undefined : Math.max(1, width - pad * 2)}
338
+ height={height}
339
+ text={resolvedText}
340
+ fill={element.fill}
341
+ fontSize={element.fontSize * scale}
342
+ fontFamily={element.fontFamily}
343
+ fontStyle={element.fontWeight}
344
+ lineHeight={element.lineHeight}
345
+ align={element.align}
346
+ verticalAlign={element.verticalAlign}
347
+ opacity={element.opacity}
348
+ rotation={element.rotation}
349
+ wrap={isSingleLine ? "none" : "word"}
350
+ />
351
+ {showSlots && element.slot ? (
352
+ <Rect x={x} y={y} width={width} height={height} stroke="#2563eb" strokeWidth={2} dash={[6, 5]} />
353
+ ) : null}
354
+ </ClipToRect>
355
+ );
356
+ }
357
+
358
+ if (element.kind === "rect") {
359
+ const decoration = decorationProps(element, scale, width, height);
360
+ return (
361
+ <ClipToRect element={element} scale={scale}>
362
+ <Rect
363
+ x={x}
364
+ y={y}
365
+ width={width}
366
+ height={height}
367
+ fill={element.gradient ? undefined : element.fill}
368
+ stroke={element.stroke}
369
+ strokeWidth={element.strokeWidth}
370
+ opacity={element.opacity}
371
+ rotation={element.rotation}
372
+ fillLinearGradientStartPoint={decoration.fillLinearGradientStartPoint}
373
+ fillLinearGradientEndPoint={decoration.fillLinearGradientEndPoint}
374
+ fillLinearGradientColorStops={decoration.fillLinearGradientColorStops}
375
+ fillRadialGradientStartPoint={decoration.fillRadialGradientStartPoint}
376
+ fillRadialGradientStartRadius={decoration.fillRadialGradientStartRadius}
377
+ fillRadialGradientEndPoint={decoration.fillRadialGradientEndPoint}
378
+ fillRadialGradientEndRadius={decoration.fillRadialGradientEndRadius}
379
+ fillRadialGradientColorStops={decoration.fillRadialGradientColorStops}
380
+ cornerRadius={decoration.cornerRadius}
381
+ shadowEnabled={decoration.shadowEnabled}
382
+ shadowColor={decoration.shadowColor}
383
+ shadowBlur={decoration.shadowBlur}
384
+ shadowOffsetX={decoration.shadowOffsetX}
385
+ shadowOffsetY={decoration.shadowOffsetY}
386
+ shadowOpacity={decoration.shadowOpacity}
387
+ />
388
+ </ClipToRect>
389
+ );
390
+ }
391
+
392
+ if (!showSlots) return null;
393
+ return (
394
+ <Text
395
+ x={x}
396
+ y={y}
397
+ width={Math.max(100, width)}
398
+ height={24}
399
+ text={`unsupported${slotText}`}
400
+ fontSize={12 * scale}
401
+ fill="#a16207"
402
+ />
403
+ );
404
+ }
405
+
406
+ export function DetailPageKonvaJsonPreview({
407
+ document,
408
+ slotData = {},
409
+ }: DetailPageKonvaJsonPreviewProps) {
410
+ const pages = useMemo(
411
+ () => normalizeCanvasJsonForKonva(document.canvas_json),
412
+ [document.canvas_json],
413
+ );
414
+ const [showSlots, setShowSlots] = useState(true);
415
+ const scale = 0.48;
416
+
417
+ return (
418
+ <div className="h-screen overflow-auto bg-neutral-100">
419
+ <div className="sticky top-0 z-10 flex h-12 items-center justify-between border-b border-neutral-200 bg-white px-4">
420
+ <div>
421
+ <p className="text-sm font-semibold text-neutral-950">Native Konva JSON Preview</p>
422
+ <p className="text-xs text-neutral-500">
423
+ Canvas 없이 JSON을 직접 렌더링하는 최소 fallback
424
+ </p>
425
+ </div>
426
+ <button
427
+ type="button"
428
+ onClick={() => setShowSlots((value) => !value)}
429
+ className="inline-flex h-8 items-center rounded-md border border-neutral-200 px-3 text-xs font-semibold text-neutral-700"
430
+ >
431
+ {showSlots ? "Hide slots" : "Show slots"}
432
+ </button>
433
+ </div>
434
+ <div className="mx-auto flex w-full max-w-[480px] flex-col gap-6 px-4 py-6">
435
+ {pages.map((page, pageIndex) => (
436
+ <section key={page.id} className="overflow-hidden rounded-md border border-neutral-200 bg-white shadow-sm">
437
+ <div className="flex items-center justify-between border-b border-neutral-200 px-3 py-2">
438
+ <span className="text-xs font-semibold text-neutral-800">
439
+ {page.id || `page-${pageIndex + 1}`}
440
+ </span>
441
+ <span className="text-xs text-neutral-500">
442
+ {Math.round(page.width)} x {Math.round(page.height)}
443
+ </span>
444
+ </div>
445
+ <Stage
446
+ width={page.width * scale}
447
+ height={page.height * scale}
448
+ data-testid="detail-page-native-konva-stage"
449
+ >
450
+ <Layer>
451
+ <Rect
452
+ x={0}
453
+ y={0}
454
+ width={page.width * scale}
455
+ height={page.height * scale}
456
+ fill={page.background}
457
+ />
458
+ {page.elements.map((element) => (
459
+ <NativeElement
460
+ key={element.id}
461
+ element={element}
462
+ scale={scale}
463
+ slotData={slotData}
464
+ showSlots={showSlots}
465
+ />
466
+ ))}
467
+ </Layer>
468
+ </Stage>
469
+ </section>
470
+ ))}
471
+ </div>
472
+ </div>
473
+ );
474
+ }