@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,974 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
4
|
+
import { useTranslation } from "react-i18next";
|
|
5
|
+
import { EditorHotkeys } from "./editor-hotkeys";
|
|
6
|
+
import { FindReplacePanel } from "./find-replace-panel";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
ChevronLeft,
|
|
10
|
+
Copy,
|
|
11
|
+
Download,
|
|
12
|
+
Eye,
|
|
13
|
+
EyeOff,
|
|
14
|
+
ListChecks,
|
|
15
|
+
RefreshCw,
|
|
16
|
+
Save,
|
|
17
|
+
} from "lucide-react";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
applyTextLineFit,
|
|
21
|
+
clearPlaceholderImageSrc,
|
|
22
|
+
} from "../../lib/detail-page-canvas/custom-props-adapter";
|
|
23
|
+
import { CanvasStoreContext } from "./canvas-observer";
|
|
24
|
+
import { LeviosaCanvasWorkspace } from "./leviosa-canvas-workspace";
|
|
25
|
+
import { DetailPagePagesTimeline } from "./detail-page-pages-timeline";
|
|
26
|
+
import {
|
|
27
|
+
SidePanel as LeviosaSidePanel,
|
|
28
|
+
ZoomButtons as LeviosaZoomButtons,
|
|
29
|
+
} from "@leviosa-ai/canvas";
|
|
30
|
+
import { createCanvasStore } from "@leviosa-ai/canvas/store";
|
|
31
|
+
import { collectFontRequests } from "@leviosa-ai/canvas/render/use-document-fonts";
|
|
32
|
+
import { ensureCanvasKey } from "../../lib/detail-page-canvas/canvas-key";
|
|
33
|
+
import { loadEditorFont } from "../../lib/detail-page-canvas/editor-fonts";
|
|
34
|
+
import { normalizeDocumentAssetSrcs } from "../../lib/detail-page/asset-bytes-url";
|
|
35
|
+
import type { DocumentJson } from "@leviosa-ai/canvas/types";
|
|
36
|
+
import { buildDetailPageSections } from "./detail-page-sidebar-sections";
|
|
37
|
+
import { DetailPageProperties } from "./detail-page-properties-panel";
|
|
38
|
+
import type { ImageTier } from "../../lib/detail-page/image-credit";
|
|
39
|
+
import { DetailPageHistoryButtons } from "./detail-page-history-buttons";
|
|
40
|
+
import { DetailPageDownloadDialog } from "./detail-page-download-dialog";
|
|
41
|
+
import { useDetailPageHost } from "./detail-page-host-context";
|
|
42
|
+
import { SectionReauthorController } from "./section-reauthor-controller";
|
|
43
|
+
import type {
|
|
44
|
+
GenerateGifFn,
|
|
45
|
+
GenerateImageFn,
|
|
46
|
+
GenerateImageGifFn,
|
|
47
|
+
GenerateTextGifFn,
|
|
48
|
+
GenerateDataGifFn,
|
|
49
|
+
RemoveBackgroundFn,
|
|
50
|
+
} from "./ai-generate-panel";
|
|
51
|
+
import type {
|
|
52
|
+
LeviosaCanvasDocument,
|
|
53
|
+
CanvasSlotBinding,
|
|
54
|
+
} from "../../types/detail-page-canvas";
|
|
55
|
+
|
|
56
|
+
export type DetailPageEditorProps = {
|
|
57
|
+
initialDocument: LeviosaCanvasDocument;
|
|
58
|
+
saving?: boolean;
|
|
59
|
+
uploadFile?: (file: File) => Promise<string>;
|
|
60
|
+
onSave: (document: LeviosaCanvasDocument) => Promise<void>;
|
|
61
|
+
/** AI 생성 탭의 프롬프트 → 이미지 URL 콜백. 없으면 패널은 안내만 표시. */
|
|
62
|
+
onGenerateImage?: GenerateImageFn;
|
|
63
|
+
/** AI 생성 탭의 프롬프트 → 애니메이션 GIF URL 콜백. 없으면 GIF 모드는 안내만 표시. */
|
|
64
|
+
onGenerateGif?: GenerateGifFn;
|
|
65
|
+
/** 텍스트 인스펙터 '텍스트를 GIF로' → GIF URL 콜백. 없으면 섹션 숨김. */
|
|
66
|
+
onGenerateTextGif?: GenerateTextGifFn;
|
|
67
|
+
/** 이미지 인스펙터 '이미지를 GIF로' → GIF URL 콜백. 없으면 섹션 숨김. */
|
|
68
|
+
onGenerateImageGif?: GenerateImageGifFn;
|
|
69
|
+
/** 수치를 GIF로(카운트업·셀 차오름) → GIF URL 콜백. 없으면 두 섹션 숨김. */
|
|
70
|
+
onGenerateDataGif?: GenerateDataGifFn;
|
|
71
|
+
/** 이미지 인스펙터 '배경 지우기' → 컷아웃 URL 콜백. 없으면 섹션 숨김. */
|
|
72
|
+
onRemoveBackground?: RemoveBackgroundFn;
|
|
73
|
+
/** AI GIF 1회 생성 비용(크레딧, feature_costs). 0/미지정이면 크레딧 UI 숨김. */
|
|
74
|
+
gifCreditCost?: number;
|
|
75
|
+
/** 텍스트 GIF 1회 생성 비용(크레딧, feature_costs). */
|
|
76
|
+
textGifCreditCost?: number;
|
|
77
|
+
/** 이미지 GIF 1회 생성 비용(크레딧, feature_costs). */
|
|
78
|
+
imageGifCreditCost?: number;
|
|
79
|
+
/** 수치 GIF 1회 생성 비용(크레딧, feature_costs). */
|
|
80
|
+
dataGifCreditCost?: number;
|
|
81
|
+
/** 배경 제거 1회 비용(크레딧, feature_costs). */
|
|
82
|
+
bgRemoveCreditCost?: number;
|
|
83
|
+
/**
|
|
84
|
+
* AI 이미지 1회 생성/편집 비용(크레딧, 중앙 feature_costs). 0/미지정이면 크레딧 UI
|
|
85
|
+
* 숨김. 잔액과 함께 1.5× 안전 마진 게이트에 쓴다.
|
|
86
|
+
*/
|
|
87
|
+
imageCreditCost?: number;
|
|
88
|
+
/** 현재 보유 크레딧 잔액. useCredits를 요구하므로 (app) 셸 호스트가 주입한다. */
|
|
89
|
+
imageCreditBalance?: number;
|
|
90
|
+
/**
|
|
91
|
+
* 티어별(basic/pro/max) 라이브 크레딧 단가. 모델 드롭다운의 각 항목 비용/게이트에
|
|
92
|
+
* 쓴다. 인증된 호스트가 공유 훅으로 feature_costs에서 계산해 주입한다.
|
|
93
|
+
*/
|
|
94
|
+
imageCostByTier?: Partial<Record<ImageTier, number>>;
|
|
95
|
+
/**
|
|
96
|
+
* 생성 인스턴스 ID. 주어지면 텍스트 요소 선택 시 우측에 "프롬프트로 편집"이
|
|
97
|
+
* 노출된다(소싱 서버 카피 엔진 호출). dev 하네스는 scratch ID를 주입한다.
|
|
98
|
+
*/
|
|
99
|
+
generatedId?: string;
|
|
100
|
+
/**
|
|
101
|
+
* 좌측 "구조" 탭에 꽂을 패널. 조합 정보(아키타입·현재 벡터·플랜)는 편집기가
|
|
102
|
+
* 아니라 편집기를 띄운 화면이 아므로 만들어진 노드를 그대로 받는다.
|
|
103
|
+
*/
|
|
104
|
+
structurePanel?: ReactNode;
|
|
105
|
+
/** 헤더에 표시할 현재 상품/상세페이지 이름. 없으면 기본 라벨. */
|
|
106
|
+
productName?: string;
|
|
107
|
+
/** 헤더 좌측 "뒤로가기" 동작. 없으면 버튼을 숨긴다. */
|
|
108
|
+
onBack?: () => void;
|
|
109
|
+
/**
|
|
110
|
+
* 헤더 우측 끝에 끼울 앱 공용 크롬(크레딧·알림·언어 등). 이 컴포넌트들은 (app)
|
|
111
|
+
* 셸의 컨텍스트를 요구하므로 에디터가 직접 import하지 않고 호스트에서 주입한다.
|
|
112
|
+
*/
|
|
113
|
+
headerActions?: ReactNode;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
type CanvasJson = Record<string, unknown>;
|
|
117
|
+
|
|
118
|
+
type CanvasJsonElement = Record<string, unknown> & {
|
|
119
|
+
id?: string;
|
|
120
|
+
type?: string;
|
|
121
|
+
x?: number;
|
|
122
|
+
y?: number;
|
|
123
|
+
width?: number;
|
|
124
|
+
height?: number;
|
|
125
|
+
text?: string;
|
|
126
|
+
src?: string;
|
|
127
|
+
custom?: Record<string, unknown>;
|
|
128
|
+
children?: CanvasJsonElement[];
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
type SlotInspection = {
|
|
132
|
+
slot: string;
|
|
133
|
+
binding?: CanvasSlotBinding;
|
|
134
|
+
element?: CanvasJsonElement;
|
|
135
|
+
pageId?: string;
|
|
136
|
+
state: "ok" | "warning" | "error";
|
|
137
|
+
message: string;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
type TemplateLintIssue = {
|
|
141
|
+
level: "error" | "warning" | "info";
|
|
142
|
+
title: string;
|
|
143
|
+
detail: string;
|
|
144
|
+
slot?: string;
|
|
145
|
+
elementId?: string;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
type TemplateQaSnapshot = {
|
|
149
|
+
json: CanvasJson;
|
|
150
|
+
slots: SlotInspection[];
|
|
151
|
+
lint: TemplateLintIssue[];
|
|
152
|
+
summary: string;
|
|
153
|
+
counts: {
|
|
154
|
+
pages: number;
|
|
155
|
+
elements: number;
|
|
156
|
+
slots: number;
|
|
157
|
+
errors: number;
|
|
158
|
+
warnings: number;
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const QA_OVERLAY_CUSTOM_KEY = "leviosaQaOverlay";
|
|
163
|
+
const QA_OVERLAY_PREFIX = "leviosa-qa-overlay";
|
|
164
|
+
|
|
165
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
166
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
167
|
+
? (value as Record<string, unknown>)
|
|
168
|
+
: {};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function asArray(value: unknown): unknown[] {
|
|
172
|
+
return Array.isArray(value) ? value : [];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function finiteNumber(value: unknown): number | null {
|
|
176
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isQaOverlayElement(element: CanvasJsonElement): boolean {
|
|
180
|
+
return element.id?.startsWith(`${QA_OVERLAY_PREFIX}-`) === true ||
|
|
181
|
+
element.custom?.[QA_OVERLAY_CUSTOM_KEY] === true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function walkElements(
|
|
185
|
+
json: CanvasJson,
|
|
186
|
+
visitor: (element: CanvasJsonElement, page: Record<string, unknown>) => void,
|
|
187
|
+
) {
|
|
188
|
+
for (const page of asArray(json.pages) as Array<Record<string, unknown>>) {
|
|
189
|
+
const visit = (element: CanvasJsonElement) => {
|
|
190
|
+
visitor(element, page);
|
|
191
|
+
for (const child of asArray(element.children) as CanvasJsonElement[]) {
|
|
192
|
+
visit(child);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
for (const element of asArray(page.children) as CanvasJsonElement[]) {
|
|
196
|
+
visit(element);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function stripQaOverlayElements(json: CanvasJson): CanvasJson {
|
|
202
|
+
const clone = JSON.parse(JSON.stringify(json)) as CanvasJson;
|
|
203
|
+
const strip = (elements: CanvasJsonElement[]): CanvasJsonElement[] =>
|
|
204
|
+
elements
|
|
205
|
+
.filter((element) => !isQaOverlayElement(element))
|
|
206
|
+
.map((element) => ({
|
|
207
|
+
...element,
|
|
208
|
+
children: Array.isArray(element.children)
|
|
209
|
+
? strip(element.children)
|
|
210
|
+
: element.children,
|
|
211
|
+
}));
|
|
212
|
+
|
|
213
|
+
clone.pages = (asArray(clone.pages) as Array<Record<string, unknown>>).map((page) => ({
|
|
214
|
+
...page,
|
|
215
|
+
children: strip(asArray(page.children) as CanvasJsonElement[]),
|
|
216
|
+
}));
|
|
217
|
+
return clone;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function getSlotFromElement(element: CanvasJsonElement): string {
|
|
221
|
+
const custom = asRecord(element.custom);
|
|
222
|
+
return typeof custom.leviosaSlot === "string" ? custom.leviosaSlot : "";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function getElementKind(element?: CanvasJsonElement): CanvasSlotBinding["kind"] | null {
|
|
226
|
+
if (!element) return null;
|
|
227
|
+
if (element.type === "image") return "image";
|
|
228
|
+
if (element.type === "text") return "text";
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function formatRect(element?: CanvasJsonElement): string {
|
|
233
|
+
if (!element) return "-";
|
|
234
|
+
const x = finiteNumber(element.x) ?? 0;
|
|
235
|
+
const y = finiteNumber(element.y) ?? 0;
|
|
236
|
+
const width = finiteNumber(element.width) ?? 0;
|
|
237
|
+
const height = finiteNumber(element.height) ?? 0;
|
|
238
|
+
return `${Math.round(x)}, ${Math.round(y)} / ${Math.round(width)} x ${Math.round(height)}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function buildQaSnapshot(
|
|
242
|
+
document: LeviosaCanvasDocument,
|
|
243
|
+
json: CanvasJson,
|
|
244
|
+
): TemplateQaSnapshot {
|
|
245
|
+
const cleanJson = stripQaOverlayElements(json);
|
|
246
|
+
const pages = asArray(cleanJson.pages) as Array<Record<string, unknown>>;
|
|
247
|
+
const elementById = new Map<string, { element: CanvasJsonElement; pageId?: string }>();
|
|
248
|
+
const slotByElementId = new Map<string, string[]>();
|
|
249
|
+
const slotsFromElements = new Map<string, { element: CanvasJsonElement; pageId?: string }>();
|
|
250
|
+
let elementCount = 0;
|
|
251
|
+
|
|
252
|
+
walkElements(cleanJson, (element, page) => {
|
|
253
|
+
elementCount += 1;
|
|
254
|
+
if (element.id) {
|
|
255
|
+
const pageId = typeof page.id === "string" ? page.id : undefined;
|
|
256
|
+
elementById.set(element.id, { element, pageId });
|
|
257
|
+
const slot = getSlotFromElement(element);
|
|
258
|
+
if (slot) slotsFromElements.set(slot, { element, pageId });
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
for (const [slot, binding] of Object.entries(document.slot_bindings)) {
|
|
263
|
+
const list = slotByElementId.get(binding.element_id) ?? [];
|
|
264
|
+
list.push(slot);
|
|
265
|
+
slotByElementId.set(binding.element_id, list);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const lint: TemplateLintIssue[] = [];
|
|
269
|
+
const slots = Object.entries(document.slot_bindings)
|
|
270
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
271
|
+
.map(([slot, binding]) => {
|
|
272
|
+
const target = elementById.get(binding.element_id);
|
|
273
|
+
const element = target?.element;
|
|
274
|
+
const inferredKind = getElementKind(element);
|
|
275
|
+
let state: SlotInspection["state"] = "ok";
|
|
276
|
+
let message = "연결됨";
|
|
277
|
+
|
|
278
|
+
if (!element) {
|
|
279
|
+
state = "error";
|
|
280
|
+
message = "바인딩 대상 요소가 없습니다";
|
|
281
|
+
lint.push({
|
|
282
|
+
level: "error",
|
|
283
|
+
title: "Missing binding target",
|
|
284
|
+
detail: `${binding.element_id} 요소를 찾을 수 없습니다.`,
|
|
285
|
+
slot,
|
|
286
|
+
elementId: binding.element_id,
|
|
287
|
+
});
|
|
288
|
+
} else if (inferredKind && inferredKind !== binding.kind) {
|
|
289
|
+
state = "warning";
|
|
290
|
+
message = `${binding.kind} 슬롯이 ${element.type} 요소에 연결됨`;
|
|
291
|
+
lint.push({
|
|
292
|
+
level: "warning",
|
|
293
|
+
title: "Slot kind mismatch",
|
|
294
|
+
detail: `${binding.kind} 슬롯이 ${element.type} 요소에 연결되어 있습니다.`,
|
|
295
|
+
slot,
|
|
296
|
+
elementId: binding.element_id,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (element?.type === "text" && !String(element.text ?? "").trim()) {
|
|
301
|
+
state = state === "error" ? state : "warning";
|
|
302
|
+
message = "텍스트가 비어 있습니다";
|
|
303
|
+
lint.push({
|
|
304
|
+
level: "warning",
|
|
305
|
+
title: "Empty text slot",
|
|
306
|
+
detail: "텍스트 요소에 표시할 문구가 없습니다.",
|
|
307
|
+
slot,
|
|
308
|
+
elementId: binding.element_id,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (element?.type === "image" && !String(element.src ?? "").trim()) {
|
|
313
|
+
state = state === "error" ? state : "warning";
|
|
314
|
+
message = "이미지 URL이 비어 있습니다";
|
|
315
|
+
lint.push({
|
|
316
|
+
level: "warning",
|
|
317
|
+
title: "Empty image slot",
|
|
318
|
+
detail: "이미지 요소에 src가 없습니다.",
|
|
319
|
+
slot,
|
|
320
|
+
elementId: binding.element_id,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const width = finiteNumber(element?.width);
|
|
325
|
+
const height = finiteNumber(element?.height);
|
|
326
|
+
if (element && (width === null || height === null || width <= 0 || height <= 0)) {
|
|
327
|
+
state = state === "error" ? state : "warning";
|
|
328
|
+
message = "요소 크기를 확인하세요";
|
|
329
|
+
lint.push({
|
|
330
|
+
level: "warning",
|
|
331
|
+
title: "Invalid element bounds",
|
|
332
|
+
detail: "요소의 width 또는 height가 0 이하입니다.",
|
|
333
|
+
slot,
|
|
334
|
+
elementId: binding.element_id,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
slot,
|
|
340
|
+
binding,
|
|
341
|
+
element,
|
|
342
|
+
pageId: target?.pageId,
|
|
343
|
+
state,
|
|
344
|
+
message,
|
|
345
|
+
};
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
for (const [elementId, slotNames] of slotByElementId) {
|
|
349
|
+
if (slotNames.length > 1) {
|
|
350
|
+
lint.push({
|
|
351
|
+
level: "warning",
|
|
352
|
+
title: "Duplicate element binding",
|
|
353
|
+
detail: `${slotNames.join(", ")} 슬롯이 같은 요소를 공유합니다.`,
|
|
354
|
+
elementId,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
for (const [slot, target] of slotsFromElements) {
|
|
360
|
+
if (!document.slot_bindings[slot]) {
|
|
361
|
+
lint.push({
|
|
362
|
+
level: "info",
|
|
363
|
+
title: "Unregistered slot marker",
|
|
364
|
+
detail: "요소 custom.leviosaSlot은 있지만 slot_bindings에는 없습니다.",
|
|
365
|
+
slot,
|
|
366
|
+
elementId: target.element.id,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const canvasWidth = finiteNumber(document.canvas.width);
|
|
372
|
+
const jsonWidth = finiteNumber(cleanJson.width);
|
|
373
|
+
if (canvasWidth && jsonWidth && canvasWidth !== jsonWidth) {
|
|
374
|
+
lint.push({
|
|
375
|
+
level: "warning",
|
|
376
|
+
title: "Canvas width drift",
|
|
377
|
+
detail: `문서 canvas.width ${canvasWidth}와 문서 JSON width ${jsonWidth}가 다릅니다.`,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const errors = lint.filter((issue) => issue.level === "error").length;
|
|
382
|
+
const warnings = lint.filter((issue) => issue.level === "warning").length;
|
|
383
|
+
const summary = [
|
|
384
|
+
`Template: ${document.template_id ?? "unspecified"} v${document.template_version ?? "-"}`,
|
|
385
|
+
`Canvas: ${cleanJson.width ?? document.canvas.width} x ${cleanJson.height ?? "-"}`,
|
|
386
|
+
`Pages: ${pages.length}, Elements: ${elementCount}, Slots: ${slots.length}`,
|
|
387
|
+
`Lint: ${errors} errors, ${warnings} warnings`,
|
|
388
|
+
"",
|
|
389
|
+
"Slots",
|
|
390
|
+
...slots.map(
|
|
391
|
+
(slot) =>
|
|
392
|
+
`- ${slot.slot}: ${slot.state.toUpperCase()} | ${slot.binding?.kind ?? "-"} | ${slot.binding?.element_id ?? "-"} | ${formatRect(slot.element)} | ${slot.message}`,
|
|
393
|
+
),
|
|
394
|
+
"",
|
|
395
|
+
"Lint",
|
|
396
|
+
...(lint.length
|
|
397
|
+
? lint.map(
|
|
398
|
+
(issue) =>
|
|
399
|
+
`- ${issue.level.toUpperCase()}: ${issue.title}${issue.slot ? ` (${issue.slot})` : ""} - ${issue.detail}`,
|
|
400
|
+
)
|
|
401
|
+
: ["- PASS: template lint found no blocking issues"]),
|
|
402
|
+
].join("\n");
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
json: cleanJson,
|
|
406
|
+
slots,
|
|
407
|
+
lint,
|
|
408
|
+
summary,
|
|
409
|
+
counts: {
|
|
410
|
+
pages: pages.length,
|
|
411
|
+
elements: elementCount,
|
|
412
|
+
slots: slots.length,
|
|
413
|
+
errors,
|
|
414
|
+
warnings,
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function DetailPageEditor({
|
|
420
|
+
initialDocument,
|
|
421
|
+
saving = false,
|
|
422
|
+
uploadFile,
|
|
423
|
+
onSave,
|
|
424
|
+
onGenerateImage,
|
|
425
|
+
onGenerateGif,
|
|
426
|
+
onGenerateTextGif,
|
|
427
|
+
onGenerateImageGif,
|
|
428
|
+
onGenerateDataGif,
|
|
429
|
+
onRemoveBackground,
|
|
430
|
+
gifCreditCost = 0,
|
|
431
|
+
textGifCreditCost = 0,
|
|
432
|
+
imageGifCreditCost = 0,
|
|
433
|
+
dataGifCreditCost = 0,
|
|
434
|
+
bgRemoveCreditCost = 0,
|
|
435
|
+
imageCreditCost = 0,
|
|
436
|
+
imageCreditBalance = 0,
|
|
437
|
+
imageCostByTier,
|
|
438
|
+
generatedId,
|
|
439
|
+
structurePanel,
|
|
440
|
+
productName,
|
|
441
|
+
onBack,
|
|
442
|
+
headerActions,
|
|
443
|
+
}: DetailPageEditorProps) {
|
|
444
|
+
const { t } = useTranslation("branding");
|
|
445
|
+
// 요금제 모달은 호스트가 꽂는다 — 편집기가 열지만 무엇을 얼마에 파는지는 앱이 안다.
|
|
446
|
+
const { slots } = useDetailPageHost();
|
|
447
|
+
const [saveError, setSaveError] = useState<string | null>(null);
|
|
448
|
+
const [saveOk, setSaveOk] = useState(false);
|
|
449
|
+
// 편집 한도 소진 시 "편집 크레딧 추가하기" → pricing 모달을 인플레이스로 연다.
|
|
450
|
+
const [pricingOpen, setPricingOpen] = useState(false);
|
|
451
|
+
const [qaJson, setQaJson] = useState<CanvasJson>(initialDocument.canvas_json);
|
|
452
|
+
const [overlayEnabled, setOverlayEnabled] = useState(false);
|
|
453
|
+
const [summaryCopied, setSummaryCopied] = useState(false);
|
|
454
|
+
// 어댑터를 안 거친다. `adaptLeviosaCustomProps`는 SDK가 **못 읽는** 장식을 읽을 수
|
|
455
|
+
// 있는 모양으로 낮춰 주던 일이었고(그림자·그라데이션 → 대표 단색), 우리 렌더러는
|
|
456
|
+
// 그 필드들을 원본 그대로 읽는다. 낮춘 것을 넣으면 오히려 원본보다 못한 그림이 된다.
|
|
457
|
+
// 브랜드 자산 주소는 바이트 경로로 옮겨서 연다 — 302 를 따라가면 CORS 가 깨져서
|
|
458
|
+
// 캔버스가 그림을 못 읽는다(asset-bytes-url 에 실측 표를 적어 뒀다).
|
|
459
|
+
const store = useMemo(() => {
|
|
460
|
+
ensureCanvasKey();
|
|
461
|
+
return createCanvasStore(
|
|
462
|
+
normalizeDocumentAssetSrcs(initialDocument.canvas_json) as DocumentJson,
|
|
463
|
+
);
|
|
464
|
+
}, [initialDocument.canvas_json]);
|
|
465
|
+
|
|
466
|
+
const qaSnapshot = useMemo(
|
|
467
|
+
() => buildQaSnapshot(initialDocument, qaJson),
|
|
468
|
+
[initialDocument, qaJson],
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
// 글꼴 준비. 문서가 쓰는 얼굴을 전부 받아 놓고, 다 온 뒤에 줄바꿈 손질
|
|
472
|
+
// (`applyTextLineFit`)을 한 번 돌린다 — 폴백 서체로 재면 과대측정해서 손질이
|
|
473
|
+
// 건너뛰어진다. 하니스(`/dev-canvas`)가 쓰는 순서와 같다.
|
|
474
|
+
useEffect(() => {
|
|
475
|
+
let cancelled = false;
|
|
476
|
+
void Promise.all(
|
|
477
|
+
collectFontRequests(store).map((request) =>
|
|
478
|
+
loadEditorFont(request).catch(() => undefined),
|
|
479
|
+
),
|
|
480
|
+
)
|
|
481
|
+
.then(() => document.fonts.ready)
|
|
482
|
+
.then(() => {
|
|
483
|
+
if (!cancelled) applyTextLineFit(store);
|
|
484
|
+
});
|
|
485
|
+
return () => {
|
|
486
|
+
cancelled = true;
|
|
487
|
+
};
|
|
488
|
+
}, [store]);
|
|
489
|
+
|
|
490
|
+
useEffect(() => {
|
|
491
|
+
// Initial sync is immediate so the QA panel is populated on mount.
|
|
492
|
+
setQaJson(stripQaOverlayElements(store.toJSON() as CanvasJson));
|
|
493
|
+
|
|
494
|
+
// 스토어는 요소를 끄는 내내 "change"를 계속 쏜다 — 끌기·크기조절·타이핑 모두.
|
|
495
|
+
// or typed into. Recomputing the QA snapshot on every event deep-clones and
|
|
496
|
+
// walks the whole document many times per second, which freezes the canvas
|
|
497
|
+
// layer on large detail-page documents. The QA panel does not need live
|
|
498
|
+
// updates mid-gesture, so coalesce changes with a trailing debounce: the
|
|
499
|
+
// snapshot refreshes ~300ms after the user stops interacting.
|
|
500
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
501
|
+
// 바뀐 문서를 이벤트에 실어 받지 않고 **잠잠해진 뒤에** 한 번 읽는다. 글자 한 자마다
|
|
502
|
+
// 문서 전체를 직렬화해 넘기면 그 비용이 타이핑 지연으로 그대로 보인다.
|
|
503
|
+
const flush = () => {
|
|
504
|
+
timer = null;
|
|
505
|
+
setQaJson(stripQaOverlayElements(store.toJSON() as CanvasJson));
|
|
506
|
+
};
|
|
507
|
+
const off = store.on("change", () => {
|
|
508
|
+
if (timer) clearTimeout(timer);
|
|
509
|
+
timer = setTimeout(flush, 300);
|
|
510
|
+
});
|
|
511
|
+
return () => {
|
|
512
|
+
if (timer) clearTimeout(timer);
|
|
513
|
+
off();
|
|
514
|
+
};
|
|
515
|
+
}, [store]);
|
|
516
|
+
|
|
517
|
+
useEffect(() => {
|
|
518
|
+
setSummaryCopied(false);
|
|
519
|
+
}, [qaSnapshot.summary]);
|
|
520
|
+
|
|
521
|
+
const removeOverlayElements = () => {
|
|
522
|
+
const ids: string[] = [];
|
|
523
|
+
walkElements(store.toJSON() as CanvasJson, (element) => {
|
|
524
|
+
if (element.id && isQaOverlayElement(element)) ids.push(element.id);
|
|
525
|
+
});
|
|
526
|
+
if (ids.length) store.deleteElements(ids);
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
const addOverlayElements = () => {
|
|
530
|
+
removeOverlayElements();
|
|
531
|
+
const cleanJson = qaSnapshot.json;
|
|
532
|
+
const pages = asArray(cleanJson.pages) as Array<Record<string, unknown>>;
|
|
533
|
+
for (const slot of qaSnapshot.slots) {
|
|
534
|
+
if (!slot.element || !slot.element.id || !slot.pageId) continue;
|
|
535
|
+
const pageIndex = pages.findIndex((page) => page.id === slot.pageId);
|
|
536
|
+
const page = store.pages[pageIndex >= 0 ? pageIndex : 0];
|
|
537
|
+
if (!page) continue;
|
|
538
|
+
const x = finiteNumber(slot.element.x) ?? 0;
|
|
539
|
+
const y = finiteNumber(slot.element.y) ?? 0;
|
|
540
|
+
const width = Math.max(finiteNumber(slot.element.width) ?? 1, 1);
|
|
541
|
+
const height = Math.max(finiteNumber(slot.element.height) ?? 1, 1);
|
|
542
|
+
const color = slot.state === "error" ? "#dc2626" : slot.state === "warning" ? "#d97706" : "#059669";
|
|
543
|
+
page.addElement(
|
|
544
|
+
{
|
|
545
|
+
id: `${QA_OVERLAY_PREFIX}-box-${slot.element.id}`,
|
|
546
|
+
type: "figure",
|
|
547
|
+
subType: "rect",
|
|
548
|
+
x,
|
|
549
|
+
y,
|
|
550
|
+
width,
|
|
551
|
+
height,
|
|
552
|
+
fill: "rgba(255,255,255,0)",
|
|
553
|
+
stroke: color,
|
|
554
|
+
strokeWidth: 3,
|
|
555
|
+
dash: [10, 6] as never,
|
|
556
|
+
selectable: false,
|
|
557
|
+
removable: false,
|
|
558
|
+
alwaysOnTop: true,
|
|
559
|
+
showInExport: false,
|
|
560
|
+
custom: { [QA_OVERLAY_CUSTOM_KEY]: true, slot: slot.slot },
|
|
561
|
+
},
|
|
562
|
+
{ skipSelect: true },
|
|
563
|
+
);
|
|
564
|
+
page.addElement(
|
|
565
|
+
{
|
|
566
|
+
id: `${QA_OVERLAY_PREFIX}-label-${slot.element.id}`,
|
|
567
|
+
type: "text",
|
|
568
|
+
x,
|
|
569
|
+
y: Math.max(0, y - 30),
|
|
570
|
+
width: Math.max(120, Math.min(width, 320)),
|
|
571
|
+
height: 24,
|
|
572
|
+
text: slot.slot,
|
|
573
|
+
fontSize: 14,
|
|
574
|
+
fontWeight: "700",
|
|
575
|
+
fill: "#ffffff",
|
|
576
|
+
backgroundEnabled: true,
|
|
577
|
+
backgroundColor: color,
|
|
578
|
+
backgroundOpacity: 0.92,
|
|
579
|
+
backgroundCornerRadius: 4,
|
|
580
|
+
backgroundPadding: 6,
|
|
581
|
+
selectable: false,
|
|
582
|
+
removable: false,
|
|
583
|
+
alwaysOnTop: true,
|
|
584
|
+
showInExport: false,
|
|
585
|
+
custom: { [QA_OVERLAY_CUSTOM_KEY]: true, slot: slot.slot },
|
|
586
|
+
},
|
|
587
|
+
{ skipSelect: true },
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
useEffect(() => {
|
|
593
|
+
if (overlayEnabled) addOverlayElements();
|
|
594
|
+
else removeOverlayElements();
|
|
595
|
+
return removeOverlayElements;
|
|
596
|
+
// The overlay is intentionally regenerated from the latest QA snapshot.
|
|
597
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
598
|
+
}, [overlayEnabled, qaSnapshot.summary, store]);
|
|
599
|
+
|
|
600
|
+
const handleSave = async () => {
|
|
601
|
+
setSaveError(null);
|
|
602
|
+
setSaveOk(false);
|
|
603
|
+
try {
|
|
604
|
+
// Drop QA overlays and revert gray image placeholders to empty src so the
|
|
605
|
+
// persisted document never carries the editor-only placeholder.
|
|
606
|
+
const cleanJson = clearPlaceholderImageSrc(
|
|
607
|
+
stripQaOverlayElements(store.toJSON() as CanvasJson),
|
|
608
|
+
);
|
|
609
|
+
await onSave({
|
|
610
|
+
...initialDocument,
|
|
611
|
+
canvas_json: cleanJson,
|
|
612
|
+
});
|
|
613
|
+
setSaveOk(true);
|
|
614
|
+
} catch (error) {
|
|
615
|
+
setSaveError(error instanceof Error ? error.message : t("editor.saveError"));
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
const handleRefreshQa = () => {
|
|
620
|
+
setQaJson(stripQaOverlayElements(store.toJSON() as CanvasJson));
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
const handleSelectSlot = (slot: SlotInspection) => {
|
|
624
|
+
if (slot.binding?.element_id) {
|
|
625
|
+
store.selectElements([slot.binding.element_id]);
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
const handleCopySummary = async () => {
|
|
630
|
+
await navigator.clipboard.writeText(qaSnapshot.summary);
|
|
631
|
+
setSummaryCopied(true);
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
const handleDownloadSummary = () => {
|
|
635
|
+
const blob = new Blob([qaSnapshot.summary], { type: "text/plain;charset=utf-8" });
|
|
636
|
+
const url = URL.createObjectURL(blob);
|
|
637
|
+
const link = document.createElement("a");
|
|
638
|
+
link.href = url;
|
|
639
|
+
link.download = `${initialDocument.template_id ?? "canvas-template"}-qa-summary.txt`;
|
|
640
|
+
link.click();
|
|
641
|
+
URL.revokeObjectURL(url);
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
// The canvas subtree only depends on the stable store and sidebar
|
|
645
|
+
// contract. Memoize it so QA/save/overlay updates never recreate the heavy
|
|
646
|
+
// canvas tree.
|
|
647
|
+
const sidebarSections = useMemo(
|
|
648
|
+
() =>
|
|
649
|
+
buildDetailPageSections({
|
|
650
|
+
t,
|
|
651
|
+
onGenerateImage,
|
|
652
|
+
onGenerateGif,
|
|
653
|
+
gifCreditCost,
|
|
654
|
+
uploadFile,
|
|
655
|
+
imageCreditCost,
|
|
656
|
+
imageCreditBalance,
|
|
657
|
+
imageCostByTier,
|
|
658
|
+
onBuyImageCredits: () => setPricingOpen(true),
|
|
659
|
+
structurePanel,
|
|
660
|
+
generatedId,
|
|
661
|
+
}),
|
|
662
|
+
[
|
|
663
|
+
t,
|
|
664
|
+
onGenerateImage,
|
|
665
|
+
onGenerateGif,
|
|
666
|
+
gifCreditCost,
|
|
667
|
+
uploadFile,
|
|
668
|
+
imageCreditCost,
|
|
669
|
+
imageCreditBalance,
|
|
670
|
+
imageCostByTier,
|
|
671
|
+
structurePanel,
|
|
672
|
+
generatedId,
|
|
673
|
+
],
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
const canvas = useMemo(() => {
|
|
677
|
+
// ⌘G / ⌘Z: 우리 손버릇 맵을 쓴다(`edit/hotkeys.ts`).
|
|
678
|
+
const hotkeys = <EditorHotkeys store={store} />;
|
|
679
|
+
// ⌘F: 20섹션에 흩어진 브랜드명·용량 표기를 한 번에 고친다.
|
|
680
|
+
const findReplace = <FindReplacePanel store={store} />;
|
|
681
|
+
|
|
682
|
+
return (
|
|
683
|
+
<div className="absolute inset-0 flex">
|
|
684
|
+
{hotkeys}
|
|
685
|
+
<LeviosaSidePanel
|
|
686
|
+
store={store}
|
|
687
|
+
sections={sidebarSections as never}
|
|
688
|
+
defaultSection="pages"
|
|
689
|
+
/>
|
|
690
|
+
<div className="relative min-w-0 flex-1">
|
|
691
|
+
<LeviosaCanvasWorkspace store={store} gap={4}>
|
|
692
|
+
{findReplace}
|
|
693
|
+
<DetailPagePagesTimeline store={store} />
|
|
694
|
+
</LeviosaCanvasWorkspace>
|
|
695
|
+
<div className="absolute bottom-16 left-1/2 z-20 -translate-x-1/2 rounded-lg border border-neutral-200 bg-white/95 px-2 py-1 shadow-sm backdrop-blur-sm">
|
|
696
|
+
<LeviosaZoomButtons store={store} />
|
|
697
|
+
</div>
|
|
698
|
+
</div>
|
|
699
|
+
</div>
|
|
700
|
+
);
|
|
701
|
+
}, [store, sidebarSections]);
|
|
702
|
+
|
|
703
|
+
return (
|
|
704
|
+
// 꽂혀 있으면 `observer`로 싼 패널·오버레이가 이 스토어의 변경 신호를 받는다
|
|
705
|
+
// (canvas-observer.tsx).
|
|
706
|
+
<CanvasStoreContext.Provider value={store}>
|
|
707
|
+
<div className="flex h-screen min-h-[640px] flex-col bg-neutral-100">
|
|
708
|
+
{/* hookable식 상단 헤더: 뒤로가기 · 상품명 · (되돌리기/다시실행) · 저장 ·
|
|
709
|
+
다운로드 · 앱 공용 크롬(크레딧/알림/언어, 호스트 주입). 높이를 고정하고
|
|
710
|
+
본문은 flex-1 min-h-0으로 두어, 헤더 높이가 바뀌어도 캔버스가 남는 높이를
|
|
711
|
+
정확히 채워 페이지 하단이 잘리지 않는다. */}
|
|
712
|
+
<header className="sticky top-0 z-20 flex h-14 shrink-0 items-center gap-1.5 border-b border-neutral-200 bg-white px-3">
|
|
713
|
+
{onBack ? (
|
|
714
|
+
<button
|
|
715
|
+
type="button"
|
|
716
|
+
onClick={onBack}
|
|
717
|
+
aria-label={t("editor.back")}
|
|
718
|
+
title={t("editor.back")}
|
|
719
|
+
className="flex h-9 w-9 items-center justify-center rounded-md text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900"
|
|
720
|
+
>
|
|
721
|
+
<ChevronLeft aria-hidden="true" size={20} />
|
|
722
|
+
</button>
|
|
723
|
+
) : null}
|
|
724
|
+
<p className="ml-1 max-w-[280px] truncate text-sm font-semibold text-neutral-900">
|
|
725
|
+
{productName?.trim() || t("editor.untitled")}
|
|
726
|
+
</p>
|
|
727
|
+
|
|
728
|
+
<div className="mx-auto" />
|
|
729
|
+
|
|
730
|
+
<DetailPageHistoryButtons store={store} />
|
|
731
|
+
<span className="mx-1 h-5 w-px bg-neutral-200" aria-hidden="true" />
|
|
732
|
+
|
|
733
|
+
{saveOk ? (
|
|
734
|
+
<span className="hidden text-xs font-medium text-emerald-600 sm:inline">
|
|
735
|
+
{t("editor.saved")}
|
|
736
|
+
</span>
|
|
737
|
+
) : null}
|
|
738
|
+
{saveError ? (
|
|
739
|
+
<span className="hidden max-w-[180px] truncate text-xs font-medium text-red-600 sm:inline">
|
|
740
|
+
{saveError}
|
|
741
|
+
</span>
|
|
742
|
+
) : null}
|
|
743
|
+
<button
|
|
744
|
+
type="button"
|
|
745
|
+
onClick={handleSave}
|
|
746
|
+
disabled={saving}
|
|
747
|
+
className="inline-flex h-9 items-center gap-2 rounded-md border border-neutral-200 bg-white px-3 text-sm font-semibold text-neutral-900 hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-50"
|
|
748
|
+
>
|
|
749
|
+
<Save aria-hidden="true" size={16} />
|
|
750
|
+
{saving ? t("editor.saving") : t("editor.save")}
|
|
751
|
+
</button>
|
|
752
|
+
<DetailPageDownloadDialog
|
|
753
|
+
store={store}
|
|
754
|
+
fileName={initialDocument.template_id ?? "detail-page"}
|
|
755
|
+
slotBindings={initialDocument.slot_bindings}
|
|
756
|
+
/>
|
|
757
|
+
|
|
758
|
+
{headerActions ? (
|
|
759
|
+
<>
|
|
760
|
+
<span className="mx-1 h-5 w-px bg-neutral-200" aria-hidden="true" />
|
|
761
|
+
{headerActions}
|
|
762
|
+
</>
|
|
763
|
+
) : null}
|
|
764
|
+
</header>
|
|
765
|
+
<div className="grid min-h-0 flex-1 grid-cols-[1fr_360px]">
|
|
766
|
+
{/* 캔버스 칸의 높이를 못 박는다. 안 그러면 그리드 행이 내용만큼 늘어나(높이
|
|
767
|
+
100%가 auto로 풀린다) 작업 영역이 화면 아래로 자라고, 아래 붙는 배율·화면
|
|
768
|
+
띠가 화면 밖으로 밀린다. */}
|
|
769
|
+
<div className="relative min-w-0 overflow-hidden">{canvas}</div>
|
|
770
|
+
<aside className="flex min-h-0 flex-col border-l border-neutral-200 bg-white">
|
|
771
|
+
{/* Figma-style properties inspector — 상단 툴바 대신 오른쪽에 둔다. */}
|
|
772
|
+
<div className="min-h-0 flex-1">
|
|
773
|
+
<DetailPageProperties
|
|
774
|
+
store={store}
|
|
775
|
+
generatedId={generatedId}
|
|
776
|
+
onBuyEditCredits={() => setPricingOpen(true)}
|
|
777
|
+
imageCreditCost={imageCreditCost}
|
|
778
|
+
imageCreditBalance={imageCreditBalance}
|
|
779
|
+
imageCostByTier={imageCostByTier}
|
|
780
|
+
onGenerateGif={onGenerateGif}
|
|
781
|
+
gifCreditCost={gifCreditCost}
|
|
782
|
+
onGenerateTextGif={onGenerateTextGif}
|
|
783
|
+
textGifCreditCost={textGifCreditCost}
|
|
784
|
+
onGenerateImageGif={onGenerateImageGif}
|
|
785
|
+
imageGifCreditCost={imageGifCreditCost}
|
|
786
|
+
onGenerateDataGif={onGenerateDataGif}
|
|
787
|
+
dataGifCreditCost={dataGifCreditCost}
|
|
788
|
+
onRemoveBackground={onRemoveBackground}
|
|
789
|
+
bgRemoveCreditCost={bgRemoveCreditCost}
|
|
790
|
+
/>
|
|
791
|
+
</div>
|
|
792
|
+
|
|
793
|
+
{/* Template QA / slot inspector / lint — authoring tools, not seller-facing. */}
|
|
794
|
+
<details className="border-t border-neutral-200">
|
|
795
|
+
<summary className="cursor-pointer p-4 text-xs font-semibold uppercase tracking-[0.06em] text-neutral-400 hover:text-neutral-600">
|
|
796
|
+
개발자 도구 · 템플릿 QA
|
|
797
|
+
</summary>
|
|
798
|
+
<div className="flex max-h-[55vh] min-h-0 flex-col overflow-y-auto">
|
|
799
|
+
<div className="border-b border-neutral-200 p-4">
|
|
800
|
+
<div className="flex items-center justify-between gap-3">
|
|
801
|
+
<div>
|
|
802
|
+
<h2 className="flex items-center gap-2 text-sm font-semibold text-neutral-950">
|
|
803
|
+
<ListChecks aria-hidden="true" size={16} />
|
|
804
|
+
{t("editor.qaTitle")}
|
|
805
|
+
</h2>
|
|
806
|
+
<p className="mt-1 text-xs text-neutral-500">
|
|
807
|
+
{qaSnapshot.counts.pages} pages / {qaSnapshot.counts.elements} elements /{" "}
|
|
808
|
+
{qaSnapshot.counts.slots} slots
|
|
809
|
+
</p>
|
|
810
|
+
</div>
|
|
811
|
+
<button
|
|
812
|
+
type="button"
|
|
813
|
+
onClick={handleRefreshQa}
|
|
814
|
+
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-neutral-200 text-neutral-600 hover:bg-neutral-50"
|
|
815
|
+
title={t("editor.refreshQa")}
|
|
816
|
+
>
|
|
817
|
+
<RefreshCw aria-hidden="true" size={15} />
|
|
818
|
+
</button>
|
|
819
|
+
</div>
|
|
820
|
+
<div className="mt-4 grid grid-cols-3 gap-2 text-center text-xs">
|
|
821
|
+
<div className="rounded-md border border-neutral-200 p-2">
|
|
822
|
+
<div className="text-base font-semibold text-neutral-950">{qaSnapshot.counts.slots}</div>
|
|
823
|
+
<div className="text-neutral-500">slots</div>
|
|
824
|
+
</div>
|
|
825
|
+
<div className="rounded-md border border-red-100 bg-red-50 p-2">
|
|
826
|
+
<div className="text-base font-semibold text-red-700">{qaSnapshot.counts.errors}</div>
|
|
827
|
+
<div className="text-red-600">errors</div>
|
|
828
|
+
</div>
|
|
829
|
+
<div className="rounded-md border border-amber-100 bg-amber-50 p-2">
|
|
830
|
+
<div className="text-base font-semibold text-amber-700">{qaSnapshot.counts.warnings}</div>
|
|
831
|
+
<div className="text-amber-600">warnings</div>
|
|
832
|
+
</div>
|
|
833
|
+
</div>
|
|
834
|
+
<div className="mt-4 flex gap-2">
|
|
835
|
+
<button
|
|
836
|
+
type="button"
|
|
837
|
+
onClick={() => setOverlayEnabled((value) => !value)}
|
|
838
|
+
className="inline-flex h-9 flex-1 items-center justify-center gap-2 rounded-md border border-neutral-200 text-xs font-semibold text-neutral-700 hover:bg-neutral-50"
|
|
839
|
+
>
|
|
840
|
+
{overlayEnabled ? <EyeOff aria-hidden="true" size={15} /> : <Eye aria-hidden="true" size={15} />}
|
|
841
|
+
{overlayEnabled ? t("editor.overlayOff") : t("editor.overlayOn")}
|
|
842
|
+
</button>
|
|
843
|
+
<button
|
|
844
|
+
type="button"
|
|
845
|
+
onClick={handleCopySummary}
|
|
846
|
+
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-neutral-200 text-neutral-700 hover:bg-neutral-50"
|
|
847
|
+
title={t("editor.copySummary")}
|
|
848
|
+
>
|
|
849
|
+
<Copy aria-hidden="true" size={15} />
|
|
850
|
+
</button>
|
|
851
|
+
<button
|
|
852
|
+
type="button"
|
|
853
|
+
onClick={handleDownloadSummary}
|
|
854
|
+
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-neutral-200 text-neutral-700 hover:bg-neutral-50"
|
|
855
|
+
title={t("editor.downloadSummary")}
|
|
856
|
+
>
|
|
857
|
+
<Download aria-hidden="true" size={15} />
|
|
858
|
+
</button>
|
|
859
|
+
</div>
|
|
860
|
+
{summaryCopied ? (
|
|
861
|
+
<p className="mt-2 text-xs font-medium text-emerald-600">{t("editor.summaryCopied")}</p>
|
|
862
|
+
) : null}
|
|
863
|
+
</div>
|
|
864
|
+
|
|
865
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
|
866
|
+
<section>
|
|
867
|
+
<h3 className="text-xs font-semibold uppercase tracking-[0.08em] text-neutral-500">
|
|
868
|
+
{t("editor.slotInspector")}
|
|
869
|
+
</h3>
|
|
870
|
+
<div className="mt-3 space-y-2">
|
|
871
|
+
{qaSnapshot.slots.length ? (
|
|
872
|
+
qaSnapshot.slots.map((slot) => (
|
|
873
|
+
<button
|
|
874
|
+
key={slot.slot}
|
|
875
|
+
type="button"
|
|
876
|
+
onClick={() => handleSelectSlot(slot)}
|
|
877
|
+
className="w-full rounded-md border border-neutral-200 p-3 text-left hover:border-neutral-300 hover:bg-neutral-50"
|
|
878
|
+
>
|
|
879
|
+
<div className="flex items-center justify-between gap-2">
|
|
880
|
+
<span className="truncate text-sm font-semibold text-neutral-950">{slot.slot}</span>
|
|
881
|
+
<span
|
|
882
|
+
className={
|
|
883
|
+
slot.state === "error"
|
|
884
|
+
? "text-xs font-semibold text-red-600"
|
|
885
|
+
: slot.state === "warning"
|
|
886
|
+
? "text-xs font-semibold text-amber-600"
|
|
887
|
+
: "text-xs font-semibold text-emerald-600"
|
|
888
|
+
}
|
|
889
|
+
>
|
|
890
|
+
{slot.state}
|
|
891
|
+
</span>
|
|
892
|
+
</div>
|
|
893
|
+
<p className="mt-1 truncate text-xs text-neutral-500">
|
|
894
|
+
{slot.binding?.kind ?? "-"} / {slot.binding?.element_id ?? "-"}
|
|
895
|
+
</p>
|
|
896
|
+
<p className="mt-1 text-xs text-neutral-600">{formatRect(slot.element)}</p>
|
|
897
|
+
<p className="mt-2 text-xs text-neutral-500">{slot.message}</p>
|
|
898
|
+
</button>
|
|
899
|
+
))
|
|
900
|
+
) : (
|
|
901
|
+
<p className="rounded-md border border-neutral-200 p-3 text-sm text-neutral-500">
|
|
902
|
+
{t("editor.noSlots")}
|
|
903
|
+
</p>
|
|
904
|
+
)}
|
|
905
|
+
</div>
|
|
906
|
+
</section>
|
|
907
|
+
|
|
908
|
+
<section className="mt-6">
|
|
909
|
+
<h3 className="text-xs font-semibold uppercase tracking-[0.08em] text-neutral-500">
|
|
910
|
+
{t("editor.templateLint")}
|
|
911
|
+
</h3>
|
|
912
|
+
<div className="mt-3 space-y-2">
|
|
913
|
+
{qaSnapshot.lint.length ? (
|
|
914
|
+
qaSnapshot.lint.map((issue, index) => (
|
|
915
|
+
<div
|
|
916
|
+
key={`${issue.title}-${issue.slot ?? issue.elementId ?? index}`}
|
|
917
|
+
className="rounded-md border border-neutral-200 p-3"
|
|
918
|
+
>
|
|
919
|
+
<div className="flex items-center justify-between gap-2">
|
|
920
|
+
<p className="text-sm font-semibold text-neutral-950">{issue.title}</p>
|
|
921
|
+
<span
|
|
922
|
+
className={
|
|
923
|
+
issue.level === "error"
|
|
924
|
+
? "text-xs font-semibold text-red-600"
|
|
925
|
+
: issue.level === "warning"
|
|
926
|
+
? "text-xs font-semibold text-amber-600"
|
|
927
|
+
: "text-xs font-semibold text-blue-600"
|
|
928
|
+
}
|
|
929
|
+
>
|
|
930
|
+
{issue.level}
|
|
931
|
+
</span>
|
|
932
|
+
</div>
|
|
933
|
+
<p className="mt-1 text-xs leading-5 text-neutral-600">{issue.detail}</p>
|
|
934
|
+
{issue.slot || issue.elementId ? (
|
|
935
|
+
<p className="mt-2 truncate text-xs text-neutral-400">
|
|
936
|
+
{[issue.slot, issue.elementId].filter(Boolean).join(" / ")}
|
|
937
|
+
</p>
|
|
938
|
+
) : null}
|
|
939
|
+
</div>
|
|
940
|
+
))
|
|
941
|
+
) : (
|
|
942
|
+
<div className="rounded-md border border-emerald-100 bg-emerald-50 p-3 text-sm font-medium text-emerald-700">
|
|
943
|
+
{t("editor.lintPassed")}
|
|
944
|
+
</div>
|
|
945
|
+
)}
|
|
946
|
+
</div>
|
|
947
|
+
</section>
|
|
948
|
+
|
|
949
|
+
<section className="mt-6">
|
|
950
|
+
<h3 className="text-xs font-semibold uppercase tracking-[0.08em] text-neutral-500">
|
|
951
|
+
{t("editor.designerSummary")}
|
|
952
|
+
</h3>
|
|
953
|
+
<pre className="mt-3 max-h-72 overflow-auto rounded-md border border-neutral-200 bg-neutral-50 p-3 text-xs leading-5 text-neutral-700">
|
|
954
|
+
{qaSnapshot.summary}
|
|
955
|
+
</pre>
|
|
956
|
+
</section>
|
|
957
|
+
</div>
|
|
958
|
+
</div>
|
|
959
|
+
</details>
|
|
960
|
+
</aside>
|
|
961
|
+
</div>
|
|
962
|
+
{/* 화면 하나만 마크업째 다시 저작 — 요청은 캔버스 옆 페이지 툴바에서 온다. */}
|
|
963
|
+
<SectionReauthorController
|
|
964
|
+
store={store}
|
|
965
|
+
generatedId={generatedId}
|
|
966
|
+
templateId={initialDocument.template_id ?? undefined}
|
|
967
|
+
/>
|
|
968
|
+
{slots?.PricingModal ? (
|
|
969
|
+
<slots.PricingModal open={pricingOpen} onOpenChange={setPricingOpen} />
|
|
970
|
+
) : null}
|
|
971
|
+
</div>
|
|
972
|
+
</CanvasStoreContext.Provider>
|
|
973
|
+
);
|
|
974
|
+
}
|