@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,313 @@
1
+ "use client";
2
+
3
+ /**
4
+ * 우리 엔진 위의 작업 영역 (G7-b).
5
+ *
6
+ * ``StackedCanvasWorkspace``가 하던 일을 그대로 한다 — 세로로 쌓기, 처음 한 번
7
+ * 화면에 맞추기, ⌘/ctrl+휠 확대, 스크롤에 따라 활성 화면 바꾸기, 썸네일 굽기,
8
+ * 그리고 캔버스 위에 얹히는 층들. 다른 점은 페이지를 **누가** 그리느냐뿐이다.
9
+ *
10
+ * 창(windowing)을 여기서 안 센다. 엔진의 ``CanvasView``가 페이지마다 화면 근처인지
11
+ * 스스로 보고 Stage를 만들거나 만들지 않는다 — 슬롯 높이를 미리 계산해 인덱스로
12
+ * 세던 예전 방식보다 정확하고, 페이지 높이가 제각각인 상세페이지에서 특히 그렇다.
13
+ *
14
+ * 썸네일도 DOM에서 캔버스를 긁지 않는다. 엔진의 ``store.toDataURL({pageId})``가
15
+ * 화면 밖 페이지를 잠깐 띄워 굽고 다시 놓아준다 — 예전에 이 길을 못 쓴 이유(내보내기
16
+ * 인스턴스가 화면의 Konva 를 못 찾는 문제)는 우리 엔진에는 없다.
17
+ */
18
+
19
+ import {
20
+ useCallback,
21
+ useEffect,
22
+ useMemo,
23
+ useRef,
24
+ useState,
25
+ type ReactNode,
26
+ } from "react";
27
+
28
+ import { BubbleTailOverlay } from "./bubble-tail-overlay";
29
+ import { CanvasContextMenu } from "./canvas-context-menu";
30
+ import { CanvasOverlayHost } from "./canvas-overlay-host";
31
+ import { DetailPagePageToolbar } from "./detail-page-page-toolbar";
32
+ import { detailPageThumbnailBus } from "./detail-page-thumbnail-bus";
33
+ import { GifAnimator } from "./gif-animator";
34
+ import { GroupDrillIn } from "./group-drill-in";
35
+ import { HoverHighlightOverlay } from "./hover-highlight-overlay";
36
+ import { CanvasSectionHeightHandle } from "./section-height-handle";
37
+ import { loadEditorFont } from "../../lib/detail-page-canvas/editor-fonts";
38
+ import { CanvasView } from "@leviosa-ai/canvas/render/canvas-view";
39
+ import { useCanvasVersion } from "@leviosa-ai/canvas/use-canvas";
40
+ import type { CanvasStore } from "@leviosa-ai/canvas/store";
41
+
42
+ const MIN_SCALE = 0.05;
43
+ const MAX_SCALE = 5;
44
+ const clamp = (v: number, lo: number, hi: number) =>
45
+ Math.max(lo, Math.min(hi, v));
46
+
47
+ /** 썸네일 해상도. 페이지 패널의 칸이 작아 이 정도면 충분하다. */
48
+ const THUMB_PIXEL_RATIO = 0.12;
49
+
50
+ export function LeviosaCanvasWorkspace({
51
+ store,
52
+ gap = 4,
53
+ paddingX = 16,
54
+ backgroundColor = "rgb(241, 241, 241)",
55
+ children,
56
+ }: {
57
+ store: CanvasStore;
58
+ gap?: number;
59
+ paddingX?: number;
60
+ backgroundColor?: string;
61
+ /** 작업 영역 위에 얹을 것(찾기·바꾸기 같은 편집기 고유 층). */
62
+ children?: ReactNode;
63
+ }) {
64
+ useCanvasVersion(store);
65
+ const outerRef = useRef<HTMLDivElement>(null);
66
+ const innerRef = useRef<HTMLDivElement>(null);
67
+ const [viewport, setViewport] = useState({ width: 0, height: 0 });
68
+ // 배율은 **스토어**에 산다. 확대 버튼도, 여기 휠도 같은 자리를 만져야 한 쪽이
69
+ // 다른 쪽을 되돌려 놓지 않는다.
70
+ const scale = store.scale;
71
+ const setScale = useCallback((next: number) => store.setScale(next), [store]);
72
+ const pageIds = store.pages.map((page) => page.id).join(",");
73
+
74
+ useEffect(() => {
75
+ const el = outerRef.current;
76
+ if (!el) return;
77
+ const apply = () =>
78
+ setViewport({ width: el.clientWidth, height: el.clientHeight });
79
+ apply();
80
+ const ro = new ResizeObserver(apply);
81
+ ro.observe(el);
82
+ return () => ro.disconnect();
83
+ }, []);
84
+
85
+ // 첫 화면을 통째로 화면에 넣는다. 폭만 맞추면 긴 상세페이지가 화면보다 길어진다.
86
+ //
87
+ // **한 번만 하면 안 된다.** 편집기가 처음 뜰 때는 좌우 패널이 아직 자리를 안 잡아
88
+ // 작업 영역이 실제보다 넓게 측정되고, 그 폭에 맞춘 배율은 패널이 들어오는 순간
89
+ // 너무 크다(맨 처음 붙였을 때 134%가 나왔다). 그래서 **사용자가 배율을 만지기
90
+ // 전까지는** 영역이 바뀔 때마다 다시 맞춘다. 손을 대는 순간 주인이 바뀐다.
91
+ const fittedScale = useRef<number | null>(null);
92
+ useEffect(() => {
93
+ if (!viewport.width || !viewport.height || store.pages.length === 0) return;
94
+ // 우리가 맞춰 놓은 값과 다르면 사용자가 만진 것이다.
95
+ if (fittedScale.current !== null && store.scale !== fittedScale.current) {
96
+ return;
97
+ }
98
+ const page = store.activePage ?? store.pages[0];
99
+ const usableW = Math.max(1, viewport.width - 2 * paddingX);
100
+ const usableH = Math.max(1, viewport.height - 2 * gap);
101
+ const next = clamp(
102
+ Math.min(usableW / page.width, usableH / page.height) * 0.94,
103
+ MIN_SCALE,
104
+ MAX_SCALE,
105
+ );
106
+ fittedScale.current = next;
107
+ setScale(next);
108
+ }, [viewport, paddingX, gap, store, setScale]);
109
+
110
+ // ⌘/ctrl+휠(맥 트랙패드 핀치가 이 모양으로 온다)로 커서 자리를 붙잡고 확대.
111
+ // 그냥 휠은 브라우저 스크롤 그대로 둔다.
112
+ useEffect(() => {
113
+ const inner = innerRef.current;
114
+ if (!inner) return;
115
+ const onWheel = (event: WheelEvent) => {
116
+ if (!event.ctrlKey && !event.metaKey) return;
117
+ event.preventDefault();
118
+ const current = store.scale;
119
+ const next = clamp(
120
+ current * (event.deltaY < 0 ? 1.08 : 1 / 1.08),
121
+ MIN_SCALE,
122
+ MAX_SCALE,
123
+ );
124
+ if (next === current) return;
125
+ const rect = inner.getBoundingClientRect();
126
+ const anchor = event.clientY - rect.top + inner.scrollTop;
127
+ const ratio = next / current;
128
+ store.setScale(next);
129
+ requestAnimationFrame(() => {
130
+ inner.scrollTop = anchor * ratio - (event.clientY - rect.top);
131
+ });
132
+ };
133
+ inner.addEventListener("wheel", onWheel, { passive: false });
134
+ return () => inner.removeEventListener("wheel", onWheel);
135
+ }, [store]);
136
+
137
+ /** 스크롤 때문에 우리가 바꾼 활성 화면 — 바깥에서 바꾼 것과 구분해 되울림을 막는다. */
138
+ const scrollSetId = useRef<string | null>(null);
139
+
140
+ // 화면 한가운데를 지나는 페이지가 활성 화면이다. 자리는 DOM 에서 직접 읽는다 —
141
+ // 높이가 제각각이라 계산으로 맞추면 한 픽셀씩 어긋난다.
142
+ const recomputeActive = useCallback(() => {
143
+ const inner = innerRef.current;
144
+ if (!inner) return;
145
+ const middle = inner.getBoundingClientRect().top + inner.clientHeight / 2;
146
+ const nodes = Array.from(
147
+ inner.querySelectorAll<HTMLElement>("[data-lc-page]"),
148
+ );
149
+ const hit =
150
+ nodes.find((node) => {
151
+ const box = node.getBoundingClientRect();
152
+ return box.top <= middle && box.bottom >= middle;
153
+ }) ?? nodes[0];
154
+ const id = hit?.dataset.lcPage;
155
+ if (!id || store.activePage?.id === id) return;
156
+ scrollSetId.current = id;
157
+ store.selectPage(id);
158
+ }, [store]);
159
+
160
+ const scrollRaf = useRef<number | null>(null);
161
+ const onScroll = useCallback(() => {
162
+ if (scrollRaf.current != null) return;
163
+ scrollRaf.current = requestAnimationFrame(() => {
164
+ scrollRaf.current = null;
165
+ recomputeActive();
166
+ });
167
+ }, [recomputeActive]);
168
+
169
+ // 바깥(페이지 패널의 행 클릭)에서 활성 화면이 바뀌면 그 페이지를 위로 올린다.
170
+ const activeId = store.activePage?.id;
171
+ useEffect(() => {
172
+ const inner = innerRef.current;
173
+ if (!inner || !activeId || activeId === scrollSetId.current) return;
174
+ scrollSetId.current = activeId;
175
+ const node = inner.querySelector<HTMLElement>(
176
+ `[data-lc-page="${CSS.escape(activeId)}"]`,
177
+ );
178
+ if (!node) return;
179
+ inner.scrollTo({
180
+ top: inner.scrollTop + node.getBoundingClientRect().top -
181
+ inner.getBoundingClientRect().top - gap,
182
+ behavior: "smooth",
183
+ });
184
+ }, [activeId, gap]);
185
+
186
+ // 페이지 패널을 열면 아직 없는 썸네일을 한 장씩 굽는다. 한 번에 하나씩 굽는 이유는
187
+ // 굽는 동안 그 페이지를 화면 밖에서도 그려야 해서다 — 30장을 한꺼번에 띄우면
188
+ // 브라우저가 멈춘다.
189
+ const panelOpen = store.openedSidePanel === "pages";
190
+ useEffect(() => {
191
+ if (!panelOpen) return;
192
+ let cancelled = false;
193
+ void (async () => {
194
+ for (const page of store.pages) {
195
+ if (cancelled) return;
196
+ if (detailPageThumbnailBus.has(page.id)) continue;
197
+ try {
198
+ const uri = await store.toDataURL({
199
+ pageId: page.id,
200
+ pixelRatio: THUMB_PIXEL_RATIO,
201
+ });
202
+ if (cancelled) return;
203
+ detailPageThumbnailBus.set(page.id, uri);
204
+ } catch {
205
+ // 못 구운 페이지는 다음에 다시 시도한다(패널을 다시 열면 온다).
206
+ }
207
+ }
208
+ })();
209
+ return () => {
210
+ cancelled = true;
211
+ };
212
+ }, [panelOpen, pageIds, store]);
213
+
214
+ const pageWidth = useMemo(
215
+ () => Math.max(1, ...store.pages.map((page) => page.width)),
216
+ // 페이지 구성이 바뀔 때만 다시 잰다.
217
+ // eslint-disable-next-line react-hooks/exhaustive-deps
218
+ [pageIds, store],
219
+ );
220
+
221
+ return (
222
+ <div
223
+ ref={outerRef}
224
+ data-lc-workspace=""
225
+ style={{
226
+ width: "100%",
227
+ height: "100%",
228
+ position: "relative",
229
+ outline: "none",
230
+ backgroundColor,
231
+ overflow: "hidden",
232
+ }}
233
+ tabIndex={0}
234
+ >
235
+ <div
236
+ ref={innerRef}
237
+ onScroll={onScroll}
238
+ onPointerDown={(event) => {
239
+ // 페이지 바깥의 빈 자리를 누르면 선택 해제. 페이지 안은 캔버스가 처리한다.
240
+ const target = event.target as HTMLElement;
241
+ if (
242
+ !target.closest("[data-lc-page]") &&
243
+ !target.closest("[data-dp-quicktoolbar]")
244
+ ) {
245
+ store.selectElements([]);
246
+ }
247
+ }}
248
+ style={{
249
+ position: "absolute",
250
+ inset: 0,
251
+ overflowX: "hidden",
252
+ overflowY: "auto",
253
+ display: "flex",
254
+ flexDirection: "column",
255
+ alignItems: "center",
256
+ padding: `${gap}px ${paddingX}px`,
257
+ }}
258
+ >
259
+ <CanvasView
260
+ store={store}
261
+ scale={scale}
262
+ gap={gap}
263
+ interactive
264
+ loadFont={loadEditorFont}
265
+ />
266
+ </div>
267
+
268
+ {/* 표·차트 레일과 크기 되먹임. 이 둘만 스토어를 구조적 타입으로 받으므로
269
+ 얼굴을 하나 씌워 넘긴다(canvas-store-facade에 이유를 적어 뒀다). */}
270
+ <CanvasOverlayHost store={store} containerRef={outerRef} />
271
+
272
+ {/* 말풍선 꼬리 핸들 · 우클릭 메뉴 · 레이어 트리 hover · 그룹 파고들기 · GIF 재생.
273
+ 선택 상자와 크기 손잡이는 엔진이 직접 그린다(그룹 안 요소까지). */}
274
+ <BubbleTailOverlay store={store} containerRef={outerRef} />
275
+ <CanvasContextMenu store={store} containerRef={outerRef} />
276
+ <HoverHighlightOverlay
277
+ store={store}
278
+ containerRef={outerRef}
279
+ scrollRef={innerRef}
280
+ />
281
+ <GroupDrillIn store={store} containerRef={outerRef} />
282
+ <GifAnimator store={store} />
283
+ {/* 활성 화면의 아래 끝을 잡아 끌어 길이를 바꾸는 손잡이. 우측 패널의 숫자와
284
+ 같은 함수를 거친다(`section-height.ts`) — 배경 요소까지 같이 늘리고 서버
285
+ 굽기 상한 안에 가둔다. */}
286
+ <CanvasSectionHeightHandle
287
+ store={store}
288
+ containerRef={outerRef}
289
+ scrollRef={innerRef}
290
+ />
291
+
292
+ {children}
293
+
294
+ {store.activePage ? (
295
+ <div
296
+ data-dp-quicktoolbar=""
297
+ style={{
298
+ position: "absolute",
299
+ left: Math.min(
300
+ viewport.width / 2 + (pageWidth * scale) / 2 + 8,
301
+ viewport.width - 52,
302
+ ),
303
+ top: "50%",
304
+ transform: "translateY(-50%)",
305
+ zIndex: 30,
306
+ }}
307
+ >
308
+ <DetailPagePageToolbar store={store} page={store.activePage} />
309
+ </div>
310
+ ) : null}
311
+ </div>
312
+ );
313
+ }
@@ -0,0 +1,364 @@
1
+ "use client";
2
+
3
+ import {
4
+ useEffect,
5
+ useLayoutEffect,
6
+ useRef,
7
+ useState,
8
+ type PointerEvent as ReactPointerEvent,
9
+ type RefObject,
10
+ } from "react";
11
+ import { observer } from "./canvas-observer";
12
+
13
+ import {
14
+ selectedElementsDeep,
15
+ type SelectableElement,
16
+ } from "./detail-page-selection";
17
+ import {
18
+ elementClientRect,
19
+ type RectElement,
20
+ } from "./element-rects";
21
+
22
+ /**
23
+ * Real selection — move + resize — for a GROUP CHILD.
24
+ *
25
+ * the stock editor's selection model only ever holds TOP-LEVEL element ids: its canvas
26
+ * resolves any hit node to ``el.top`` (the outermost non-group ancestor) before
27
+ * selecting, and ``store.selectedShapes`` (what the Konva Transformer attaches
28
+ * to) derives from ``store.selectedElements``, a getter that only scans a page's
29
+ * direct children. So a group child gets no transformer, and the moment you press
30
+ * on it the canvas re-selects its group instead — you cannot drag it.
31
+ *
32
+ * Rather than fight that model, this overlay OWNS the interaction. It sits above
33
+ * the canvas in the DOM, so a pointerdown on the selected child is consumed here
34
+ * and never reaches Konva — the group can no longer steal the selection. Drag and
35
+ * corner-resize write straight to the element (``el.set``), which is exactly what
36
+ * the stock editor's own transformer would do, so undo/redo and re-render behave normally.
37
+ *
38
+ * Geometry comes from the Konva node itself (``getClientRect`` → absolute canvas
39
+ * px, zoom included) mapped through the stage's container rect, so the overlay is
40
+ * layout-independent: zoom, scroll, and the stacked workspace's per-page slots all
41
+ * work without re-deriving any of their math. Screen deltas convert to element
42
+ * deltas by dividing by ``store.scale``.
43
+ */
44
+
45
+ type StoreLike = {
46
+ selectedElements?: SelectableElement[];
47
+ selectedElementsIds?: string[];
48
+ getElementById?: (id: string) => SelectableElement | undefined;
49
+ scale?: number;
50
+ history?: { startTransaction?: () => void; endTransaction?: () => void };
51
+ };
52
+
53
+ type Rect = { left: number; top: number; width: number; height: number };
54
+ type Box = { id: string; rect: Rect; isGroup: boolean };
55
+ type Corner = "nw" | "ne" | "sw" | "se";
56
+
57
+ /** The selected ids Canvas itself cannot draw — i.e. the ones nested in a group. */
58
+ export function nestedSelectedIds(store: StoreLike): string[] {
59
+ const deep = selectedElementsDeep(store);
60
+ if (!deep.length) return [];
61
+ // ``selectedElements`` only ever holds TOP-LEVEL elements; anything resolved
62
+ // deep but absent there is nested, and therefore has no Canvas transformer.
63
+ const topLevel = new Set((store.selectedElements ?? []).map((e) => e.id));
64
+ return deep.map((e) => e.id).filter((id) => !topLevel.has(id));
65
+ }
66
+
67
+ /**
68
+ * Measure a selected element and return its rect in `container` space.
69
+ *
70
+ * Goes through ``elementClientRect``, which measures a GROUP from its children:
71
+ * Canvas never stamps the element id onto the Konva.Group it renders, so a direct
72
+ * `#<groupId>` lookup finds nothing — a group nested inside another group would get
73
+ * no selection box at all.
74
+ */
75
+ function measure(el: SelectableElement, container: HTMLElement): Rect | null {
76
+ const r = elementClientRect(el as RectElement);
77
+ if (!r) return null;
78
+ const host = container.getBoundingClientRect();
79
+ return {
80
+ left: r.left - host.left,
81
+ top: r.top - host.top,
82
+ width: r.right - r.left,
83
+ height: r.bottom - r.top,
84
+ };
85
+ }
86
+
87
+ const num = (v: unknown, fallback = 0): number =>
88
+ typeof v === "number" && Number.isFinite(v) ? v : fallback;
89
+
90
+ const HANDLE = 8;
91
+ const ACCENT = "rgb(0, 161, 255)";
92
+ const MIN_SIZE = 4;
93
+
94
+ const CURSOR: Record<Corner, string> = {
95
+ nw: "nwse-resize",
96
+ ne: "nesw-resize",
97
+ sw: "nesw-resize",
98
+ se: "nwse-resize",
99
+ };
100
+
101
+ export type Geom = { x: number; y: number; width: number; height: number };
102
+
103
+ /**
104
+ * Element geometry after dragging by (dx, dy) *element* px. ``corner === null``
105
+ * moves; a corner resizes, anchoring the opposite corner (so a west/north grab
106
+ * shifts x/y as the size changes). Sizes clamp so a box never inverts.
107
+ */
108
+ export function applyDrag(
109
+ start: Geom,
110
+ corner: Corner | null,
111
+ dx: number,
112
+ dy: number,
113
+ ): Geom {
114
+ if (!corner) {
115
+ return { ...start, x: Math.round(start.x + dx), y: Math.round(start.y + dy) };
116
+ }
117
+ const west = corner === "nw" || corner === "sw";
118
+ const north = corner === "nw" || corner === "ne";
119
+ const width = Math.max(MIN_SIZE, west ? start.width - dx : start.width + dx);
120
+ const height = Math.max(MIN_SIZE, north ? start.height - dy : start.height + dy);
121
+ return {
122
+ x: Math.round(west ? start.x + (start.width - width) : start.x),
123
+ y: Math.round(north ? start.y + (start.height - height) : start.y),
124
+ width: Math.round(width),
125
+ height: Math.round(height),
126
+ };
127
+ }
128
+
129
+ /** One element the drag writes to, with the geometry it started from. */
130
+ type DragTarget = { el: SelectableElement; start: Geom };
131
+
132
+ type DragState = {
133
+ pointerId: number;
134
+ corner: Corner | null; // null = move
135
+ startX: number;
136
+ startY: number;
137
+ targets: DragTarget[];
138
+ };
139
+
140
+ /**
141
+ * The elements a drag actually has to write to.
142
+ *
143
+ * A GROUP is not a transform: the stock editor's group element carries no offset (setting
144
+ * its x/y moves nothing — verified on the live store), the LEAVES hold the real
145
+ * coordinates. So moving a group means moving every leaf under it by the delta.
146
+ */
147
+ export function dragTargets(el: SelectableElement): DragTarget[] {
148
+ const kids = (el as { children?: SelectableElement[] }).children;
149
+ if (kids?.length) return kids.flatMap(dragTargets);
150
+ return [
151
+ {
152
+ el,
153
+ start: {
154
+ x: num(el.x),
155
+ y: num(el.y),
156
+ width: num(el.width),
157
+ height: num(el.height),
158
+ },
159
+ },
160
+ ];
161
+ }
162
+
163
+ export const NestedSelectionOverlay = observer(function NestedSelectionOverlay({
164
+ store,
165
+ containerRef,
166
+ scrollRef,
167
+ }: {
168
+ store: unknown;
169
+ containerRef: RefObject<HTMLDivElement | null>;
170
+ scrollRef: RefObject<HTMLDivElement | null>;
171
+ }) {
172
+ const s = store as StoreLike;
173
+ const ids = nestedSelectedIds(s);
174
+ const key = ids.join(",");
175
+ const scale = s.scale ?? 1;
176
+
177
+ // Reading each element's geometry here (mobx) re-renders the observer — and
178
+ // re-measures — as a drag/resize moves it. Read the LEAVES: a group's own x/y
179
+ // never change (they are not where its geometry lives), so watching them would
180
+ // leave the box frozen while the group is dragged.
181
+ const geomKey = ids
182
+ .map((id) => {
183
+ const el = s.getElementById?.(id);
184
+ if (!el) return "";
185
+ return dragTargets(el)
186
+ .map((t) => `${t.start.x},${t.start.y},${t.start.width},${t.start.height}`)
187
+ .join(";");
188
+ })
189
+ .join("|");
190
+
191
+ const [boxes, setBoxes] = useState<Box[]>([]);
192
+ const frame = useRef<number | null>(null);
193
+ const drag = useRef<DragState | null>(null);
194
+
195
+ const remeasure = () => {
196
+ const host = containerRef.current;
197
+ if (!host || !ids.length) {
198
+ setBoxes((prev) => (prev.length ? [] : prev));
199
+ return;
200
+ }
201
+ const next: Box[] = [];
202
+ for (const id of ids) {
203
+ const el = s.getElementById?.(id);
204
+ if (!el) continue;
205
+ const rect = measure(el, host);
206
+ // Each box carries its own id: a group whose children are all off-screen
207
+ // measures to nothing, and an index-aligned array would then hand the next
208
+ // box the wrong element to drag.
209
+ if (rect) next.push({ id, rect, isGroup: el.type === "group" });
210
+ }
211
+ setBoxes(next);
212
+ };
213
+
214
+ // Measure after Konva has redrawn for this selection / zoom / geometry change.
215
+ useLayoutEffect(() => {
216
+ const raf = requestAnimationFrame(() => {
217
+ remeasure();
218
+ requestAnimationFrame(remeasure);
219
+ });
220
+ return () => cancelAnimationFrame(raf);
221
+ // eslint-disable-next-line react-hooks/exhaustive-deps
222
+ }, [key, scale, geomKey]);
223
+
224
+ // Follow scroll / resize while a nested element stays selected.
225
+ useEffect(() => {
226
+ if (!key) return;
227
+ const scroller = scrollRef.current;
228
+ const onMove = () => {
229
+ if (frame.current !== null) return;
230
+ frame.current = requestAnimationFrame(() => {
231
+ frame.current = null;
232
+ remeasure();
233
+ });
234
+ };
235
+ scroller?.addEventListener("scroll", onMove, { passive: true });
236
+ window.addEventListener("resize", onMove);
237
+ return () => {
238
+ scroller?.removeEventListener("scroll", onMove);
239
+ window.removeEventListener("resize", onMove);
240
+ if (frame.current !== null) cancelAnimationFrame(frame.current);
241
+ frame.current = null;
242
+ };
243
+ // eslint-disable-next-line react-hooks/exhaustive-deps
244
+ }, [key, scrollRef]);
245
+
246
+ const begin = (
247
+ e: ReactPointerEvent<HTMLElement>,
248
+ id: string,
249
+ corner: Corner | null,
250
+ ) => {
251
+ const el = s.getElementById?.(id);
252
+ if (!el) return;
253
+ // A locked layer must not move — same rule the Canvas transformer follows.
254
+ if (el.draggable === false && !corner) return;
255
+ // Consume the press so Konva never sees it: that is what stops the canvas
256
+ // from resolving the hit to `el.top` and re-selecting the parent group.
257
+ e.preventDefault();
258
+ e.stopPropagation();
259
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
260
+ s.history?.startTransaction?.();
261
+ drag.current = {
262
+ pointerId: e.pointerId,
263
+ corner,
264
+ startX: e.clientX,
265
+ startY: e.clientY,
266
+ targets: dragTargets(el),
267
+ };
268
+ };
269
+
270
+ const move = (e: ReactPointerEvent<HTMLElement>) => {
271
+ const d = drag.current;
272
+ if (!d || e.pointerId !== d.pointerId) return;
273
+ e.preventDefault();
274
+ const k = scale || 1;
275
+ // Screen px -> element px. Element coords are in the parent's space, so a
276
+ // relative delta is correct whether or not the group carries an offset.
277
+ const dx = (e.clientX - d.startX) / k;
278
+ const dy = (e.clientY - d.startY) / k;
279
+
280
+ for (const t of d.targets) {
281
+ const next = applyDrag(t.start, d.corner, dx, dy);
282
+ t.el.set(d.corner ? next : { x: next.x, y: next.y });
283
+ }
284
+ };
285
+
286
+ const end = (e: ReactPointerEvent<HTMLElement>) => {
287
+ const d = drag.current;
288
+ if (!d || e.pointerId !== d.pointerId) return;
289
+ drag.current = null;
290
+ s.history?.endTransaction?.();
291
+ };
292
+
293
+ if (!boxes.length) return null;
294
+
295
+ return (
296
+ <div
297
+ data-dp-nested-selection=""
298
+ style={{
299
+ position: "absolute",
300
+ inset: 0,
301
+ // Only the boxes themselves take pointer events; everywhere else the
302
+ // canvas keeps its normal click behaviour.
303
+ pointerEvents: "none",
304
+ zIndex: 20,
305
+ }}
306
+ >
307
+ {boxes.map(({ id, rect: r, isGroup }) => (
308
+ <div
309
+ key={id}
310
+ onPointerDown={(e) => begin(e, id, null)}
311
+ onPointerMove={move}
312
+ onPointerUp={end}
313
+ onPointerCancel={end}
314
+ style={{
315
+ position: "absolute",
316
+ left: r.left,
317
+ top: r.top,
318
+ width: r.width,
319
+ height: r.height,
320
+ border: `1px solid ${ACCENT}`,
321
+ boxSizing: "border-box",
322
+ pointerEvents: "auto",
323
+ cursor: "move",
324
+ touchAction: "none",
325
+ }}
326
+ >
327
+ {/* A group is move-only. Its width/height are not a real box (the
328
+ decomposer pins the wrapper to the origin and the children hold the
329
+ coordinates), so a corner drag would resize nothing and mangle the
330
+ geometry. Drag its children to resize. */}
331
+ {isGroup
332
+ ? null
333
+ : (["nw", "ne", "sw", "se"] as const).map((corner) => (
334
+ <span
335
+ key={corner}
336
+ onPointerDown={(e) => begin(e, id, corner)}
337
+ onPointerMove={move}
338
+ onPointerUp={end}
339
+ onPointerCancel={end}
340
+ style={{
341
+ position: "absolute",
342
+ left:
343
+ (corner === "ne" || corner === "se" ? r.width : 0) -
344
+ HANDLE / 2,
345
+ top:
346
+ (corner === "sw" || corner === "se" ? r.height : 0) -
347
+ HANDLE / 2,
348
+ width: HANDLE,
349
+ height: HANDLE,
350
+ background: "#fff",
351
+ border: `1px solid ${ACCENT}`,
352
+ boxSizing: "border-box",
353
+ pointerEvents: "auto",
354
+ cursor: CURSOR[corner],
355
+ touchAction: "none",
356
+ }}
357
+ />
358
+ ))}
359
+ </div>
360
+ ))}
361
+ </div>
362
+ );
363
+ });
364
+ NestedSelectionOverlay.displayName = "NestedSelectionOverlay";