@leviosa-ai/detail-page-editor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/cardnews/color-input.tsx +380 -0
- package/components/detail-page/ai-generate-panel.tsx +763 -0
- package/components/detail-page/annotation-canvas.tsx +860 -0
- package/components/detail-page/annotation-dialog.tsx +397 -0
- package/components/detail-page/bubble-tail-overlay.tsx +209 -0
- package/components/detail-page/canvas-context-menu.tsx +234 -0
- package/components/detail-page/canvas-observer.tsx +68 -0
- package/components/detail-page/canvas-overlay-host.tsx +44 -0
- package/components/detail-page/canvas-store-facade.ts +51 -0
- package/components/detail-page/chart-inspector.tsx +531 -0
- package/components/detail-page/design-reference-picker.tsx +383 -0
- package/components/detail-page/detail-page-basic-shapes.tsx +180 -0
- package/components/detail-page/detail-page-brand-gifs-panel.tsx +261 -0
- package/components/detail-page/detail-page-brand-kit-panel.tsx +77 -0
- package/components/detail-page/detail-page-brand-panel-header.tsx +44 -0
- package/components/detail-page/detail-page-charts-panel.tsx +104 -0
- package/components/detail-page/detail-page-decorations-panel.tsx +125 -0
- package/components/detail-page/detail-page-download-dialog.tsx +524 -0
- package/components/detail-page/detail-page-editor.tsx +974 -0
- package/components/detail-page/detail-page-elements-panel.tsx +117 -0
- package/components/detail-page/detail-page-font-picker.tsx +231 -0
- package/components/detail-page/detail-page-history-buttons.tsx +87 -0
- package/components/detail-page/detail-page-host-context.tsx +465 -0
- package/components/detail-page/detail-page-icons-panel.tsx +251 -0
- package/components/detail-page/detail-page-konva-json-preview.tsx +474 -0
- package/components/detail-page/detail-page-layers-panel.tsx +618 -0
- package/components/detail-page/detail-page-my-images-panel.tsx +179 -0
- package/components/detail-page/detail-page-my-shapes-panel.tsx +174 -0
- package/components/detail-page/detail-page-page-toolbar.tsx +171 -0
- package/components/detail-page/detail-page-pages-panel.tsx +299 -0
- package/components/detail-page/detail-page-pages-timeline.tsx +79 -0
- package/components/detail-page/detail-page-photos-panel.tsx +257 -0
- package/components/detail-page/detail-page-properties-panel.tsx +2680 -0
- package/components/detail-page/detail-page-qr-panel.tsx +137 -0
- package/components/detail-page/detail-page-references-panel.tsx +166 -0
- package/components/detail-page/detail-page-selection.ts +44 -0
- package/components/detail-page/detail-page-shapes-panel.tsx +16 -0
- package/components/detail-page/detail-page-sidebar-sections.tsx +324 -0
- package/components/detail-page/detail-page-tables-panel.tsx +106 -0
- package/components/detail-page/detail-page-text-panel.tsx +179 -0
- package/components/detail-page/detail-page-thumbnail-bus.ts +42 -0
- package/components/detail-page/edit-quota-ui.tsx +150 -0
- package/components/detail-page/editor-history.ts +56 -0
- package/components/detail-page/editor-hotkeys.tsx +165 -0
- package/components/detail-page/element-recents-strip.tsx +105 -0
- package/components/detail-page/element-rects.ts +82 -0
- package/components/detail-page/fill-control.tsx +261 -0
- package/components/detail-page/find-replace-panel.tsx +252 -0
- package/components/detail-page/generation-progress-fill.tsx +57 -0
- package/components/detail-page/gif-animator.tsx +184 -0
- package/components/detail-page/gif-effect-picker.tsx +136 -0
- package/components/detail-page/group-drill-in.tsx +219 -0
- package/components/detail-page/group-prompt-edit-panel.tsx +154 -0
- package/components/detail-page/hover-highlight-overlay.tsx +111 -0
- package/components/detail-page/hovered-layer.ts +34 -0
- package/components/detail-page/inspector-controls.tsx +175 -0
- package/components/detail-page/leviosa-canvas-workspace.tsx +313 -0
- package/components/detail-page/nested-selection-overlay.tsx +364 -0
- package/components/detail-page/panel-search-input.tsx +49 -0
- package/components/detail-page/prompt-edit-panel.tsx +146 -0
- package/components/detail-page/section-height-handle.tsx +276 -0
- package/components/detail-page/section-reauthor-controller.tsx +210 -0
- package/components/detail-page/spec-prompt-edit-panel.tsx +178 -0
- package/components/detail-page/spec-resize-absorber.tsx +149 -0
- package/components/detail-page/svg-prompt-edit-panel.tsx +139 -0
- package/components/detail-page/table-canvas-overlay.tsx +707 -0
- package/components/detail-page/table-inspector.tsx +586 -0
- package/components/ui/button.tsx +64 -0
- package/components/ui/command.tsx +175 -0
- package/components/ui/dialog.tsx +167 -0
- package/components/ui/label.tsx +24 -0
- package/components/ui/select.tsx +190 -0
- package/components/ui/slider.tsx +63 -0
- package/components/ui/switch.tsx +35 -0
- package/components/ui/tooltip.tsx +57 -0
- package/config/detail-page-bundle-font-tags.json +39 -0
- package/config/detail-page-fonts.json +373 -0
- package/index.ts +24 -0
- package/lib/cardnews/font-loader.ts +306 -0
- package/lib/detail-page/animation-sniff.ts +101 -0
- package/lib/detail-page/asset-bytes-url.ts +70 -0
- package/lib/detail-page/basic-shapes.ts +603 -0
- package/lib/detail-page/canvas-menu.ts +182 -0
- package/lib/detail-page/chart/defaults.ts +283 -0
- package/lib/detail-page/chart/format.ts +66 -0
- package/lib/detail-page/chart/normalize.ts +149 -0
- package/lib/detail-page/chart/palette.ts +85 -0
- package/lib/detail-page/chart/paste.ts +82 -0
- package/lib/detail-page/chart/preview.ts +78 -0
- package/lib/detail-page/chart/render.ts +188 -0
- package/lib/detail-page/chart/renderers/bar-h.ts +103 -0
- package/lib/detail-page/chart/renderers/bar-v.ts +213 -0
- package/lib/detail-page/chart/renderers/donut.ts +186 -0
- package/lib/detail-page/chart/renderers/gauge.ts +96 -0
- package/lib/detail-page/chart/renderers/line.ts +143 -0
- package/lib/detail-page/chart/renderers/shared.ts +263 -0
- package/lib/detail-page/chart/renderers/stack.ts +121 -0
- package/lib/detail-page/chart/sync.ts +92 -0
- package/lib/detail-page/chart/types.ts +145 -0
- package/lib/detail-page/css-color.ts +34 -0
- package/lib/detail-page/data-gif-payload.ts +167 -0
- package/lib/detail-page/design-reference.ts +244 -0
- package/lib/detail-page/distribute.ts +89 -0
- package/lib/detail-page/editor-routes.ts +10 -0
- package/lib/detail-page/element-recents.ts +167 -0
- package/lib/detail-page/find-replace.ts +120 -0
- package/lib/detail-page/format-painter.ts +159 -0
- package/lib/detail-page/gif-frames.ts +216 -0
- package/lib/detail-page/gif-timeline.ts +65 -0
- package/lib/detail-page/group-action.ts +62 -0
- package/lib/detail-page/hangul-roman.ts +58 -0
- package/lib/detail-page/icon-curated.ts +290 -0
- package/lib/detail-page/icon-fuzzy.ts +250 -0
- package/lib/detail-page/icon-keywords.generated.ts +3766 -0
- package/lib/detail-page/icon-keywords.ko.ts +524 -0
- package/lib/detail-page/icon-search.ts +257 -0
- package/lib/detail-page/icons.ts +67 -0
- package/lib/detail-page/image-credit.ts +121 -0
- package/lib/detail-page/image-data-uri.ts +29 -0
- package/lib/detail-page/insert-image.ts +75 -0
- package/lib/detail-page/insert-shape.ts +49 -0
- package/lib/detail-page/korean-dictionary.ts +187 -0
- package/lib/detail-page/layer-move.ts +257 -0
- package/lib/detail-page/new-product-draft-store.ts +105 -0
- package/lib/detail-page/new-product-draft.ts +283 -0
- package/lib/detail-page/personal-images-refresh.ts +30 -0
- package/lib/detail-page/product-source.ts +587 -0
- package/lib/detail-page/qr-code.ts +214 -0
- package/lib/detail-page/reference-image.ts +103 -0
- package/lib/detail-page/reference-upload.ts +181 -0
- package/lib/detail-page/replace-page.ts +35 -0
- package/lib/detail-page/replace-with-gif.ts +251 -0
- package/lib/detail-page/section-height.ts +131 -0
- package/lib/detail-page/section-reauthor-bus.ts +61 -0
- package/lib/detail-page/shape-to-image.ts +219 -0
- package/lib/detail-page/spec-group/clipboard.ts +35 -0
- package/lib/detail-page/spec-group/sync.ts +377 -0
- package/lib/detail-page/spec-group/text-metrics.ts +48 -0
- package/lib/detail-page/stock-photos.ts +88 -0
- package/lib/detail-page/svg-colors.ts +74 -0
- package/lib/detail-page/table/defaults.ts +344 -0
- package/lib/detail-page/table/edit.ts +312 -0
- package/lib/detail-page/table/harvest.ts +103 -0
- package/lib/detail-page/table/normalize.ts +125 -0
- package/lib/detail-page/table/paste.ts +49 -0
- package/lib/detail-page/table/preview.ts +66 -0
- package/lib/detail-page/table/render.ts +388 -0
- package/lib/detail-page/table/sync.ts +107 -0
- package/lib/detail-page/table/types.ts +102 -0
- package/lib/detail-page/text-gif-layout.ts +245 -0
- package/lib/detail-page/text-gif-payload.ts +67 -0
- package/lib/detail-page/text-presets.ts +230 -0
- package/lib/detail-page/use-fake-progress.ts +67 -0
- package/lib/detail-page/z-order.ts +82 -0
- package/lib/detail-page-canvas/bubble-path.ts +190 -0
- package/lib/detail-page-canvas/canvas-key.ts +22 -0
- package/lib/detail-page-canvas/clip-rect.ts +65 -0
- package/lib/detail-page-canvas/custom-props-adapter.ts +219 -0
- package/lib/detail-page-canvas/editor-fonts.ts +161 -0
- package/lib/detail-page-canvas/export/ai.ts +413 -0
- package/lib/detail-page-canvas/export/color.ts +35 -0
- package/lib/detail-page-canvas/export/document-model.ts +78 -0
- package/lib/detail-page-canvas/export/export-files.ts +290 -0
- package/lib/detail-page-canvas/export/font-subset.ts +264 -0
- package/lib/detail-page-canvas/export/gif-export.ts +310 -0
- package/lib/detail-page-canvas/export/gif-plan.ts +95 -0
- package/lib/detail-page-canvas/export/mp4-encode.ts +158 -0
- package/lib/detail-page-canvas/export/mp4-support.ts +22 -0
- package/lib/detail-page-canvas/export/pdf/geometry.ts +309 -0
- package/lib/detail-page-canvas/export/pdf/inline-svg.ts +399 -0
- package/lib/detail-page-canvas/export/pdf/resources.ts +443 -0
- package/lib/detail-page-canvas/export/pdf/text-ops.ts +81 -0
- package/lib/detail-page-canvas/export/pdf/writer.ts +109 -0
- package/lib/detail-page-canvas/export/psd.ts +354 -0
- package/lib/detail-page-canvas/export/raster.ts +255 -0
- package/lib/detail-page-canvas/export/svg.ts +479 -0
- package/lib/detail-page-canvas/export/text-layout.ts +142 -0
- package/lib/detail-page-canvas/font-catalog.ts +177 -0
- package/lib/detail-page-canvas/font-tags.ts +47 -0
- package/lib/detail-page-canvas/gif-web-fonts.ts +90 -0
- package/lib/detail-page-canvas/konva-fallback.ts +477 -0
- package/lib/detail-page-canvas/text-background-plan.ts +124 -0
- package/lib/detail-page-canvas/text-highlight-bands.ts +127 -0
- package/lib/utils.ts +10 -0
- package/package.json +68 -0
- package/types/commerce.ts +171 -0
- package/types/detail-page-api.ts +200 -0
- package/types/detail-page-canvas.ts +20 -0
- package/types/fontkit.d.ts +42 -0
- package/types/gifenc.d.ts +51 -0
|
@@ -0,0 +1,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
|
+
}
|