@leviosa-ai/detail-page-editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/components/cardnews/color-input.tsx +380 -0
  2. package/components/detail-page/ai-generate-panel.tsx +763 -0
  3. package/components/detail-page/annotation-canvas.tsx +860 -0
  4. package/components/detail-page/annotation-dialog.tsx +397 -0
  5. package/components/detail-page/bubble-tail-overlay.tsx +209 -0
  6. package/components/detail-page/canvas-context-menu.tsx +234 -0
  7. package/components/detail-page/canvas-observer.tsx +68 -0
  8. package/components/detail-page/canvas-overlay-host.tsx +44 -0
  9. package/components/detail-page/canvas-store-facade.ts +51 -0
  10. package/components/detail-page/chart-inspector.tsx +531 -0
  11. package/components/detail-page/design-reference-picker.tsx +383 -0
  12. package/components/detail-page/detail-page-basic-shapes.tsx +180 -0
  13. package/components/detail-page/detail-page-brand-gifs-panel.tsx +261 -0
  14. package/components/detail-page/detail-page-brand-kit-panel.tsx +77 -0
  15. package/components/detail-page/detail-page-brand-panel-header.tsx +44 -0
  16. package/components/detail-page/detail-page-charts-panel.tsx +104 -0
  17. package/components/detail-page/detail-page-decorations-panel.tsx +125 -0
  18. package/components/detail-page/detail-page-download-dialog.tsx +524 -0
  19. package/components/detail-page/detail-page-editor.tsx +974 -0
  20. package/components/detail-page/detail-page-elements-panel.tsx +117 -0
  21. package/components/detail-page/detail-page-font-picker.tsx +231 -0
  22. package/components/detail-page/detail-page-history-buttons.tsx +87 -0
  23. package/components/detail-page/detail-page-host-context.tsx +465 -0
  24. package/components/detail-page/detail-page-icons-panel.tsx +251 -0
  25. package/components/detail-page/detail-page-konva-json-preview.tsx +474 -0
  26. package/components/detail-page/detail-page-layers-panel.tsx +618 -0
  27. package/components/detail-page/detail-page-my-images-panel.tsx +179 -0
  28. package/components/detail-page/detail-page-my-shapes-panel.tsx +174 -0
  29. package/components/detail-page/detail-page-page-toolbar.tsx +171 -0
  30. package/components/detail-page/detail-page-pages-panel.tsx +299 -0
  31. package/components/detail-page/detail-page-pages-timeline.tsx +79 -0
  32. package/components/detail-page/detail-page-photos-panel.tsx +257 -0
  33. package/components/detail-page/detail-page-properties-panel.tsx +2680 -0
  34. package/components/detail-page/detail-page-qr-panel.tsx +137 -0
  35. package/components/detail-page/detail-page-references-panel.tsx +166 -0
  36. package/components/detail-page/detail-page-selection.ts +44 -0
  37. package/components/detail-page/detail-page-shapes-panel.tsx +16 -0
  38. package/components/detail-page/detail-page-sidebar-sections.tsx +324 -0
  39. package/components/detail-page/detail-page-tables-panel.tsx +106 -0
  40. package/components/detail-page/detail-page-text-panel.tsx +179 -0
  41. package/components/detail-page/detail-page-thumbnail-bus.ts +42 -0
  42. package/components/detail-page/edit-quota-ui.tsx +150 -0
  43. package/components/detail-page/editor-history.ts +56 -0
  44. package/components/detail-page/editor-hotkeys.tsx +165 -0
  45. package/components/detail-page/element-recents-strip.tsx +105 -0
  46. package/components/detail-page/element-rects.ts +82 -0
  47. package/components/detail-page/fill-control.tsx +261 -0
  48. package/components/detail-page/find-replace-panel.tsx +252 -0
  49. package/components/detail-page/generation-progress-fill.tsx +57 -0
  50. package/components/detail-page/gif-animator.tsx +184 -0
  51. package/components/detail-page/gif-effect-picker.tsx +136 -0
  52. package/components/detail-page/group-drill-in.tsx +219 -0
  53. package/components/detail-page/group-prompt-edit-panel.tsx +154 -0
  54. package/components/detail-page/hover-highlight-overlay.tsx +111 -0
  55. package/components/detail-page/hovered-layer.ts +34 -0
  56. package/components/detail-page/inspector-controls.tsx +175 -0
  57. package/components/detail-page/leviosa-canvas-workspace.tsx +313 -0
  58. package/components/detail-page/nested-selection-overlay.tsx +364 -0
  59. package/components/detail-page/panel-search-input.tsx +49 -0
  60. package/components/detail-page/prompt-edit-panel.tsx +146 -0
  61. package/components/detail-page/section-height-handle.tsx +276 -0
  62. package/components/detail-page/section-reauthor-controller.tsx +210 -0
  63. package/components/detail-page/spec-prompt-edit-panel.tsx +178 -0
  64. package/components/detail-page/spec-resize-absorber.tsx +149 -0
  65. package/components/detail-page/svg-prompt-edit-panel.tsx +139 -0
  66. package/components/detail-page/table-canvas-overlay.tsx +707 -0
  67. package/components/detail-page/table-inspector.tsx +586 -0
  68. package/components/ui/button.tsx +64 -0
  69. package/components/ui/command.tsx +175 -0
  70. package/components/ui/dialog.tsx +167 -0
  71. package/components/ui/label.tsx +24 -0
  72. package/components/ui/select.tsx +190 -0
  73. package/components/ui/slider.tsx +63 -0
  74. package/components/ui/switch.tsx +35 -0
  75. package/components/ui/tooltip.tsx +57 -0
  76. package/config/detail-page-bundle-font-tags.json +39 -0
  77. package/config/detail-page-fonts.json +373 -0
  78. package/index.ts +24 -0
  79. package/lib/cardnews/font-loader.ts +306 -0
  80. package/lib/detail-page/animation-sniff.ts +101 -0
  81. package/lib/detail-page/asset-bytes-url.ts +70 -0
  82. package/lib/detail-page/basic-shapes.ts +603 -0
  83. package/lib/detail-page/canvas-menu.ts +182 -0
  84. package/lib/detail-page/chart/defaults.ts +283 -0
  85. package/lib/detail-page/chart/format.ts +66 -0
  86. package/lib/detail-page/chart/normalize.ts +149 -0
  87. package/lib/detail-page/chart/palette.ts +85 -0
  88. package/lib/detail-page/chart/paste.ts +82 -0
  89. package/lib/detail-page/chart/preview.ts +78 -0
  90. package/lib/detail-page/chart/render.ts +188 -0
  91. package/lib/detail-page/chart/renderers/bar-h.ts +103 -0
  92. package/lib/detail-page/chart/renderers/bar-v.ts +213 -0
  93. package/lib/detail-page/chart/renderers/donut.ts +186 -0
  94. package/lib/detail-page/chart/renderers/gauge.ts +96 -0
  95. package/lib/detail-page/chart/renderers/line.ts +143 -0
  96. package/lib/detail-page/chart/renderers/shared.ts +263 -0
  97. package/lib/detail-page/chart/renderers/stack.ts +121 -0
  98. package/lib/detail-page/chart/sync.ts +92 -0
  99. package/lib/detail-page/chart/types.ts +145 -0
  100. package/lib/detail-page/css-color.ts +34 -0
  101. package/lib/detail-page/data-gif-payload.ts +167 -0
  102. package/lib/detail-page/design-reference.ts +244 -0
  103. package/lib/detail-page/distribute.ts +89 -0
  104. package/lib/detail-page/editor-routes.ts +10 -0
  105. package/lib/detail-page/element-recents.ts +167 -0
  106. package/lib/detail-page/find-replace.ts +120 -0
  107. package/lib/detail-page/format-painter.ts +159 -0
  108. package/lib/detail-page/gif-frames.ts +216 -0
  109. package/lib/detail-page/gif-timeline.ts +65 -0
  110. package/lib/detail-page/group-action.ts +62 -0
  111. package/lib/detail-page/hangul-roman.ts +58 -0
  112. package/lib/detail-page/icon-curated.ts +290 -0
  113. package/lib/detail-page/icon-fuzzy.ts +250 -0
  114. package/lib/detail-page/icon-keywords.generated.ts +3766 -0
  115. package/lib/detail-page/icon-keywords.ko.ts +524 -0
  116. package/lib/detail-page/icon-search.ts +257 -0
  117. package/lib/detail-page/icons.ts +67 -0
  118. package/lib/detail-page/image-credit.ts +121 -0
  119. package/lib/detail-page/image-data-uri.ts +29 -0
  120. package/lib/detail-page/insert-image.ts +75 -0
  121. package/lib/detail-page/insert-shape.ts +49 -0
  122. package/lib/detail-page/korean-dictionary.ts +187 -0
  123. package/lib/detail-page/layer-move.ts +257 -0
  124. package/lib/detail-page/new-product-draft-store.ts +105 -0
  125. package/lib/detail-page/new-product-draft.ts +283 -0
  126. package/lib/detail-page/personal-images-refresh.ts +30 -0
  127. package/lib/detail-page/product-source.ts +587 -0
  128. package/lib/detail-page/qr-code.ts +214 -0
  129. package/lib/detail-page/reference-image.ts +103 -0
  130. package/lib/detail-page/reference-upload.ts +181 -0
  131. package/lib/detail-page/replace-page.ts +35 -0
  132. package/lib/detail-page/replace-with-gif.ts +251 -0
  133. package/lib/detail-page/section-height.ts +131 -0
  134. package/lib/detail-page/section-reauthor-bus.ts +61 -0
  135. package/lib/detail-page/shape-to-image.ts +219 -0
  136. package/lib/detail-page/spec-group/clipboard.ts +35 -0
  137. package/lib/detail-page/spec-group/sync.ts +377 -0
  138. package/lib/detail-page/spec-group/text-metrics.ts +48 -0
  139. package/lib/detail-page/stock-photos.ts +88 -0
  140. package/lib/detail-page/svg-colors.ts +74 -0
  141. package/lib/detail-page/table/defaults.ts +344 -0
  142. package/lib/detail-page/table/edit.ts +312 -0
  143. package/lib/detail-page/table/harvest.ts +103 -0
  144. package/lib/detail-page/table/normalize.ts +125 -0
  145. package/lib/detail-page/table/paste.ts +49 -0
  146. package/lib/detail-page/table/preview.ts +66 -0
  147. package/lib/detail-page/table/render.ts +388 -0
  148. package/lib/detail-page/table/sync.ts +107 -0
  149. package/lib/detail-page/table/types.ts +102 -0
  150. package/lib/detail-page/text-gif-layout.ts +245 -0
  151. package/lib/detail-page/text-gif-payload.ts +67 -0
  152. package/lib/detail-page/text-presets.ts +230 -0
  153. package/lib/detail-page/use-fake-progress.ts +67 -0
  154. package/lib/detail-page/z-order.ts +82 -0
  155. package/lib/detail-page-canvas/bubble-path.ts +190 -0
  156. package/lib/detail-page-canvas/canvas-key.ts +22 -0
  157. package/lib/detail-page-canvas/clip-rect.ts +65 -0
  158. package/lib/detail-page-canvas/custom-props-adapter.ts +219 -0
  159. package/lib/detail-page-canvas/editor-fonts.ts +161 -0
  160. package/lib/detail-page-canvas/export/ai.ts +413 -0
  161. package/lib/detail-page-canvas/export/color.ts +35 -0
  162. package/lib/detail-page-canvas/export/document-model.ts +78 -0
  163. package/lib/detail-page-canvas/export/export-files.ts +290 -0
  164. package/lib/detail-page-canvas/export/font-subset.ts +264 -0
  165. package/lib/detail-page-canvas/export/gif-export.ts +310 -0
  166. package/lib/detail-page-canvas/export/gif-plan.ts +95 -0
  167. package/lib/detail-page-canvas/export/mp4-encode.ts +158 -0
  168. package/lib/detail-page-canvas/export/mp4-support.ts +22 -0
  169. package/lib/detail-page-canvas/export/pdf/geometry.ts +309 -0
  170. package/lib/detail-page-canvas/export/pdf/inline-svg.ts +399 -0
  171. package/lib/detail-page-canvas/export/pdf/resources.ts +443 -0
  172. package/lib/detail-page-canvas/export/pdf/text-ops.ts +81 -0
  173. package/lib/detail-page-canvas/export/pdf/writer.ts +109 -0
  174. package/lib/detail-page-canvas/export/psd.ts +354 -0
  175. package/lib/detail-page-canvas/export/raster.ts +255 -0
  176. package/lib/detail-page-canvas/export/svg.ts +479 -0
  177. package/lib/detail-page-canvas/export/text-layout.ts +142 -0
  178. package/lib/detail-page-canvas/font-catalog.ts +177 -0
  179. package/lib/detail-page-canvas/font-tags.ts +47 -0
  180. package/lib/detail-page-canvas/gif-web-fonts.ts +90 -0
  181. package/lib/detail-page-canvas/konva-fallback.ts +477 -0
  182. package/lib/detail-page-canvas/text-background-plan.ts +124 -0
  183. package/lib/detail-page-canvas/text-highlight-bands.ts +127 -0
  184. package/lib/utils.ts +10 -0
  185. package/package.json +68 -0
  186. package/types/commerce.ts +171 -0
  187. package/types/detail-page-api.ts +200 -0
  188. package/types/detail-page-canvas.ts +20 -0
  189. package/types/fontkit.d.ts +42 -0
  190. package/types/gifenc.d.ts +51 -0
@@ -0,0 +1,49 @@
1
+ "use client";
2
+
3
+ import { Search } from "lucide-react";
4
+
5
+ /**
6
+ * 좌측 패널의 검색창. 사진·아이콘·도형이 같은 것을 쓴다.
7
+ *
8
+ * 따로 빼 둔 이유가 둘이다.
9
+ *
10
+ * 1. **돋보기 자리.** 아이콘을 `absolute`로 얹으려면 기준 상자가 **입력칸과 정확히
11
+ * 같아야** 한다. 바깥의 여백 있는 상자를 기준으로 삼으면 그 여백만큼 어긋나
12
+ * 돋보기가 테두리에 걸린다(실제로 그렇게 보였다).
13
+ * 2. **`appearance-none`.** 사파리는 `input[type=search]`를 OS 검색칸으로 그려서
14
+ * 우리가 준 모서리·높이·안여백을 무시하고 알약 모양으로 만든다. 이걸 꺼야 세
15
+ * 패널이 같은 모양이 된다.
16
+ *
17
+ * `type="search"`를 유지하는 것은 esc로 지우기·입력 도구 힌트 때문이다.
18
+ */
19
+ export function PanelSearchInput({
20
+ value,
21
+ onChange,
22
+ placeholder,
23
+ label,
24
+ className = "",
25
+ }: {
26
+ value: string;
27
+ onChange: (next: string) => void;
28
+ placeholder: string;
29
+ label: string;
30
+ className?: string;
31
+ }) {
32
+ return (
33
+ <div className={`relative ${className}`}>
34
+ <Search
35
+ aria-hidden="true"
36
+ size={14}
37
+ className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-neutral-400"
38
+ />
39
+ <input
40
+ type="search"
41
+ value={value}
42
+ onChange={(event) => onChange(event.target.value)}
43
+ placeholder={placeholder}
44
+ aria-label={label}
45
+ className="w-full appearance-none rounded-lg border border-neutral-200 py-2 pl-8 pr-2.5 text-sm leading-5 placeholder:text-neutral-400 focus:border-neutral-400 focus:outline-none [&::-webkit-search-cancel-button]:appearance-none"
46
+ />
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,146 @@
1
+ "use client";
2
+
3
+ // "프롬프트로 편집" — 선택한 텍스트 슬롯의 카피를 자연어 요청으로 다시 쓴다. 소싱 서버의
4
+ // v2 카피 엔진(/{generatedId}/copy/prompt-edit)에 현재 문구 + 지시를 한 번 POST 하고,
5
+ // 돌아온 텍스트를 곧바로 해당 Canvas 요소에 적용한다(상품 문맥·존댓말·컴플라이언스 유지).
6
+
7
+ import { useCallback, useState } from "react";
8
+ import { ArrowUp, Loader2, Sparkles } from "lucide-react";
9
+ import { useTranslation } from "react-i18next";
10
+ import { useDetailPageHost } from "./detail-page-host-context";
11
+ import { EditQuotaBlock, EditUsageBadge } from "./edit-quota-ui";
12
+ import { GenerationProgressLine } from "./generation-progress-fill";
13
+ import {
14
+ useFakeProgress,
15
+ GENERATION_ESTIMATE_MS,
16
+ } from "../../lib/detail-page/use-fake-progress";
17
+
18
+ interface PromptEditPanelProps {
19
+ /** 생성 인스턴스 ID. 없으면(픽스처 모드) 이 패널은 렌더되지 않는다. */
20
+ generatedId: string;
21
+ /** 요소의 custom.leviosaSlot. */
22
+ slotRole: string;
23
+ /** 편집기에 지금 표시된 문구(로컬 편집 반영본). */
24
+ currentText: string;
25
+ /** custom.leviosaSlotKind (headline_highlight / line_split 등). */
26
+ renderKind?: string;
27
+ /** 슬롯 최대 글자 수(있으면 전달). */
28
+ maxLength?: number;
29
+ /** 수정된 텍스트를 요소에 반영하는 콜백 (el.set({ text })). */
30
+ onApplied: (text: string) => void;
31
+ /** 이 인스턴스의 텍스트 편집 사용 횟수/한도(부모가 관리). */
32
+ editsUsed?: number;
33
+ editLimit?: number;
34
+ /** scratch 등 한도 면제면 카운터/차단을 숨긴다. */
35
+ unlimited?: boolean;
36
+ /** 편집 성공/차단 후 사용량 갱신을 부모에 알린다. */
37
+ onUsage?: (used: number, limit: number) => void;
38
+ /** 한도 소진 시 "편집 크레딧 추가하기" 목적지(레비오사 결제면). */
39
+ onBuyMore?: () => void;
40
+ }
41
+
42
+ export function PromptEditPanel({
43
+ generatedId,
44
+ slotRole,
45
+ currentText,
46
+ renderKind,
47
+ maxLength,
48
+ onApplied,
49
+ editsUsed = 0,
50
+ editLimit = 0,
51
+ unlimited = false,
52
+ onUsage,
53
+ onBuyMore,
54
+ }: PromptEditPanelProps) {
55
+ const { t } = useTranslation("branding");
56
+ const { api, toast } = useDetailPageHost();
57
+ const [input, setInput] = useState("");
58
+ const [busy, setBusy] = useState(false);
59
+ const progress = useFakeProgress(busy, GENERATION_ESTIMATE_MS.text);
60
+
61
+ const blocked = !unlimited && editLimit > 0 && editsUsed >= editLimit;
62
+
63
+ const send = useCallback(async () => {
64
+ const instruction = input.trim();
65
+ if (!instruction || busy || blocked) return;
66
+ setBusy(true);
67
+ try {
68
+ const result = await api.promptEditDetailPageCopy(generatedId, {
69
+ slot_role: slotRole,
70
+ current_text: currentText,
71
+ instruction,
72
+ max_length: maxLength,
73
+ render_kind: renderKind,
74
+ });
75
+ if (typeof result.edits_used === "number" && typeof result.edit_limit === "number") {
76
+ onUsage?.(result.edits_used, result.edit_limit);
77
+ }
78
+ const text = (result.text ?? "").trim();
79
+ if (!text || text === currentText.trim()) {
80
+ toast.info(t("detailPage.promptEdit.noChange"));
81
+ } else {
82
+ onApplied(text);
83
+ toast.success(t("detailPage.promptEdit.textSuccess"));
84
+ setInput("");
85
+ }
86
+ } catch (err) {
87
+ const quota = api.asEditQuotaError(err);
88
+ if (quota) {
89
+ onUsage?.(quota.limit, quota.limit);
90
+ toast.error(t("detailPage.promptEdit.quotaExhausted"));
91
+ } else {
92
+ toast.error(err instanceof Error ? err.message : t("detailPage.promptEdit.requestFailed"));
93
+ }
94
+ } finally {
95
+ setBusy(false);
96
+ }
97
+ }, [api, toast, input, busy, blocked, generatedId, slotRole, currentText, maxLength, renderKind, onApplied, onUsage, t]);
98
+
99
+ return (
100
+ <div className="relative flex flex-col overflow-hidden rounded-xl border border-border bg-card">
101
+ <div className="flex items-center gap-1.5 border-b border-border px-3 py-2">
102
+ <Sparkles size={13} className="text-primary" />
103
+ <span className="text-xs font-medium text-foreground">{t("detailPage.promptEdit.header")}</span>
104
+ {unlimited || editLimit > 0 ? (
105
+ <EditUsageBadge used={editsUsed} limit={editLimit} unlimited={unlimited} />
106
+ ) : null}
107
+ </div>
108
+ {blocked ? (
109
+ <EditQuotaBlock kind="text" onBuyMore={onBuyMore} />
110
+ ) : (
111
+ <div className="flex items-end gap-1.5 px-2 py-2">
112
+ <textarea
113
+ value={input}
114
+ onChange={(e) => setInput(e.target.value)}
115
+ onKeyDown={(e) => {
116
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
117
+ e.preventDefault();
118
+ send();
119
+ }
120
+ }}
121
+ rows={2}
122
+ placeholder={
123
+ busy ? t("detailPage.promptEdit.editing") : t("detailPage.promptEdit.textPlaceholder")
124
+ }
125
+ disabled={busy}
126
+ className="max-h-32 flex-1 resize-none bg-transparent px-2 py-1.5 text-[13px] leading-5 text-foreground outline-none placeholder:text-muted-foreground/70 disabled:opacity-60"
127
+ />
128
+ <button
129
+ type="button"
130
+ onClick={send}
131
+ disabled={busy || !input.trim()}
132
+ className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-primary text-primary-foreground disabled:opacity-40"
133
+ aria-label={t("detailPage.promptEdit.send")}
134
+ >
135
+ {busy ? (
136
+ <Loader2 size={15} className="animate-spin" />
137
+ ) : (
138
+ <ArrowUp size={15} />
139
+ )}
140
+ </button>
141
+ </div>
142
+ )}
143
+ {busy ? <GenerationProgressLine progress={progress} /> : null}
144
+ </div>
145
+ );
146
+ }
@@ -0,0 +1,276 @@
1
+ "use client";
2
+
3
+ /**
4
+ * 이 장의 아래 끝을 잡아 끌어 높이를 바꾸는 손잡이 — 두 엔진 공용.
5
+ *
6
+ * 상세페이지 한 장은 세로로 이어 붙는 띠라서 길이 자체가 편집 대상이다. 우측 패널의
7
+ * 숫자·'내용에 맞추기'는 두 엔진이 이미 같은 함수를 쓰지만(`section-height.ts`),
8
+ * 손잡이는 **붙일 자리**가 엔진마다 달랐다:
9
+ *
10
+ * - Canvas 워크스페이스는 장마다 슬롯 `<div>`를 직접 만든다 → 그 안에 넣으면 끝.
11
+ * - 우리 엔진은 `CanvasView` 하나가 장 전부를 그린다 → 넣을 자리가 없다. 그래서
12
+ * 캔버스 쪽은 다른 오버레이들과 같은 방식으로(`[data-lc-page]`의 상자를 재서)
13
+ * 활성 장 위에 자리를 잡아 준다.
14
+ *
15
+ * 손잡이 자체는 한 벌이다. 두 벌로 두면 한쪽만 고쳐진다.
16
+ */
17
+
18
+ import {
19
+ useEffect,
20
+ useLayoutEffect,
21
+ useRef,
22
+ useState,
23
+ type PointerEvent as ReactPointerEvent,
24
+ type RefObject,
25
+ } from "react";
26
+ import { useTranslation } from "react-i18next";
27
+
28
+ import { observer } from "./canvas-observer";
29
+ import {
30
+ applySectionHeight,
31
+ type SectionHeightPage,
32
+ } from "../../lib/detail-page/section-height";
33
+
34
+ export type HeightPageLike = SectionHeightPage & { id: string };
35
+
36
+ type HistoryLike = {
37
+ startTransaction?: () => void;
38
+ endTransaction?: () => void;
39
+ };
40
+
41
+ /**
42
+ * 손잡이 하나. **자리를 잡아 주는 건 부모다** — `position: relative` 인 조상 아래
43
+ * 놓이면 그 상자의 아래 끝에 붙는다.
44
+ *
45
+ * 끄는 동안은 히스토리 트랜잭션으로 묶는다. 안 묶으면 마우스를 한 번 끄는 사이 수십 개의
46
+ * 되돌리기 단계가 쌓여 ⌘Z 를 스무 번 눌러야 원래 높이로 돌아간다.
47
+ */
48
+ export const SectionHeightHandle = observer(function SectionHeightHandle({
49
+ page,
50
+ scale,
51
+ history,
52
+ }: {
53
+ page: HeightPageLike;
54
+ /** 지금 배율. 화면 px 이동을 페이지 px 로 옮기는 데 쓴다. */
55
+ scale: number;
56
+ history?: HistoryLike;
57
+ }) {
58
+ const { t } = useTranslation("branding");
59
+ const drag = useRef<{ pointerId: number; y: number; base: number } | null>(null);
60
+ const [dragging, setDragging] = useState(false);
61
+ const height = Math.round(Number(page.computedHeight) || 0);
62
+
63
+ const begin = (e: ReactPointerEvent<HTMLDivElement>) => {
64
+ if (e.button !== 0) return;
65
+ // Konva 가 이 누름을 보면 캔버스 빈 곳 클릭으로 읽어 선택을 풀어 버린다.
66
+ e.preventDefault();
67
+ e.stopPropagation();
68
+ e.currentTarget.setPointerCapture(e.pointerId);
69
+ history?.startTransaction?.();
70
+ drag.current = { pointerId: e.pointerId, y: e.clientY, base: height };
71
+ setDragging(true);
72
+ };
73
+
74
+ const move = (e: ReactPointerEvent<HTMLDivElement>) => {
75
+ const d = drag.current;
76
+ if (!d || e.pointerId !== d.pointerId) return;
77
+ e.preventDefault();
78
+ // 화면 px → 페이지 px. 축소해서 보고 있으면 1px 을 끌어도 페이지는 더 많이 움직인다.
79
+ const dy = (e.clientY - d.y) / Math.max(scale || 1, 0.01);
80
+ applySectionHeight(page, d.base + dy);
81
+ };
82
+
83
+ const end = (e: ReactPointerEvent<HTMLDivElement>) => {
84
+ const d = drag.current;
85
+ if (!d || e.pointerId !== d.pointerId) return;
86
+ drag.current = null;
87
+ setDragging(false);
88
+ history?.endTransaction?.();
89
+ };
90
+
91
+ return (
92
+ <div
93
+ data-dp-section-height-handle=""
94
+ role="separator"
95
+ aria-orientation="horizontal"
96
+ aria-label={t("detailPage.properties.pageHeight")}
97
+ onPointerDown={begin}
98
+ onPointerMove={move}
99
+ onPointerUp={end}
100
+ onPointerCancel={end}
101
+ style={{
102
+ position: "absolute",
103
+ left: "50%",
104
+ bottom: -7,
105
+ transform: "translateX(-50%)",
106
+ width: 64,
107
+ height: 14,
108
+ zIndex: 20,
109
+ cursor: "ns-resize",
110
+ display: "flex",
111
+ alignItems: "center",
112
+ justifyContent: "center",
113
+ touchAction: "none",
114
+ // 캔버스 쪽에서는 이 손잡이를 이벤트가 안 통하는 상자 안에 넣는다.
115
+ pointerEvents: "auto",
116
+ }}
117
+ >
118
+ <div
119
+ style={{
120
+ width: dragging ? 64 : 44,
121
+ height: 5,
122
+ borderRadius: 999,
123
+ background: dragging ? "rgb(139, 92, 246)" : "rgba(23,23,23,0.28)",
124
+ transition: "width 120ms ease, background 120ms ease",
125
+ }}
126
+ />
127
+ {dragging ? (
128
+ <span
129
+ style={{
130
+ position: "absolute",
131
+ top: 16,
132
+ padding: "2px 7px",
133
+ borderRadius: 6,
134
+ background: "rgb(23,23,23)",
135
+ color: "#fff",
136
+ fontSize: 11,
137
+ fontVariantNumeric: "tabular-nums",
138
+ whiteSpace: "nowrap",
139
+ pointerEvents: "none",
140
+ }}
141
+ >
142
+ {height}px
143
+ </span>
144
+ ) : null}
145
+ </div>
146
+ );
147
+ });
148
+ SectionHeightHandle.displayName = "SectionHeightHandle";
149
+
150
+ type CanvasStoreLike = {
151
+ scale?: number;
152
+ activePage?: HeightPageLike;
153
+ history?: HistoryLike;
154
+ };
155
+
156
+ type Box = { left: number; top: number; width: number; height: number };
157
+
158
+ function sameBox(a: Box | null, b: Box | null): boolean {
159
+ if (!a || !b) return a === b;
160
+ return (
161
+ a.left === b.left &&
162
+ a.top === b.top &&
163
+ a.width === b.width &&
164
+ a.height === b.height
165
+ );
166
+ }
167
+
168
+ /**
169
+ * 우리 엔진 위에서 손잡이의 자리를 잡아 준다.
170
+ *
171
+ * 엔진이 그린 장의 상자(`[data-lc-page]`)를 재서 그 위에 이벤트가 안 통하는 껍데기를
172
+ * 겹치고, 손잡이만 그 안에서 받는다. 엔진 패키지 안에 편집기 전용 부품을 넣지 않기
173
+ * 위한 자리다 — 다른 오버레이들(말풍선 꼬리·hover)도 같은 방식이다.
174
+ *
175
+ * 끄는 동안 상자가 한 프레임 늦게 따라와도 값은 안 틀어진다. 끌기 계산이 상자 자리가
176
+ * 아니라 처음 눌렀을 때의 높이와 포인터 이동량만 쓰기 때문이다.
177
+ */
178
+ export const CanvasSectionHeightHandle = observer(
179
+ function CanvasSectionHeightHandle({
180
+ store,
181
+ containerRef,
182
+ scrollRef,
183
+ }: {
184
+ store: unknown;
185
+ containerRef: RefObject<HTMLDivElement | null>;
186
+ scrollRef: RefObject<HTMLDivElement | null>;
187
+ }) {
188
+ const s = store as CanvasStoreLike;
189
+ const page = s.activePage;
190
+ const pageId = page?.id ?? "";
191
+ const scale = Number(s.scale) || 1;
192
+ const height = Math.round(Number(page?.computedHeight) || 0);
193
+ const [box, setBox] = useState<Box | null>(null);
194
+ const frame = useRef<number | null>(null);
195
+
196
+ const measure = () => {
197
+ const host = containerRef.current;
198
+ const node = pageId
199
+ ? host?.querySelector<HTMLElement>(`[data-lc-page="${CSS.escape(pageId)}"]`)
200
+ : null;
201
+ const next: Box | null =
202
+ host && node
203
+ ? (() => {
204
+ const r = node.getBoundingClientRect();
205
+ const h = host.getBoundingClientRect();
206
+ return {
207
+ left: r.left - h.left,
208
+ top: r.top - h.top,
209
+ width: r.width,
210
+ height: r.height,
211
+ };
212
+ })()
213
+ : null;
214
+ // 같은 값이면 그대로 둔다 — 끄는 동안 매 프레임 새 객체를 넣으면 공짜 리렌더가 쌓인다.
215
+ setBox((prev) => (sameBox(prev, next) ? prev : next));
216
+ };
217
+
218
+ // 높이·배율이 바뀌면 장의 상자도 바뀐다 — 끄는 동안 손잡이가 아래 끝을 따라가야 한다.
219
+ //
220
+ // **두 번 잰다.** 처음 붙을 때는 그 자리에서 재도 소용이 없다 — 자식의 레이아웃
221
+ // 이펙트가 부모의 ref 보다 먼저 돌아서 `containerRef.current` 가 아직 비어 있다
222
+ // (다른 오버레이들이 프레임을 기다리는 이유가 이것이다). 반대로 높이가 바뀌는
223
+ // 동안은 ref 가 이미 있으므로 그 자리에서 재야 손잡이가 한 박자 늦지 않는다.
224
+ useLayoutEffect(() => {
225
+ measure();
226
+ const raf = requestAnimationFrame(measure);
227
+ return () => cancelAnimationFrame(raf);
228
+ // eslint-disable-next-line react-hooks/exhaustive-deps
229
+ }, [pageId, height, scale]);
230
+
231
+ useEffect(() => {
232
+ if (!pageId) return;
233
+ const scroller = scrollRef.current;
234
+ const onMove = () => {
235
+ if (frame.current !== null) return;
236
+ frame.current = requestAnimationFrame(() => {
237
+ frame.current = null;
238
+ measure();
239
+ });
240
+ };
241
+ scroller?.addEventListener("scroll", onMove, { passive: true });
242
+ window.addEventListener("resize", onMove);
243
+ return () => {
244
+ scroller?.removeEventListener("scroll", onMove);
245
+ window.removeEventListener("resize", onMove);
246
+ if (frame.current !== null) cancelAnimationFrame(frame.current);
247
+ frame.current = null;
248
+ };
249
+ // eslint-disable-next-line react-hooks/exhaustive-deps
250
+ }, [pageId, scrollRef]);
251
+
252
+ if (!page || !box) return null;
253
+
254
+ return (
255
+ <div
256
+ data-dp-section-height-frame=""
257
+ style={{
258
+ position: "absolute",
259
+ left: box.left,
260
+ top: box.top,
261
+ width: box.width,
262
+ height: box.height,
263
+ pointerEvents: "none",
264
+ zIndex: 21,
265
+ }}
266
+ >
267
+ <SectionHeightHandle
268
+ page={page}
269
+ scale={scale}
270
+ history={s.history}
271
+ />
272
+ </div>
273
+ );
274
+ },
275
+ );
276
+ CanvasSectionHeightHandle.displayName = "CanvasSectionHeightHandle";
@@ -0,0 +1,210 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+ import { useTranslation } from "react-i18next";
5
+
6
+ import { AnnotationDialog } from "./annotation-dialog";
7
+ import {
8
+ MAX_DESIGN_REFERENCES,
9
+ type DesignReference,
10
+ } from "../../lib/detail-page/design-reference";
11
+ import {
12
+ replaceCanvasPage,
13
+ type CanvasDocument,
14
+ } from "../../lib/detail-page/replace-page";
15
+ import { onSectionReauthorRequested } from "../../lib/detail-page/section-reauthor-bus";
16
+ import { useDetailPageHost } from "./detail-page-host-context";
17
+
18
+ /**
19
+ * 화면 재저작 배선 — 페이지 툴바의 요청을 받아 모달을 띄우고, 결과 페이지를 문서에
20
+ * 갈아 끼운다.
21
+ *
22
+ * 편집기 본체에서 분리한 이유는 두 가지다. 첫째, 이 흐름은 store 와 generatedId 만
23
+ * 있으면 완결된다. 둘째, 워크스페이스가 memo 로 잡혀 있어 콜백을 아래로 내리면
24
+ * 캔버스가 통째로 다시 그려진다.
25
+ *
26
+ * 밑그림은 ``store.toDataURL({pageId})`` 로 뜬다 — 편집기가 지금 보여 주는 그대로다.
27
+ * 유저는 자기가 보는 화면 위에 표시하지, 서버가 렌더한 다른 그림 위에 표시하지 않는다.
28
+ */
29
+
30
+ type StoreLike = {
31
+ toDataURL: (opts: {
32
+ pageId?: string;
33
+ pixelRatio?: number;
34
+ mimeType?: string;
35
+ }) => Promise<string>;
36
+ toJSON: () => Record<string, unknown>;
37
+ loadJSON: (json: unknown) => void;
38
+ pages?: Array<{ id: string; computedHeight?: number }>;
39
+ };
40
+
41
+ /**
42
+ * 지금 이 화면이 편집기에서 몇 px 인지. 서버는 이 값을 모델에게 "지금 높이"로 알려 준다.
43
+ *
44
+ * 저장본에서 읽지 않고 살아 있는 스토어에서 읽는 이유: 높이는 손잡이·우측 패널로 바뀌고
45
+ * 그 값은 저장을 누르기 전까지 서버에 없다. 저장본을 기준으로 삼으면 방금 잡아 놓은 높이를
46
+ * 재저작이 되돌린다.
47
+ */
48
+ export function livePageHeight(store: unknown, pageId: string): number | undefined {
49
+ const page = (store as StoreLike).pages?.find((p) => p.id === pageId);
50
+ const height = Math.round(Number(page?.computedHeight ?? 0));
51
+ return Number.isFinite(height) && height > 0 ? height : undefined;
52
+ }
53
+
54
+ export function SectionReauthorController({
55
+ store,
56
+ generatedId,
57
+ templateId,
58
+ }: {
59
+ store: unknown;
60
+ /** 없으면 배선하지 않는다 — 툴바 버튼도 뜨지 않는다. */
61
+ generatedId?: string;
62
+ /**
63
+ * 지금 열려 있는 문서의 템플릿 id.
64
+ *
65
+ * dev-canvas 는 픽스처를 **브라우저에서만** 띄운다 — 서버의 scratch 인스턴스에는
66
+ * 템플릿도 HTML 도 없어서, 알려 주지 않으면 시연 하니스에서는 이 기능을 눌러 볼 수
67
+ * 없다. 실제 인스턴스에서는 서버가 자기 템플릿을 쓰므로 무시된다.
68
+ */
69
+ templateId?: string;
70
+ }) {
71
+ const { t } = useTranslation("branding");
72
+ const { api } = useDetailPageHost();
73
+ const [pageId, setPageId] = useState<string | null>(null);
74
+ const [baseImage, setBaseImage] = useState<string | null>(null);
75
+ const [busy, setBusy] = useState(false);
76
+ const [error, setError] = useState<string | null>(null);
77
+ const [notice, setNotice] = useState<string | null>(null);
78
+
79
+ useEffect(() => {
80
+ if (!generatedId) return;
81
+ return onSectionReauthorRequested((requestedPageId) => {
82
+ setError(null);
83
+ setNotice(null);
84
+ setPageId(requestedPageId);
85
+ setBaseImage(null);
86
+ // 렌더는 비동기다(Canvas 가 대상 페이지를 잠깐 강제 마운트한다). 실패해도 모달은
87
+ // 열어 둔다 — 밑그림 없이도 글로만 지시할 수 있어야 한다.
88
+ void (store as StoreLike)
89
+ .toDataURL({ pageId: requestedPageId, pixelRatio: 1 })
90
+ .then(setBaseImage)
91
+ .catch(() => setBaseImage(null));
92
+ });
93
+ }, [store, generatedId]);
94
+
95
+ const close = useCallback(() => {
96
+ if (busy) return;
97
+ setPageId(null);
98
+ setBaseImage(null);
99
+ setError(null);
100
+ }, [busy]);
101
+
102
+ const submit = useCallback(
103
+ async ({
104
+ instruction,
105
+ annotatedImage,
106
+ references,
107
+ }: {
108
+ instruction: string;
109
+ annotatedImage: string | null;
110
+ references: DesignReference[];
111
+ }) => {
112
+ if (!generatedId || !pageId) return;
113
+ setBusy(true);
114
+ setError(null);
115
+ try {
116
+ const result = await api.reauthorDetailPageSection(generatedId, {
117
+ label: pageId,
118
+ instruction,
119
+ annotated_image: annotatedImage ?? undefined,
120
+ // 축을 고른 장은 객체로 간다 — 서버가 그 장의 딱지에 축을 적어야 "1번 이미지의
121
+ // 색감"이라는 지시가 어느 장을 가리키는지 모델이 안다.
122
+ reference_images: references.length ? references : undefined,
123
+ current_height: livePageHeight(store, pageId),
124
+ template_id: templateId,
125
+ });
126
+ const document = (store as StoreLike).toJSON() as CanvasDocument;
127
+ const next = replaceCanvasPage(document, result.page);
128
+ if (next === document) {
129
+ setError(
130
+ t("detailPage.reauthor.pageMissing", {
131
+ defaultValue: "고친 화면을 문서에서 찾지 못했어요.",
132
+ }),
133
+ );
134
+ return;
135
+ }
136
+ // 첫 로드와 **같은 길**로 넣는다 — 어댑터를 안 거친다. 그 어댑터는 SDK가 못 읽는
137
+ // 장식을 대표 단색으로 낮추던 것이라, 원본을 그대로 읽는 우리 렌더러에 넣으면
138
+ // 그 화면만 밋밋해진다.
139
+ (store as StoreLike).loadJSON(next);
140
+ setPageId(null);
141
+ setBaseImage(null);
142
+ if (!result.lint_ok) {
143
+ // 규약 위반이 남아도 결과는 적용한다. 조용히 되돌리면 유저는 "아무 일도 안
144
+ // 일어났다"만 보고, 그건 크레딧만 쓴 상태와 구별되지 않는다.
145
+ setNotice(
146
+ t("detailPage.reauthor.lintWarning", {
147
+ defaultValue:
148
+ "화면을 바꿨지만 규약 검사에 걸린 항목이 있어요. 눈으로 한 번 확인해 주세요.",
149
+ }),
150
+ );
151
+ }
152
+ } catch (err) {
153
+ setError(
154
+ err instanceof Error
155
+ ? err.message
156
+ : t("detailPage.reauthor.failed", {
157
+ defaultValue: "화면을 다시 만들지 못했어요.",
158
+ }),
159
+ );
160
+ } finally {
161
+ setBusy(false);
162
+ }
163
+ },
164
+ [api, generatedId, pageId, store, t, templateId],
165
+ );
166
+
167
+ if (!generatedId) return null;
168
+
169
+ return (
170
+ <>
171
+ <AnnotationDialog
172
+ open={Boolean(pageId)}
173
+ // 렌더 전이면 흰 밑그림으로 연다 — 글로만 지시하는 길을 막지 않는다.
174
+ imageUrl={baseImage ?? BLANK_BASE}
175
+ title={t("detailPage.reauthor.title", {
176
+ defaultValue: "이 화면 다시 만들기",
177
+ })}
178
+ description={t("detailPage.reauthor.description", {
179
+ defaultValue:
180
+ "고칠 자리를 표시하고 어떻게 바꿀지 적어주세요. 칸 수·표 같은 구조는 물론 " +
181
+ '화면 길이도 바뀔 수 있어요("더 시원하게", "너무 길어요").',
182
+ })}
183
+ submitLabel={t("detailPage.reauthor.submit", {
184
+ defaultValue: "다시 만들기",
185
+ })}
186
+ maxReferences={MAX_DESIGN_REFERENCES}
187
+ busy={busy}
188
+ error={error}
189
+ onSubmit={submit}
190
+ onClose={close}
191
+ />
192
+ {notice ? (
193
+ <div className="fixed bottom-4 left-1/2 z-[110] -translate-x-1/2 rounded-lg border border-amber-200 bg-amber-50 px-4 py-2 text-xs text-amber-800 shadow-lg">
194
+ <span>{notice}</span>
195
+ <button
196
+ type="button"
197
+ onClick={() => setNotice(null)}
198
+ className="ml-3 font-semibold underline"
199
+ >
200
+ {t("detailPage.annotate.close", { defaultValue: "닫기" })}
201
+ </button>
202
+ </div>
203
+ ) : null}
204
+ </>
205
+ );
206
+ }
207
+
208
+ /** 1×1 흰 픽셀. 밑그림 렌더가 실패해도 캔버스가 열리도록 하는 최소 배경. */
209
+ const BLANK_BASE =
210
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";