@leviosa-ai/detail-page-editor 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/detail-page/detail-page-editor.tsx +52 -648
- package/components/detail-page/detail-page-host-context.tsx +10 -28
- package/components/detail-page/detail-page-page-toolbar.tsx +25 -150
- package/components/detail-page/detail-page-pages-panel.tsx +69 -11
- package/components/detail-page/detail-page-properties-panel.tsx +29 -8
- package/components/detail-page/detail-page-sidebar-sections.tsx +5 -14
- package/components/detail-page/use-auto-save.ts +105 -0
- package/i18n/en.json +1 -16
- package/i18n/ko.json +1 -16
- package/index.ts +1 -0
- package/lib/detail-page/selection-actions.ts +15 -8
- package/package.json +1 -1
- package/components/detail-page/detail-page-brand-kit-panel.tsx +0 -77
|
@@ -1,20 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
4
4
|
import { useTranslation } from "react-i18next";
|
|
5
5
|
import { EditorHotkeys } from "./editor-hotkeys";
|
|
6
|
+
import { useAutoSave, type SaveReason } from "./use-auto-save";
|
|
6
7
|
import { FindReplacePanel } from "./find-replace-panel";
|
|
7
8
|
|
|
8
|
-
import {
|
|
9
|
-
ChevronLeft,
|
|
10
|
-
Copy,
|
|
11
|
-
Download,
|
|
12
|
-
Eye,
|
|
13
|
-
EyeOff,
|
|
14
|
-
ListChecks,
|
|
15
|
-
RefreshCw,
|
|
16
|
-
Save,
|
|
17
|
-
} from "lucide-react";
|
|
9
|
+
import { ChevronLeft, Save } from "lucide-react";
|
|
18
10
|
|
|
19
11
|
import {
|
|
20
12
|
applyTextLineFit,
|
|
@@ -49,16 +41,28 @@ import type {
|
|
|
49
41
|
GenerateDataGifFn,
|
|
50
42
|
RemoveBackgroundFn,
|
|
51
43
|
} from "./ai-generate-panel";
|
|
52
|
-
import type {
|
|
53
|
-
LeviosaCanvasDocument,
|
|
54
|
-
CanvasSlotBinding,
|
|
55
|
-
} from "../../types/detail-page-canvas";
|
|
44
|
+
import type { LeviosaCanvasDocument } from "../../types/detail-page-canvas";
|
|
56
45
|
|
|
57
46
|
export type DetailPageEditorProps = {
|
|
58
47
|
initialDocument: LeviosaCanvasDocument;
|
|
59
48
|
saving?: boolean;
|
|
60
49
|
uploadFile?: (file: File) => Promise<string>;
|
|
61
|
-
|
|
50
|
+
/**
|
|
51
|
+
* 문서를 저장한다. `reason` 은 왜 지금 저장하는가다 — 앱이 이걸 보고 무거운 뒷일
|
|
52
|
+
* (상세페이지의 HTML 굽기 같은)을 할지 말지 고른다. 자동저장은 `auto`, 저장 버튼은
|
|
53
|
+
* `manual`, 탭을 닫거나 편집기를 떠날 때는 `leave` 로 온다.
|
|
54
|
+
*/
|
|
55
|
+
onSave: (
|
|
56
|
+
document: LeviosaCanvasDocument,
|
|
57
|
+
meta: { reason: SaveReason },
|
|
58
|
+
) => Promise<void>;
|
|
59
|
+
/**
|
|
60
|
+
* 자동저장 간격(ms). 없으면 자동저장을 안 한다.
|
|
61
|
+
*
|
|
62
|
+
* 저장이 무거운 화면은 길게 잡는다 — 상세페이지 저장은 서버가 HTML 을 새로 만들어
|
|
63
|
+
* S3 에 올리고, 캐러셀 저장은 문서 JSON 만 넣는다.
|
|
64
|
+
*/
|
|
65
|
+
autoSaveDelayMs?: number;
|
|
62
66
|
/** AI 생성 탭의 프롬프트 → 이미지 URL 콜백. 없으면 패널은 안내만 표시. */
|
|
63
67
|
onGenerateImage?: GenerateImageFn;
|
|
64
68
|
/** AI 생성 탭의 프롬프트 → 애니메이션 GIF URL 콜백. 없으면 GIF 모드는 안내만 표시. */
|
|
@@ -124,313 +128,12 @@ export type DetailPageEditorProps = {
|
|
|
124
128
|
|
|
125
129
|
type CanvasJson = Record<string, unknown>;
|
|
126
130
|
|
|
127
|
-
type CanvasJsonElement = Record<string, unknown> & {
|
|
128
|
-
id?: string;
|
|
129
|
-
type?: string;
|
|
130
|
-
x?: number;
|
|
131
|
-
y?: number;
|
|
132
|
-
width?: number;
|
|
133
|
-
height?: number;
|
|
134
|
-
text?: string;
|
|
135
|
-
src?: string;
|
|
136
|
-
custom?: Record<string, unknown>;
|
|
137
|
-
children?: CanvasJsonElement[];
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
type SlotInspection = {
|
|
141
|
-
slot: string;
|
|
142
|
-
binding?: CanvasSlotBinding;
|
|
143
|
-
element?: CanvasJsonElement;
|
|
144
|
-
pageId?: string;
|
|
145
|
-
state: "ok" | "warning" | "error";
|
|
146
|
-
message: string;
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
type TemplateLintIssue = {
|
|
150
|
-
level: "error" | "warning" | "info";
|
|
151
|
-
title: string;
|
|
152
|
-
detail: string;
|
|
153
|
-
slot?: string;
|
|
154
|
-
elementId?: string;
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
type TemplateQaSnapshot = {
|
|
158
|
-
json: CanvasJson;
|
|
159
|
-
slots: SlotInspection[];
|
|
160
|
-
lint: TemplateLintIssue[];
|
|
161
|
-
summary: string;
|
|
162
|
-
counts: {
|
|
163
|
-
pages: number;
|
|
164
|
-
elements: number;
|
|
165
|
-
slots: number;
|
|
166
|
-
errors: number;
|
|
167
|
-
warnings: number;
|
|
168
|
-
};
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
const QA_OVERLAY_CUSTOM_KEY = "leviosaQaOverlay";
|
|
172
|
-
const QA_OVERLAY_PREFIX = "leviosa-qa-overlay";
|
|
173
|
-
|
|
174
|
-
function asRecord(value: unknown): Record<string, unknown> {
|
|
175
|
-
return value && typeof value === "object" && !Array.isArray(value)
|
|
176
|
-
? (value as Record<string, unknown>)
|
|
177
|
-
: {};
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function asArray(value: unknown): unknown[] {
|
|
181
|
-
return Array.isArray(value) ? value : [];
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function finiteNumber(value: unknown): number | null {
|
|
185
|
-
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function isQaOverlayElement(element: CanvasJsonElement): boolean {
|
|
189
|
-
return element.id?.startsWith(`${QA_OVERLAY_PREFIX}-`) === true ||
|
|
190
|
-
element.custom?.[QA_OVERLAY_CUSTOM_KEY] === true;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function walkElements(
|
|
194
|
-
json: CanvasJson,
|
|
195
|
-
visitor: (element: CanvasJsonElement, page: Record<string, unknown>) => void,
|
|
196
|
-
) {
|
|
197
|
-
for (const page of asArray(json.pages) as Array<Record<string, unknown>>) {
|
|
198
|
-
const visit = (element: CanvasJsonElement) => {
|
|
199
|
-
visitor(element, page);
|
|
200
|
-
for (const child of asArray(element.children) as CanvasJsonElement[]) {
|
|
201
|
-
visit(child);
|
|
202
|
-
}
|
|
203
|
-
};
|
|
204
|
-
for (const element of asArray(page.children) as CanvasJsonElement[]) {
|
|
205
|
-
visit(element);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function stripQaOverlayElements(json: CanvasJson): CanvasJson {
|
|
211
|
-
const clone = JSON.parse(JSON.stringify(json)) as CanvasJson;
|
|
212
|
-
const strip = (elements: CanvasJsonElement[]): CanvasJsonElement[] =>
|
|
213
|
-
elements
|
|
214
|
-
.filter((element) => !isQaOverlayElement(element))
|
|
215
|
-
.map((element) => ({
|
|
216
|
-
...element,
|
|
217
|
-
children: Array.isArray(element.children)
|
|
218
|
-
? strip(element.children)
|
|
219
|
-
: element.children,
|
|
220
|
-
}));
|
|
221
|
-
|
|
222
|
-
clone.pages = (asArray(clone.pages) as Array<Record<string, unknown>>).map((page) => ({
|
|
223
|
-
...page,
|
|
224
|
-
children: strip(asArray(page.children) as CanvasJsonElement[]),
|
|
225
|
-
}));
|
|
226
|
-
return clone;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function getSlotFromElement(element: CanvasJsonElement): string {
|
|
230
|
-
const custom = asRecord(element.custom);
|
|
231
|
-
return typeof custom.leviosaSlot === "string" ? custom.leviosaSlot : "";
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function getElementKind(element?: CanvasJsonElement): CanvasSlotBinding["kind"] | null {
|
|
235
|
-
if (!element) return null;
|
|
236
|
-
if (element.type === "image") return "image";
|
|
237
|
-
if (element.type === "text") return "text";
|
|
238
|
-
return null;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function formatRect(element?: CanvasJsonElement): string {
|
|
242
|
-
if (!element) return "-";
|
|
243
|
-
const x = finiteNumber(element.x) ?? 0;
|
|
244
|
-
const y = finiteNumber(element.y) ?? 0;
|
|
245
|
-
const width = finiteNumber(element.width) ?? 0;
|
|
246
|
-
const height = finiteNumber(element.height) ?? 0;
|
|
247
|
-
return `${Math.round(x)}, ${Math.round(y)} / ${Math.round(width)} x ${Math.round(height)}`;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function buildQaSnapshot(
|
|
251
|
-
document: LeviosaCanvasDocument,
|
|
252
|
-
json: CanvasJson,
|
|
253
|
-
): TemplateQaSnapshot {
|
|
254
|
-
const slotBindings = document.slot_bindings ?? {};
|
|
255
|
-
const cleanJson = stripQaOverlayElements(json);
|
|
256
|
-
const pages = asArray(cleanJson.pages) as Array<Record<string, unknown>>;
|
|
257
|
-
const elementById = new Map<string, { element: CanvasJsonElement; pageId?: string }>();
|
|
258
|
-
const slotByElementId = new Map<string, string[]>();
|
|
259
|
-
const slotsFromElements = new Map<string, { element: CanvasJsonElement; pageId?: string }>();
|
|
260
|
-
let elementCount = 0;
|
|
261
|
-
|
|
262
|
-
walkElements(cleanJson, (element, page) => {
|
|
263
|
-
elementCount += 1;
|
|
264
|
-
if (element.id) {
|
|
265
|
-
const pageId = typeof page.id === "string" ? page.id : undefined;
|
|
266
|
-
elementById.set(element.id, { element, pageId });
|
|
267
|
-
const slot = getSlotFromElement(element);
|
|
268
|
-
if (slot) slotsFromElements.set(slot, { element, pageId });
|
|
269
|
-
}
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
for (const [slot, binding] of Object.entries(slotBindings)) {
|
|
273
|
-
const list = slotByElementId.get(binding.element_id) ?? [];
|
|
274
|
-
list.push(slot);
|
|
275
|
-
slotByElementId.set(binding.element_id, list);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
const lint: TemplateLintIssue[] = [];
|
|
279
|
-
const slots = Object.entries(slotBindings)
|
|
280
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
281
|
-
.map(([slot, binding]) => {
|
|
282
|
-
const target = elementById.get(binding.element_id);
|
|
283
|
-
const element = target?.element;
|
|
284
|
-
const inferredKind = getElementKind(element);
|
|
285
|
-
let state: SlotInspection["state"] = "ok";
|
|
286
|
-
let message = "연결됨";
|
|
287
|
-
|
|
288
|
-
if (!element) {
|
|
289
|
-
state = "error";
|
|
290
|
-
message = "바인딩 대상 요소가 없습니다";
|
|
291
|
-
lint.push({
|
|
292
|
-
level: "error",
|
|
293
|
-
title: "Missing binding target",
|
|
294
|
-
detail: `${binding.element_id} 요소를 찾을 수 없습니다.`,
|
|
295
|
-
slot,
|
|
296
|
-
elementId: binding.element_id,
|
|
297
|
-
});
|
|
298
|
-
} else if (inferredKind && inferredKind !== binding.kind) {
|
|
299
|
-
state = "warning";
|
|
300
|
-
message = `${binding.kind} 슬롯이 ${element.type} 요소에 연결됨`;
|
|
301
|
-
lint.push({
|
|
302
|
-
level: "warning",
|
|
303
|
-
title: "Slot kind mismatch",
|
|
304
|
-
detail: `${binding.kind} 슬롯이 ${element.type} 요소에 연결되어 있습니다.`,
|
|
305
|
-
slot,
|
|
306
|
-
elementId: binding.element_id,
|
|
307
|
-
});
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
if (element?.type === "text" && !String(element.text ?? "").trim()) {
|
|
311
|
-
state = state === "error" ? state : "warning";
|
|
312
|
-
message = "텍스트가 비어 있습니다";
|
|
313
|
-
lint.push({
|
|
314
|
-
level: "warning",
|
|
315
|
-
title: "Empty text slot",
|
|
316
|
-
detail: "텍스트 요소에 표시할 문구가 없습니다.",
|
|
317
|
-
slot,
|
|
318
|
-
elementId: binding.element_id,
|
|
319
|
-
});
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
if (element?.type === "image" && !String(element.src ?? "").trim()) {
|
|
323
|
-
state = state === "error" ? state : "warning";
|
|
324
|
-
message = "이미지 URL이 비어 있습니다";
|
|
325
|
-
lint.push({
|
|
326
|
-
level: "warning",
|
|
327
|
-
title: "Empty image slot",
|
|
328
|
-
detail: "이미지 요소에 src가 없습니다.",
|
|
329
|
-
slot,
|
|
330
|
-
elementId: binding.element_id,
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
const width = finiteNumber(element?.width);
|
|
335
|
-
const height = finiteNumber(element?.height);
|
|
336
|
-
if (element && (width === null || height === null || width <= 0 || height <= 0)) {
|
|
337
|
-
state = state === "error" ? state : "warning";
|
|
338
|
-
message = "요소 크기를 확인하세요";
|
|
339
|
-
lint.push({
|
|
340
|
-
level: "warning",
|
|
341
|
-
title: "Invalid element bounds",
|
|
342
|
-
detail: "요소의 width 또는 height가 0 이하입니다.",
|
|
343
|
-
slot,
|
|
344
|
-
elementId: binding.element_id,
|
|
345
|
-
});
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
return {
|
|
349
|
-
slot,
|
|
350
|
-
binding,
|
|
351
|
-
element,
|
|
352
|
-
pageId: target?.pageId,
|
|
353
|
-
state,
|
|
354
|
-
message,
|
|
355
|
-
};
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
for (const [elementId, slotNames] of slotByElementId) {
|
|
359
|
-
if (slotNames.length > 1) {
|
|
360
|
-
lint.push({
|
|
361
|
-
level: "warning",
|
|
362
|
-
title: "Duplicate element binding",
|
|
363
|
-
detail: `${slotNames.join(", ")} 슬롯이 같은 요소를 공유합니다.`,
|
|
364
|
-
elementId,
|
|
365
|
-
});
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
for (const [slot, target] of slotsFromElements) {
|
|
370
|
-
if (!slotBindings[slot]) {
|
|
371
|
-
lint.push({
|
|
372
|
-
level: "info",
|
|
373
|
-
title: "Unregistered slot marker",
|
|
374
|
-
detail: "요소 custom.leviosaSlot은 있지만 slot_bindings에는 없습니다.",
|
|
375
|
-
slot,
|
|
376
|
-
elementId: target.element.id,
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
const canvasWidth = finiteNumber(document.canvas.width);
|
|
382
|
-
const jsonWidth = finiteNumber(cleanJson.width);
|
|
383
|
-
if (canvasWidth && jsonWidth && canvasWidth !== jsonWidth) {
|
|
384
|
-
lint.push({
|
|
385
|
-
level: "warning",
|
|
386
|
-
title: "Canvas width drift",
|
|
387
|
-
detail: `문서 canvas.width ${canvasWidth}와 문서 JSON width ${jsonWidth}가 다릅니다.`,
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
const errors = lint.filter((issue) => issue.level === "error").length;
|
|
392
|
-
const warnings = lint.filter((issue) => issue.level === "warning").length;
|
|
393
|
-
const summary = [
|
|
394
|
-
`Template: ${document.template_id ?? "unspecified"} v${document.template_version ?? "-"}`,
|
|
395
|
-
`Canvas: ${cleanJson.width ?? document.canvas.width} x ${cleanJson.height ?? "-"}`,
|
|
396
|
-
`Pages: ${pages.length}, Elements: ${elementCount}, Slots: ${slots.length}`,
|
|
397
|
-
`Lint: ${errors} errors, ${warnings} warnings`,
|
|
398
|
-
"",
|
|
399
|
-
"Slots",
|
|
400
|
-
...slots.map(
|
|
401
|
-
(slot) =>
|
|
402
|
-
`- ${slot.slot}: ${slot.state.toUpperCase()} | ${slot.binding?.kind ?? "-"} | ${slot.binding?.element_id ?? "-"} | ${formatRect(slot.element)} | ${slot.message}`,
|
|
403
|
-
),
|
|
404
|
-
"",
|
|
405
|
-
"Lint",
|
|
406
|
-
...(lint.length
|
|
407
|
-
? lint.map(
|
|
408
|
-
(issue) =>
|
|
409
|
-
`- ${issue.level.toUpperCase()}: ${issue.title}${issue.slot ? ` (${issue.slot})` : ""} - ${issue.detail}`,
|
|
410
|
-
)
|
|
411
|
-
: ["- PASS: template lint found no blocking issues"]),
|
|
412
|
-
].join("\n");
|
|
413
|
-
|
|
414
|
-
return {
|
|
415
|
-
json: cleanJson,
|
|
416
|
-
slots,
|
|
417
|
-
lint,
|
|
418
|
-
summary,
|
|
419
|
-
counts: {
|
|
420
|
-
pages: pages.length,
|
|
421
|
-
elements: elementCount,
|
|
422
|
-
slots: slots.length,
|
|
423
|
-
errors,
|
|
424
|
-
warnings,
|
|
425
|
-
},
|
|
426
|
-
};
|
|
427
|
-
}
|
|
428
|
-
|
|
429
131
|
export function DetailPageEditor({
|
|
430
132
|
initialDocument,
|
|
431
133
|
saving = false,
|
|
432
134
|
uploadFile,
|
|
433
135
|
onSave,
|
|
136
|
+
autoSaveDelayMs,
|
|
434
137
|
onGenerateImage,
|
|
435
138
|
onGenerateGif,
|
|
436
139
|
onGenerateTextGif,
|
|
@@ -467,25 +170,23 @@ export function DetailPageEditor({
|
|
|
467
170
|
const [saveOk, setSaveOk] = useState(false);
|
|
468
171
|
// 편집 한도 소진 시 "편집 크레딧 추가하기" → pricing 모달을 인플레이스로 연다.
|
|
469
172
|
const [pricingOpen, setPricingOpen] = useState(false);
|
|
470
|
-
const [qaJson, setQaJson] = useState<CanvasJson>(initialDocument.canvas_json);
|
|
471
|
-
const [overlayEnabled, setOverlayEnabled] = useState(false);
|
|
472
|
-
const [summaryCopied, setSummaryCopied] = useState(false);
|
|
473
173
|
// 어댑터를 안 거친다. `adaptLeviosaCustomProps`는 SDK가 **못 읽는** 장식을 읽을 수
|
|
474
174
|
// 있는 모양으로 낮춰 주던 일이었고(그림자·그라데이션 → 대표 단색), 우리 렌더러는
|
|
475
175
|
// 그 필드들을 원본 그대로 읽는다. 낮춘 것을 넣으면 오히려 원본보다 못한 그림이 된다.
|
|
476
176
|
// 브랜드 자산 주소는 바이트 경로로 옮겨서 연다 — 302 를 따라가면 CORS 가 깨져서
|
|
477
177
|
// 캔버스가 그림을 못 읽는다(asset-bytes-url 에 실측 표를 적어 뒀다).
|
|
478
|
-
|
|
178
|
+
// 문서는 편집기가 열릴 때 **한 번만** 읽는다. 저장이 끝나고 앱이 서버가 돌려준
|
|
179
|
+
// 문서를 도로 넣어도 캔버스를 다시 만들지 않는다 — 다시 만들면 되돌리기 100단과
|
|
180
|
+
// 선택이 통째로 날아간다. 저장 버튼 한 번에 ⌘Z 가 죽던 것이 그 증상이었고,
|
|
181
|
+
// 자동저장이 붙으면 몇 초마다 그렇게 된다.
|
|
182
|
+
//
|
|
183
|
+
// 정말로 다른 문서를 여는 것은 부르는 쪽이 `key` 로 다시 마운트한다.
|
|
184
|
+
const [store] = useState(() => {
|
|
479
185
|
ensureCanvasKey();
|
|
480
186
|
return createCanvasStore(
|
|
481
187
|
normalizeDocumentAssetSrcs(initialDocument.canvas_json) as DocumentJson,
|
|
482
188
|
);
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
const qaSnapshot = useMemo(
|
|
486
|
-
() => buildQaSnapshot(initialDocument, qaJson),
|
|
487
|
-
[initialDocument, qaJson],
|
|
488
|
-
);
|
|
189
|
+
});
|
|
489
190
|
|
|
490
191
|
// 글꼴 준비. 문서가 쓰는 얼굴을 전부 받아 놓고, 다 온 뒤에 줄바꿈 손질
|
|
491
192
|
// (`applyTextLineFit`)을 한 번 돌린다 — 폴백 서체로 재면 과대측정해서 손질이
|
|
@@ -506,159 +207,30 @@ export function DetailPageEditor({
|
|
|
506
207
|
};
|
|
507
208
|
}, [store]);
|
|
508
209
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
});
|
|
530
|
-
return () => {
|
|
531
|
-
if (timer) clearTimeout(timer);
|
|
532
|
-
off();
|
|
533
|
-
};
|
|
534
|
-
}, [store]);
|
|
535
|
-
|
|
536
|
-
useEffect(() => {
|
|
537
|
-
setSummaryCopied(false);
|
|
538
|
-
}, [qaSnapshot.summary]);
|
|
539
|
-
|
|
540
|
-
const removeOverlayElements = () => {
|
|
541
|
-
const ids: string[] = [];
|
|
542
|
-
walkElements(store.toJSON() as CanvasJson, (element) => {
|
|
543
|
-
if (element.id && isQaOverlayElement(element)) ids.push(element.id);
|
|
544
|
-
});
|
|
545
|
-
if (ids.length) store.deleteElements(ids);
|
|
546
|
-
};
|
|
547
|
-
|
|
548
|
-
const addOverlayElements = () => {
|
|
549
|
-
removeOverlayElements();
|
|
550
|
-
const cleanJson = qaSnapshot.json;
|
|
551
|
-
const pages = asArray(cleanJson.pages) as Array<Record<string, unknown>>;
|
|
552
|
-
for (const slot of qaSnapshot.slots) {
|
|
553
|
-
if (!slot.element || !slot.element.id || !slot.pageId) continue;
|
|
554
|
-
const pageIndex = pages.findIndex((page) => page.id === slot.pageId);
|
|
555
|
-
const page = store.pages[pageIndex >= 0 ? pageIndex : 0];
|
|
556
|
-
if (!page) continue;
|
|
557
|
-
const x = finiteNumber(slot.element.x) ?? 0;
|
|
558
|
-
const y = finiteNumber(slot.element.y) ?? 0;
|
|
559
|
-
const width = Math.max(finiteNumber(slot.element.width) ?? 1, 1);
|
|
560
|
-
const height = Math.max(finiteNumber(slot.element.height) ?? 1, 1);
|
|
561
|
-
const color = slot.state === "error" ? "#dc2626" : slot.state === "warning" ? "#d97706" : "#059669";
|
|
562
|
-
page.addElement(
|
|
563
|
-
{
|
|
564
|
-
id: `${QA_OVERLAY_PREFIX}-box-${slot.element.id}`,
|
|
565
|
-
type: "figure",
|
|
566
|
-
subType: "rect",
|
|
567
|
-
x,
|
|
568
|
-
y,
|
|
569
|
-
width,
|
|
570
|
-
height,
|
|
571
|
-
fill: "rgba(255,255,255,0)",
|
|
572
|
-
stroke: color,
|
|
573
|
-
strokeWidth: 3,
|
|
574
|
-
dash: [10, 6] as never,
|
|
575
|
-
selectable: false,
|
|
576
|
-
removable: false,
|
|
577
|
-
alwaysOnTop: true,
|
|
578
|
-
showInExport: false,
|
|
579
|
-
custom: { [QA_OVERLAY_CUSTOM_KEY]: true, slot: slot.slot },
|
|
580
|
-
},
|
|
581
|
-
{ skipSelect: true },
|
|
582
|
-
);
|
|
583
|
-
page.addElement(
|
|
584
|
-
{
|
|
585
|
-
id: `${QA_OVERLAY_PREFIX}-label-${slot.element.id}`,
|
|
586
|
-
type: "text",
|
|
587
|
-
x,
|
|
588
|
-
y: Math.max(0, y - 30),
|
|
589
|
-
width: Math.max(120, Math.min(width, 320)),
|
|
590
|
-
height: 24,
|
|
591
|
-
text: slot.slot,
|
|
592
|
-
fontSize: 14,
|
|
593
|
-
fontWeight: "700",
|
|
594
|
-
fill: "#ffffff",
|
|
595
|
-
backgroundEnabled: true,
|
|
596
|
-
backgroundColor: color,
|
|
597
|
-
backgroundOpacity: 0.92,
|
|
598
|
-
backgroundCornerRadius: 4,
|
|
599
|
-
backgroundPadding: 6,
|
|
600
|
-
selectable: false,
|
|
601
|
-
removable: false,
|
|
602
|
-
alwaysOnTop: true,
|
|
603
|
-
showInExport: false,
|
|
604
|
-
custom: { [QA_OVERLAY_CUSTOM_KEY]: true, slot: slot.slot },
|
|
605
|
-
},
|
|
606
|
-
{ skipSelect: true },
|
|
607
|
-
);
|
|
608
|
-
}
|
|
609
|
-
};
|
|
610
|
-
|
|
611
|
-
useEffect(() => {
|
|
612
|
-
if (overlayEnabled) addOverlayElements();
|
|
613
|
-
else removeOverlayElements();
|
|
614
|
-
return removeOverlayElements;
|
|
615
|
-
// The overlay is intentionally regenerated from the latest QA snapshot.
|
|
616
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
617
|
-
}, [overlayEnabled, qaSnapshot.summary, store]);
|
|
618
|
-
|
|
619
|
-
const handleSave = async () => {
|
|
620
|
-
setSaveError(null);
|
|
621
|
-
setSaveOk(false);
|
|
622
|
-
try {
|
|
623
|
-
// Drop QA overlays and revert gray image placeholders to empty src so the
|
|
624
|
-
// persisted document never carries the editor-only placeholder.
|
|
625
|
-
const cleanJson = clearPlaceholderImageSrc(
|
|
626
|
-
stripQaOverlayElements(store.toJSON() as CanvasJson),
|
|
627
|
-
);
|
|
628
|
-
await onSave({
|
|
629
|
-
...initialDocument,
|
|
630
|
-
canvas_json: cleanJson,
|
|
631
|
-
});
|
|
632
|
-
setSaveOk(true);
|
|
633
|
-
} catch (error) {
|
|
634
|
-
setSaveError(error instanceof Error ? error.message : t("editor.saveError"));
|
|
635
|
-
}
|
|
636
|
-
};
|
|
637
|
-
|
|
638
|
-
const handleRefreshQa = () => {
|
|
639
|
-
setQaJson(stripQaOverlayElements(store.toJSON() as CanvasJson));
|
|
640
|
-
};
|
|
641
|
-
|
|
642
|
-
const handleSelectSlot = (slot: SlotInspection) => {
|
|
643
|
-
if (slot.binding?.element_id) {
|
|
644
|
-
store.selectElements([slot.binding.element_id]);
|
|
645
|
-
}
|
|
646
|
-
};
|
|
210
|
+
const documentRef = useRef(initialDocument);
|
|
211
|
+
documentRef.current = initialDocument;
|
|
212
|
+
|
|
213
|
+
const runSave = useCallback(
|
|
214
|
+
async (reason: SaveReason) => {
|
|
215
|
+
setSaveError(null);
|
|
216
|
+
setSaveOk(false);
|
|
217
|
+
try {
|
|
218
|
+
// 회색 자리표시 이미지는 빈 src 로 되돌려 저장한다 — 편집기에서만 쓰는 그림이다.
|
|
219
|
+
const cleanJson = clearPlaceholderImageSrc(store.toJSON() as CanvasJson);
|
|
220
|
+
await onSave({ ...documentRef.current, canvas_json: cleanJson }, { reason });
|
|
221
|
+
setSaveOk(true);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
setSaveError(error instanceof Error ? error.message : t("editor.saveError"));
|
|
224
|
+
// 자동저장은 조용히 넘어가지만 실패는 알려 줘야 다음에 다시 보낸다.
|
|
225
|
+
if (reason !== "manual") throw error;
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
[onSave, store, t],
|
|
229
|
+
);
|
|
647
230
|
|
|
648
|
-
const
|
|
649
|
-
await navigator.clipboard.writeText(qaSnapshot.summary);
|
|
650
|
-
setSummaryCopied(true);
|
|
651
|
-
};
|
|
231
|
+
const handleSave = useCallback(() => void runSave("manual"), [runSave]);
|
|
652
232
|
|
|
653
|
-
const
|
|
654
|
-
const blob = new Blob([qaSnapshot.summary], { type: "text/plain;charset=utf-8" });
|
|
655
|
-
const url = URL.createObjectURL(blob);
|
|
656
|
-
const link = document.createElement("a");
|
|
657
|
-
link.href = url;
|
|
658
|
-
link.download = `${initialDocument.template_id ?? "canvas-template"}-qa-summary.txt`;
|
|
659
|
-
link.click();
|
|
660
|
-
URL.revokeObjectURL(url);
|
|
661
|
-
};
|
|
233
|
+
const unsaved = useAutoSave({ store, delayMs: autoSaveDelayMs, save: runSave });
|
|
662
234
|
|
|
663
235
|
// The canvas subtree only depends on the stable store and sidebar
|
|
664
236
|
// contract. Memoize it so QA/save/overlay updates never recreate the heavy
|
|
@@ -837,174 +409,6 @@ export function DetailPageEditor({
|
|
|
837
409
|
dataGifCreditCost={dataGifCreditCost}
|
|
838
410
|
/>
|
|
839
411
|
</div>
|
|
840
|
-
|
|
841
|
-
{/* Template QA / slot inspector / lint — authoring tools, not seller-facing. */}
|
|
842
|
-
<details className="border-t border-dpe-ink-200">
|
|
843
|
-
<summary className="cursor-pointer p-4 text-xs font-dpe-semibold uppercase tracking-[0.06em] text-dpe-ink-400 hover:text-dpe-ink-600">
|
|
844
|
-
개발자 도구 · 템플릿 QA
|
|
845
|
-
</summary>
|
|
846
|
-
<div className="flex max-h-[55vh] min-h-0 flex-col overflow-y-auto">
|
|
847
|
-
<div className="border-b border-dpe-ink-200 p-4">
|
|
848
|
-
<div className="flex items-center justify-between gap-3">
|
|
849
|
-
<div>
|
|
850
|
-
<h2 className="flex items-center gap-2 text-sm font-dpe-semibold text-dpe-ink-950">
|
|
851
|
-
<ListChecks aria-hidden="true" size={16} />
|
|
852
|
-
{t("editor.qaTitle")}
|
|
853
|
-
</h2>
|
|
854
|
-
<p className="mt-1 text-xs text-dpe-ink-500">
|
|
855
|
-
{qaSnapshot.counts.pages} pages / {qaSnapshot.counts.elements} elements /{" "}
|
|
856
|
-
{qaSnapshot.counts.slots} slots
|
|
857
|
-
</p>
|
|
858
|
-
</div>
|
|
859
|
-
<button
|
|
860
|
-
type="button"
|
|
861
|
-
onClick={handleRefreshQa}
|
|
862
|
-
className="inline-flex h-8 w-8 items-center justify-center rounded-dpe-md border border-dpe-ink-200 text-dpe-ink-600 hover:bg-dpe-ink-50"
|
|
863
|
-
title={t("editor.refreshQa")}
|
|
864
|
-
>
|
|
865
|
-
<RefreshCw aria-hidden="true" size={15} />
|
|
866
|
-
</button>
|
|
867
|
-
</div>
|
|
868
|
-
<div className="mt-4 grid grid-cols-3 gap-2 text-center text-xs">
|
|
869
|
-
<div className="rounded-dpe-md border border-dpe-ink-200 p-2">
|
|
870
|
-
<div className="text-base font-dpe-semibold text-dpe-ink-950">{qaSnapshot.counts.slots}</div>
|
|
871
|
-
<div className="text-dpe-ink-500">slots</div>
|
|
872
|
-
</div>
|
|
873
|
-
<div className="rounded-dpe-md border border-dpe-danger-100 bg-dpe-danger-50 p-2">
|
|
874
|
-
<div className="text-base font-dpe-semibold text-dpe-danger-700">{qaSnapshot.counts.errors}</div>
|
|
875
|
-
<div className="text-dpe-danger-600">errors</div>
|
|
876
|
-
</div>
|
|
877
|
-
<div className="rounded-dpe-md border border-dpe-warn-100 bg-dpe-warn-50 p-2">
|
|
878
|
-
<div className="text-base font-dpe-semibold text-dpe-warn-700">{qaSnapshot.counts.warnings}</div>
|
|
879
|
-
<div className="text-dpe-warn-600">warnings</div>
|
|
880
|
-
</div>
|
|
881
|
-
</div>
|
|
882
|
-
<div className="mt-4 flex gap-2">
|
|
883
|
-
<button
|
|
884
|
-
type="button"
|
|
885
|
-
onClick={() => setOverlayEnabled((value) => !value)}
|
|
886
|
-
className="inline-flex h-9 flex-1 items-center justify-center gap-2 rounded-dpe-md border border-dpe-ink-200 text-xs font-dpe-semibold text-dpe-ink-700 hover:bg-dpe-ink-50"
|
|
887
|
-
>
|
|
888
|
-
{overlayEnabled ? <EyeOff aria-hidden="true" size={15} /> : <Eye aria-hidden="true" size={15} />}
|
|
889
|
-
{overlayEnabled ? t("editor.overlayOff") : t("editor.overlayOn")}
|
|
890
|
-
</button>
|
|
891
|
-
<button
|
|
892
|
-
type="button"
|
|
893
|
-
onClick={handleCopySummary}
|
|
894
|
-
className="inline-flex h-9 w-9 items-center justify-center rounded-dpe-md border border-dpe-ink-200 text-dpe-ink-700 hover:bg-dpe-ink-50"
|
|
895
|
-
title={t("editor.copySummary")}
|
|
896
|
-
>
|
|
897
|
-
<Copy aria-hidden="true" size={15} />
|
|
898
|
-
</button>
|
|
899
|
-
<button
|
|
900
|
-
type="button"
|
|
901
|
-
onClick={handleDownloadSummary}
|
|
902
|
-
className="inline-flex h-9 w-9 items-center justify-center rounded-dpe-md border border-dpe-ink-200 text-dpe-ink-700 hover:bg-dpe-ink-50"
|
|
903
|
-
title={t("editor.downloadSummary")}
|
|
904
|
-
>
|
|
905
|
-
<Download aria-hidden="true" size={15} />
|
|
906
|
-
</button>
|
|
907
|
-
</div>
|
|
908
|
-
{summaryCopied ? (
|
|
909
|
-
<p className="mt-2 text-xs font-dpe-medium text-dpe-ok-600">{t("editor.summaryCopied")}</p>
|
|
910
|
-
) : null}
|
|
911
|
-
</div>
|
|
912
|
-
|
|
913
|
-
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
|
914
|
-
<section>
|
|
915
|
-
<h3 className="text-xs font-dpe-semibold uppercase tracking-[0.08em] text-dpe-ink-500">
|
|
916
|
-
{t("editor.slotInspector")}
|
|
917
|
-
</h3>
|
|
918
|
-
<div className="mt-3 space-y-2">
|
|
919
|
-
{qaSnapshot.slots.length ? (
|
|
920
|
-
qaSnapshot.slots.map((slot) => (
|
|
921
|
-
<button
|
|
922
|
-
key={slot.slot}
|
|
923
|
-
type="button"
|
|
924
|
-
onClick={() => handleSelectSlot(slot)}
|
|
925
|
-
className="w-full rounded-dpe-md border border-dpe-ink-200 p-3 text-left hover:border-dpe-ink-300 hover:bg-dpe-ink-50"
|
|
926
|
-
>
|
|
927
|
-
<div className="flex items-center justify-between gap-2">
|
|
928
|
-
<span className="truncate text-sm font-dpe-semibold text-dpe-ink-950">{slot.slot}</span>
|
|
929
|
-
<span
|
|
930
|
-
className={
|
|
931
|
-
slot.state === "error"
|
|
932
|
-
? "text-xs font-dpe-semibold text-dpe-danger-600"
|
|
933
|
-
: slot.state === "warning"
|
|
934
|
-
? "text-xs font-dpe-semibold text-dpe-warn-600"
|
|
935
|
-
: "text-xs font-dpe-semibold text-dpe-ok-600"
|
|
936
|
-
}
|
|
937
|
-
>
|
|
938
|
-
{slot.state}
|
|
939
|
-
</span>
|
|
940
|
-
</div>
|
|
941
|
-
<p className="mt-1 truncate text-xs text-dpe-ink-500">
|
|
942
|
-
{slot.binding?.kind ?? "-"} / {slot.binding?.element_id ?? "-"}
|
|
943
|
-
</p>
|
|
944
|
-
<p className="mt-1 text-xs text-dpe-ink-600">{formatRect(slot.element)}</p>
|
|
945
|
-
<p className="mt-2 text-xs text-dpe-ink-500">{slot.message}</p>
|
|
946
|
-
</button>
|
|
947
|
-
))
|
|
948
|
-
) : (
|
|
949
|
-
<p className="rounded-dpe-md border border-dpe-ink-200 p-3 text-sm text-dpe-ink-500">
|
|
950
|
-
{t("editor.noSlots")}
|
|
951
|
-
</p>
|
|
952
|
-
)}
|
|
953
|
-
</div>
|
|
954
|
-
</section>
|
|
955
|
-
|
|
956
|
-
<section className="mt-6">
|
|
957
|
-
<h3 className="text-xs font-dpe-semibold uppercase tracking-[0.08em] text-dpe-ink-500">
|
|
958
|
-
{t("editor.templateLint")}
|
|
959
|
-
</h3>
|
|
960
|
-
<div className="mt-3 space-y-2">
|
|
961
|
-
{qaSnapshot.lint.length ? (
|
|
962
|
-
qaSnapshot.lint.map((issue, index) => (
|
|
963
|
-
<div
|
|
964
|
-
key={`${issue.title}-${issue.slot ?? issue.elementId ?? index}`}
|
|
965
|
-
className="rounded-dpe-md border border-dpe-ink-200 p-3"
|
|
966
|
-
>
|
|
967
|
-
<div className="flex items-center justify-between gap-2">
|
|
968
|
-
<p className="text-sm font-dpe-semibold text-dpe-ink-950">{issue.title}</p>
|
|
969
|
-
<span
|
|
970
|
-
className={
|
|
971
|
-
issue.level === "error"
|
|
972
|
-
? "text-xs font-dpe-semibold text-dpe-danger-600"
|
|
973
|
-
: issue.level === "warning"
|
|
974
|
-
? "text-xs font-dpe-semibold text-dpe-warn-600"
|
|
975
|
-
: "text-xs font-dpe-semibold text-dpe-active-600"
|
|
976
|
-
}
|
|
977
|
-
>
|
|
978
|
-
{issue.level}
|
|
979
|
-
</span>
|
|
980
|
-
</div>
|
|
981
|
-
<p className="mt-1 text-xs leading-5 text-dpe-ink-600">{issue.detail}</p>
|
|
982
|
-
{issue.slot || issue.elementId ? (
|
|
983
|
-
<p className="mt-2 truncate text-xs text-dpe-ink-400">
|
|
984
|
-
{[issue.slot, issue.elementId].filter(Boolean).join(" / ")}
|
|
985
|
-
</p>
|
|
986
|
-
) : null}
|
|
987
|
-
</div>
|
|
988
|
-
))
|
|
989
|
-
) : (
|
|
990
|
-
<div className="rounded-dpe-md border border-dpe-ok-100 bg-dpe-ok-50 p-3 text-sm font-dpe-medium text-dpe-ok-700">
|
|
991
|
-
{t("editor.lintPassed")}
|
|
992
|
-
</div>
|
|
993
|
-
)}
|
|
994
|
-
</div>
|
|
995
|
-
</section>
|
|
996
|
-
|
|
997
|
-
<section className="mt-6">
|
|
998
|
-
<h3 className="text-xs font-dpe-semibold uppercase tracking-[0.08em] text-dpe-ink-500">
|
|
999
|
-
{t("editor.designerSummary")}
|
|
1000
|
-
</h3>
|
|
1001
|
-
<pre className="mt-3 max-h-72 overflow-auto rounded-dpe-md border border-dpe-ink-200 bg-dpe-ink-50 p-3 text-xs leading-5 text-dpe-ink-700">
|
|
1002
|
-
{qaSnapshot.summary}
|
|
1003
|
-
</pre>
|
|
1004
|
-
</section>
|
|
1005
|
-
</div>
|
|
1006
|
-
</div>
|
|
1007
|
-
</details>
|
|
1008
412
|
</aside>
|
|
1009
413
|
);
|
|
1010
414
|
// 인스펙터는 감싸 쓰는 경우가 더 많다 — 자기 크롬만 두르고 속은 그대로 둔다.
|
|
@@ -1018,7 +422,7 @@ export function DetailPageEditor({
|
|
|
1018
422
|
<HeaderSlot
|
|
1019
423
|
productName={productName?.trim() || t("editor.untitled")}
|
|
1020
424
|
onBack={onBack}
|
|
1021
|
-
save={{ run: handleSave, saving, ok: saveOk, error: saveError }}
|
|
425
|
+
save={{ run: handleSave, saving, ok: saveOk, error: saveError, unsaved }}
|
|
1022
426
|
parts={{
|
|
1023
427
|
history: historyPart,
|
|
1024
428
|
download: downloadPart,
|