@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,707 @@
1
+ "use client";
2
+
3
+ import {
4
+ useEffect,
5
+ useRef,
6
+ useState,
7
+ type CSSProperties,
8
+ type PointerEvent as ReactPointerEvent,
9
+ type RefObject,
10
+ } from "react";
11
+ import { observer } from "./canvas-observer";
12
+ import { useTranslation } from "react-i18next";
13
+
14
+ import {
15
+ selectedElementsDeep,
16
+ type SelectableElement,
17
+ } from "./detail-page-selection";
18
+ import {
19
+ elementClientRect,
20
+ type ClientRect,
21
+ type RectElement,
22
+ } from "./element-rects";
23
+ import {
24
+ canInsertColumn,
25
+ canInsertRow,
26
+ canRemoveColumn,
27
+ canRemoveRow,
28
+ insertColumn,
29
+ insertRow,
30
+ moveColumn,
31
+ moveRow,
32
+ removeColumn,
33
+ removeRow,
34
+ resizeColumn,
35
+ } from "../../lib/detail-page/table/edit";
36
+ import { tableLayout } from "../../lib/detail-page/table/render";
37
+ import {
38
+ harvestTableGroup,
39
+ readTableSpec,
40
+ syncTableGroup,
41
+ type ElementLike,
42
+ type StoreLike,
43
+ } from "../../lib/detail-page/table/sync";
44
+ import type { TableSpec } from "../../lib/detail-page/table/types";
45
+
46
+ /**
47
+ * 표를 **캔버스에서** 만지는 층 — Canva·미리캔버스식 행·열 조작과 리사이즈 되먹임.
48
+ *
49
+ * 우측 인스펙터만으로도 다 되지만, 표를 보면서 그 자리에서 행을 끼우는 것과 패널의 목록을
50
+ * 내려가며 고치는 것은 다른 작업이다.
51
+ *
52
+ * 표 왼쪽·위에 레일을 깔고 행/열마다 삭제, 경계마다 삽입 버튼을 둔다. 좌표는
53
+ * ``tableLayout``에서 온다 — 렌더러가 쓰는 바로 그 배치라, 핸들이 행 경계에서 어긋날 수
54
+ * 없다. 행·열을 건드리기 전에는 캔버스에서 고친 글자를 먼저 걷는다(인덱스가 밀리면
55
+ * 되받기가 같은 칸을 못 알아본다).
56
+ *
57
+ * 리사이즈 되먹임은 여기 없다 — 차트도 똑같이 필요해서 ``SpecResizeAbsorber``가 맡는다.
58
+ *
59
+ * 좌표계 규약은 ``BubbleTailOverlay``와 같다: Konva 노드에서 잰 rect를 컨테이너 기준으로
60
+ * 옮기므로 줌·스크롤·스택 워크스페이스가 그대로 동작한다. 무한 루프 방지 규약도 같다 —
61
+ * 계산 결과를 요약한 키가 그대로면 상태를 아예 건드리지 않는다.
62
+ */
63
+
64
+ const RAIL = 18; // 레일 두께(px)
65
+ /** 레일 바깥에 ``+``가 앉는 여백. 레일과 한 hover 영역으로 묶인다. */
66
+ const GUTTER = 14;
67
+ const ACCENT = "#2F6FEB";
68
+
69
+ type SelectionStore = {
70
+ selectedElementsIds?: string[];
71
+ selectedElements?: SelectableElement[];
72
+ getElementById?: (id: string) => SelectableElement | undefined;
73
+ pages?: Array<{ children?: unknown }>;
74
+ scale?: number;
75
+ };
76
+
77
+ type OverlayStore = StoreLike & SelectionStore;
78
+
79
+ // ── 순수 계산 ────────────────────────────────────────────────────────────────
80
+
81
+ function childrenOf(node: { children?: unknown }): ElementLike[] {
82
+ return Array.isArray(node.children) ? (node.children as ElementLike[]) : [];
83
+ }
84
+
85
+ function idsUnder(el: ElementLike, into: Set<string>): Set<string> {
86
+ if (typeof el.id === "string") into.add(el.id);
87
+ for (const kid of childrenOf(el)) idsUnder(kid, into);
88
+ return into;
89
+ }
90
+
91
+ /**
92
+ * 선택이 어느 표 안에 있는가.
93
+ *
94
+ * 그룹 자체가 선택된 경우와, 드릴인해서 **칸 하나**가 선택된 경우를 모두 받는다. 칸을
95
+ * 고치는 중에 레일이 사라지면 "행 추가"를 누르러 매번 표를 다시 골라야 한다.
96
+ */
97
+ export function findTableGroup(
98
+ store: { pages?: Array<{ children?: unknown }> },
99
+ selectedIds: ReadonlyArray<string>,
100
+ ): ElementLike | null {
101
+ if (selectedIds.length === 0) return null;
102
+ const wanted = new Set(selectedIds);
103
+ for (const page of store.pages ?? []) {
104
+ for (const el of childrenOf(page)) {
105
+ if (el.type !== "group") continue;
106
+ if (!readTableSpec(el)) continue;
107
+ const inside = idsUnder(el, new Set<string>());
108
+ if (selectedIds.some((id) => inside.has(id)) || wanted.has(String(el.id))) {
109
+ return el;
110
+ }
111
+ }
112
+ }
113
+ return null;
114
+ }
115
+
116
+ /** 레일 위의 한 칸(행 하나 또는 열 하나). 컨테이너 기준 좌표. */
117
+ export type Track = { index: number; start: number; size: number };
118
+ /** 삽입 자리. ``index``는 "여기에 끼운다"는 위치다(행 수와 같으면 맨 뒤). */
119
+ export type Boundary = { index: number; at: number };
120
+
121
+ export type RailGeometry = {
122
+ /** 표 상자(컨테이너 기준). */
123
+ left: number;
124
+ top: number;
125
+ width: number;
126
+ height: number;
127
+ /**
128
+ * 화면 px ÷ 페이지 px. 드래그 거리를 스펙 단위로 되돌릴 때 쓴다 — 줌이 걸려 있으면
129
+ * 화면에서 10px 끈 게 스펙에서는 10px이 아니다.
130
+ */
131
+ sx: number;
132
+ sy: number;
133
+ rows: Track[];
134
+ rowBoundaries: Boundary[];
135
+ columns: Track[];
136
+ columnBoundaries: Boundary[];
137
+ };
138
+
139
+ /** 좌표가 어느 경계에 제일 가까운가. 순서 바꾸기의 드롭 자리를 정한다. */
140
+ export function nearestBoundary(boundaries: Boundary[], at: number): number {
141
+ let best = 0;
142
+ let distance = Infinity;
143
+ for (const boundary of boundaries) {
144
+ const gap = Math.abs(boundary.at - at);
145
+ if (gap < distance) {
146
+ distance = gap;
147
+ best = boundary.index;
148
+ }
149
+ }
150
+ return best;
151
+ }
152
+
153
+ /**
154
+ * 경계 번호를 배열 인덱스로 바꾼다.
155
+ *
156
+ * ``moveRow(from, to)``의 ``to``는 **빼낸 뒤**의 자리다. 자기 뒤쪽 경계에 떨어뜨리면
157
+ * 자기가 빠지면서 한 칸 당겨지므로 1을 뺀다. 이걸 빼먹으면 아래로 한 칸 옮기려 할 때
158
+ * 제자리에 남는다.
159
+ */
160
+ export function dropIndex(from: number, boundary: number): number {
161
+ return boundary > from ? boundary - 1 : boundary;
162
+ }
163
+
164
+ /**
165
+ * 스펙의 배치를 화면 좌표로 옮긴다.
166
+ *
167
+ * 배율은 상자 크기 ÷ 배치 크기로 잰다 — 사용자가 방금 끌어서 자식이 스케일된 상태여도
168
+ * (아직 흡수 전) 비율은 그대로라 핸들이 표를 따라간다.
169
+ */
170
+ export function railGeometry(
171
+ spec: TableSpec,
172
+ rect: ClientRect,
173
+ origin: { left: number; top: number },
174
+ ): RailGeometry {
175
+ const layout = tableLayout(spec);
176
+ const width = rect.right - rect.left;
177
+ const height = rect.bottom - rect.top;
178
+ const sx = layout.width > 0 ? width / layout.width : 1;
179
+ const sy = layout.height > 0 ? height / layout.height : 1;
180
+ const left = rect.left - origin.left;
181
+ const top = rect.top - origin.top;
182
+
183
+ const rows: Track[] = layout.rows.map((row, index) => ({
184
+ index,
185
+ start: top + row.y * sy,
186
+ size: row.height * sy,
187
+ }));
188
+ const rowBoundaries: Boundary[] = rows.map((row) => ({
189
+ index: row.index,
190
+ at: row.start,
191
+ }));
192
+ const last = rows[rows.length - 1];
193
+ if (last) rowBoundaries.push({ index: rows.length, at: last.start + last.size });
194
+
195
+ const columns: Track[] = layout.columns.map((column, index) => ({
196
+ index,
197
+ start: left + column.x * sx,
198
+ size: column.width * sx,
199
+ }));
200
+ const columnBoundaries: Boundary[] = columns.map((column) => ({
201
+ index: column.index,
202
+ at: column.start,
203
+ }));
204
+ const lastColumn = columns[columns.length - 1];
205
+ if (lastColumn) {
206
+ columnBoundaries.push({
207
+ index: columns.length,
208
+ at: lastColumn.start + lastColumn.size,
209
+ });
210
+ }
211
+
212
+ return {
213
+ left,
214
+ top,
215
+ width,
216
+ height,
217
+ sx,
218
+ sy,
219
+ rows,
220
+ rowBoundaries,
221
+ columns,
222
+ columnBoundaries,
223
+ };
224
+ }
225
+
226
+ // ── 컴포넌트 ─────────────────────────────────────────────────────────────────
227
+
228
+ type Op = (spec: TableSpec) => TableSpec;
229
+
230
+ /**
231
+ * 진행 중인 드래그.
232
+ *
233
+ * 끄는 동안에는 스펙을 안 고친다 — mousemove마다 표 전체를 다시 그리면 캔버스가 덜덜
234
+ * 떨고 undo가 수십 단계로 쪼개진다. 안내선만 그리고 손을 뗄 때 한 번 적용한다.
235
+ */
236
+ type Drag =
237
+ /** 열 경계를 좌우로: 인접 두 열이 폭을 주고받는다. */
238
+ | { mode: "width"; boundary: number; startX: number; deltaX: number }
239
+ /** 행·열 레일을 잡아 순서 바꾸기. ``target``은 떨어뜨릴 경계 번호. */
240
+ | { mode: "row"; from: number; target: number; startY: number; moved: boolean }
241
+ | { mode: "column"; from: number; target: number; startX: number; moved: boolean };
242
+
243
+ /** 이만큼 움직이기 전에는 순서 바꾸기가 아니라 그냥 누른 것으로 본다. */
244
+ const DRAG_SLOP = 4;
245
+
246
+ export const TableCanvasOverlay = observer(function TableCanvasOverlay({
247
+ store,
248
+ containerRef,
249
+ }: {
250
+ store: unknown;
251
+ containerRef: RefObject<HTMLElement | null>;
252
+ }) {
253
+ const { t } = useTranslation("branding");
254
+ const s = store as OverlayStore;
255
+ const [geometry, setGeometry] = useState<RailGeometry | null>(null);
256
+ const [hoverRow, setHoverRow] = useState<number | null>(null);
257
+ const [hoverColumn, setHoverColumn] = useState<number | null>(null);
258
+ const [rowsHot, setRowsHot] = useState(false);
259
+ const [columnsHot, setColumnsHot] = useState(false);
260
+ /**
261
+ * 드래그 상태는 **ref가 정본**이고, ``dragView``는 안내선을 그리기 위한 사본이다.
262
+ *
263
+ * ``pointermove``는 discrete 이벤트가 아니라 setState가 미뤄질 수 있다. 상태만 쓰면
264
+ * ``pointerup``이 낡은 값을 읽어 **끈 결과가 통째로 사라진다**(테스트에서 잡혔다).
265
+ */
266
+ const dragRef = useRef<Drag | null>(null);
267
+ const [dragView, setDragView] = useState<Drag | null>(null);
268
+ /** 컨테이너 좌상단(클라이언트 좌표) — 포인터를 기하와 같은 좌표계로 옮긴다. */
269
+ const hostOrigin = useRef({ left: 0, top: 0 });
270
+
271
+ const selected = selectedElementsDeep(s);
272
+ const group = findTableGroup(s, selected.map((e) => String(e.id)));
273
+ const spec = group ? readTableSpec(group) : null;
274
+
275
+ // mobx가 자식의 이동·크기 변화를 감지하도록 렌더 중에 읽는다. 글자는 안 읽는다 —
276
+ // 되받기는 재생성 직전(sync)과 패널이 읽을 때 일어나므로 여기서 반응할 필요가 없고,
277
+ // 타이핑마다 오버레이가 다시 그려지는 것도 피한다.
278
+ const stamp = group
279
+ ? childrenOf(group)
280
+ .map((k) => `${k.id}:${k.x},${k.y},${k.width},${k.height}`)
281
+ .join("|")
282
+ : "";
283
+ const scale = s.scale ?? 1;
284
+ const groupId = group ? String(group.id) : null;
285
+
286
+ // selectedElementsDeep은 매 렌더 새 배열을 만든다 — 의존성에 두면 이펙트가 매 렌더
287
+ // 돌고 setState가 다시 렌더를 부른다(무한 루프). 내용을 요약한 stamp로만 건다.
288
+ const groupRef = useRef<ElementLike | null>(group);
289
+ groupRef.current = group;
290
+ const specRef = useRef<TableSpec | null>(spec);
291
+ specRef.current = spec;
292
+ const keyRef = useRef<string | null>(null);
293
+
294
+ // ── 레일 좌표 ─────────────────────────────────────────────────────────────
295
+ useEffect(() => {
296
+ const host = containerRef.current;
297
+ const el = groupRef.current;
298
+ const current = specRef.current;
299
+ let next: RailGeometry | null = null;
300
+ if (host && el && current) {
301
+ const rect = elementClientRect(el as unknown as RectElement);
302
+ if (rect) {
303
+ const box = host.getBoundingClientRect();
304
+ hostOrigin.current = { left: box.left, top: box.top };
305
+ next = railGeometry(current, rect, { left: box.left, top: box.top });
306
+ }
307
+ }
308
+ const key = next
309
+ ? `${groupId}:${next.left.toFixed(1)},${next.top.toFixed(1)},${next.width.toFixed(1)},${next.height.toFixed(1)},${next.rows.length},${next.columns.length}`
310
+ : null;
311
+ if (key === keyRef.current) return;
312
+ keyRef.current = key;
313
+ setGeometry(next);
314
+ }, [stamp, scale, groupId, containerRef]);
315
+
316
+ /** 행·열을 건드리기 전에 캔버스 글자를 먼저 걷는다(인덱스가 밀리기 전에). */
317
+ const run = (op: Op) => {
318
+ const el = groupRef.current;
319
+ const current = specRef.current;
320
+ if (!el || !current) return;
321
+ const harvested = harvestTableGroup(el, current);
322
+ const next = op(harvested);
323
+ if (next === harvested) return;
324
+ syncTableGroup(s, el, next);
325
+ };
326
+
327
+ // ── 드래그 ────────────────────────────────────────────────────────────────
328
+ const geometryRef = useRef<RailGeometry | null>(geometry);
329
+ geometryRef.current = geometry;
330
+
331
+ const beginDrag = (event: ReactPointerEvent<HTMLElement>, next: Drag) => {
332
+ event.preventDefault();
333
+ // 캔버스로 내려가면 스톡 편집기가 표를 잡아 끌기 시작한다.
334
+ event.stopPropagation();
335
+ // 캡처를 못 잡아도(포인터 캡처가 없는 환경) 드래그 자체는 돌아야 한다.
336
+ const target = event.currentTarget;
337
+ if (typeof target.setPointerCapture === "function") {
338
+ target.setPointerCapture(event.pointerId);
339
+ }
340
+ dragRef.current = next;
341
+ setDragView(next);
342
+ };
343
+
344
+ const moveDrag = (event: ReactPointerEvent<HTMLElement>) => {
345
+ const box = geometryRef.current;
346
+ const current = dragRef.current;
347
+ if (!box || !current) return;
348
+ const x = event.clientX - hostOrigin.current.left;
349
+ const y = event.clientY - hostOrigin.current.top;
350
+
351
+ let next: Drag;
352
+ if (current.mode === "width") {
353
+ next = { ...current, deltaX: event.clientX - current.startX };
354
+ } else if (current.mode === "row") {
355
+ next = {
356
+ ...current,
357
+ moved: current.moved || Math.abs(event.clientY - current.startY) > DRAG_SLOP,
358
+ target: nearestBoundary(box.rowBoundaries, y),
359
+ };
360
+ } else {
361
+ next = {
362
+ ...current,
363
+ moved: current.moved || Math.abs(event.clientX - current.startX) > DRAG_SLOP,
364
+ target: nearestBoundary(box.columnBoundaries, x),
365
+ };
366
+ }
367
+ dragRef.current = next;
368
+ setDragView(next);
369
+ };
370
+
371
+ const endDrag = (event: ReactPointerEvent<HTMLElement>) => {
372
+ const current = dragRef.current;
373
+ dragRef.current = null;
374
+ setDragView(null);
375
+ if (!current) return;
376
+ event.stopPropagation();
377
+ const box = geometryRef.current;
378
+ if (current.mode === "width") {
379
+ // 화면에서 끈 거리를 페이지 단위로 되돌린다(줌이 걸려 있으면 다르다).
380
+ const sx = box?.sx || 1;
381
+ const delta = current.deltaX / sx;
382
+ if (Math.abs(delta) >= 1) {
383
+ run((table) => resizeColumn(table, current.boundary, delta));
384
+ }
385
+ return;
386
+ }
387
+ if (!current.moved) return; // 잡기만 하고 안 옮겼다
388
+ const to = dropIndex(current.from, current.target);
389
+ if (to === current.from) return;
390
+ run((table) =>
391
+ current.mode === "row"
392
+ ? moveRow(table, current.from, to)
393
+ : moveColumn(table, current.from, to),
394
+ );
395
+ };
396
+
397
+ const dragHandlers = {
398
+ onPointerMove: moveDrag,
399
+ onPointerUp: endDrag,
400
+ onPointerCancel: endDrag,
401
+ };
402
+
403
+ if (!geometry || !spec) return null;
404
+
405
+ const showColumns = spec.kind === "grid";
406
+ const canAddRow = canInsertRow(spec);
407
+ const canDropRow = canRemoveRow(spec);
408
+ const canAddColumn = canInsertColumn(spec);
409
+ const canDropColumn = canRemoveColumn(spec);
410
+
411
+ const chip: CSSProperties = {
412
+ position: "absolute",
413
+ display: "grid",
414
+ placeItems: "center",
415
+ borderRadius: 999,
416
+ background: "#fff",
417
+ border: `1px solid ${ACCENT}`,
418
+ color: ACCENT,
419
+ fontSize: 11,
420
+ lineHeight: 1,
421
+ fontWeight: 600,
422
+ pointerEvents: "auto",
423
+ cursor: "pointer",
424
+ padding: 0,
425
+ };
426
+
427
+ // 레일과 그 바깥의 ``+`` 줄을 **한 영역**으로 감싼다. 버튼마다 hover를 걸면 행 칸에서
428
+ // 버튼으로 넘어가는 순간 leave가 먼저 떠서 버튼이 사라지고, 누를 수가 없다.
429
+ const rowsRailLeft = Math.max(0, geometry.left - RAIL - GUTTER);
430
+ const columnsRailTop = Math.max(0, geometry.top - RAIL - GUTTER);
431
+ const railSkin = (hot: boolean): CSSProperties => ({
432
+ position: "absolute",
433
+ pointerEvents: "auto",
434
+ opacity: hot ? 1 : 0.35,
435
+ transition: "opacity 120ms",
436
+ });
437
+
438
+ return (
439
+ <div
440
+ data-dp-table-rail
441
+ style={{ position: "absolute", inset: 0, pointerEvents: "none", zIndex: 22 }}
442
+ >
443
+ {/* ── 행 레일(왼쪽) ─────────────────────────────────────────────────── */}
444
+ <div
445
+ onPointerEnter={() => setRowsHot(true)}
446
+ onPointerLeave={() => {
447
+ setRowsHot(false);
448
+ setHoverRow(null);
449
+ }}
450
+ style={{
451
+ ...railSkin(rowsHot),
452
+ left: rowsRailLeft,
453
+ top: geometry.top,
454
+ width: geometry.left - rowsRailLeft,
455
+ height: geometry.height,
456
+ }}
457
+ >
458
+ {geometry.rows.map((row) => (
459
+ <div
460
+ key={`row-${row.index}`}
461
+ data-dp-row-grip={row.index}
462
+ title={t("detailPage.table.rail.moveRow")}
463
+ onPointerEnter={() => setHoverRow(row.index)}
464
+ onPointerDown={(e) =>
465
+ beginDrag(e, {
466
+ mode: "row",
467
+ from: row.index,
468
+ target: row.index,
469
+ startY: e.clientY,
470
+ moved: false,
471
+ })
472
+ }
473
+ {...dragHandlers}
474
+ style={{
475
+ position: "absolute",
476
+ left: GUTTER,
477
+ top: row.start - geometry.top,
478
+ width: RAIL,
479
+ height: row.size,
480
+ borderRadius: 3,
481
+ cursor: "grab",
482
+ touchAction: "none",
483
+ background:
484
+ hoverRow === row.index ? "rgba(47,111,235,0.14)" : "rgba(47,111,235,0.05)",
485
+ }}
486
+ >
487
+ {canDropRow && hoverRow === row.index ? (
488
+ <button
489
+ type="button"
490
+ data-dp-row-remove={row.index}
491
+ aria-label={t("detailPage.table.rail.removeRow", { row: row.index + 1 })}
492
+ title={t("detailPage.table.rail.removeRow", { row: row.index + 1 })}
493
+ onClick={() => run((current) => removeRow(current, row.index))}
494
+ style={{ ...chip, left: (RAIL - 16) / 2, top: row.size / 2 - 8, width: 16, height: 16 }}
495
+ >
496
+ ×
497
+ </button>
498
+ ) : null}
499
+ </div>
500
+ ))}
501
+
502
+ {/* 경계마다 "여기에 행 추가". 마지막 경계는 표 맨 아래다. */}
503
+ {canAddRow
504
+ ? geometry.rowBoundaries.map((boundary) => (
505
+ <button
506
+ key={`row-add-${boundary.index}`}
507
+ type="button"
508
+ data-dp-row-insert={boundary.index}
509
+ aria-label={t("detailPage.table.rail.insertRow", {
510
+ at: boundary.index + 1,
511
+ })}
512
+ title={t("detailPage.table.rail.insertRow", { at: boundary.index + 1 })}
513
+ onClick={() => run((current) => insertRow(current, boundary.index))}
514
+ style={{
515
+ ...chip,
516
+ left: 0,
517
+ top: boundary.at - geometry.top - 7,
518
+ width: 14,
519
+ height: 14,
520
+ }}
521
+ >
522
+ +
523
+ </button>
524
+ ))
525
+ : null}
526
+ </div>
527
+
528
+ {/* ── 열 경계 끌기(표 위) ───────────────────────────────────────────
529
+ 엑셀·Canva처럼 열 구분선 자체를 잡아 끈다. 인접 두 열이 폭을 주고받으므로
530
+ 표 전체 폭은 안 변한다. 첫 경계(0)와 마지막은 표 바깥이라 뺀다. */}
531
+ {geometry.columns.length > 1
532
+ ? geometry.columnBoundaries
533
+ .filter((b) => b.index > 0 && b.index < geometry.columns.length)
534
+ .map((boundary) => (
535
+ <div
536
+ key={`col-size-${boundary.index}`}
537
+ data-dp-col-resize={boundary.index}
538
+ title={t("detailPage.table.rail.resizeColumn")}
539
+ onPointerDown={(e) =>
540
+ beginDrag(e, {
541
+ mode: "width",
542
+ boundary: boundary.index,
543
+ startX: e.clientX,
544
+ deltaX: 0,
545
+ })
546
+ }
547
+ {...dragHandlers}
548
+ style={{
549
+ position: "absolute",
550
+ left: boundary.at - 3,
551
+ top: geometry.top,
552
+ width: 6,
553
+ height: geometry.height,
554
+ cursor: "col-resize",
555
+ touchAction: "none",
556
+ pointerEvents: "auto",
557
+ }}
558
+ />
559
+ ))
560
+ : null}
561
+
562
+ {/* 끄는 동안의 안내선 — 스펙은 손을 뗄 때 한 번만 고친다. */}
563
+ {dragView?.mode === "width" ? (
564
+ <div
565
+ data-dp-drag-guide="width"
566
+ style={{
567
+ position: "absolute",
568
+ left:
569
+ (geometry.columnBoundaries.find((b) => b.index === dragView.boundary)?.at ??
570
+ 0) + dragView.deltaX,
571
+ top: geometry.top,
572
+ width: 2,
573
+ height: geometry.height,
574
+ background: ACCENT,
575
+ pointerEvents: "none",
576
+ }}
577
+ />
578
+ ) : null}
579
+ {dragView?.mode === "row" && dragView.moved ? (
580
+ <div
581
+ data-dp-drag-guide="row"
582
+ style={{
583
+ position: "absolute",
584
+ left: geometry.left,
585
+ top:
586
+ (geometry.rowBoundaries.find((b) => b.index === dragView.target)?.at ?? 0) - 1,
587
+ width: geometry.width,
588
+ height: 2,
589
+ background: ACCENT,
590
+ pointerEvents: "none",
591
+ }}
592
+ />
593
+ ) : null}
594
+ {dragView?.mode === "column" && dragView.moved ? (
595
+ <div
596
+ data-dp-drag-guide="column"
597
+ style={{
598
+ position: "absolute",
599
+ left:
600
+ (geometry.columnBoundaries.find((b) => b.index === dragView.target)?.at ?? 0) -
601
+ 1,
602
+ top: geometry.top,
603
+ width: 2,
604
+ height: geometry.height,
605
+ background: ACCENT,
606
+ pointerEvents: "none",
607
+ }}
608
+ />
609
+ ) : null}
610
+
611
+ {/* ── 열 레일(위) ──────────────────────────────────────────────────── */}
612
+ {showColumns ? (
613
+ <div
614
+ onPointerEnter={() => setColumnsHot(true)}
615
+ onPointerLeave={() => {
616
+ setColumnsHot(false);
617
+ setHoverColumn(null);
618
+ }}
619
+ style={{
620
+ ...railSkin(columnsHot),
621
+ left: geometry.left,
622
+ top: columnsRailTop,
623
+ width: geometry.width,
624
+ height: geometry.top - columnsRailTop,
625
+ }}
626
+ >
627
+ {geometry.columns.map((column) => (
628
+ <div
629
+ key={`col-${column.index}`}
630
+ data-dp-col-grip={column.index}
631
+ title={t("detailPage.table.rail.moveColumn")}
632
+ onPointerEnter={() => setHoverColumn(column.index)}
633
+ onPointerDown={(e) =>
634
+ beginDrag(e, {
635
+ mode: "column",
636
+ from: column.index,
637
+ target: column.index,
638
+ startX: e.clientX,
639
+ moved: false,
640
+ })
641
+ }
642
+ {...dragHandlers}
643
+ style={{
644
+ position: "absolute",
645
+ left: column.start - geometry.left,
646
+ top: GUTTER,
647
+ width: column.size,
648
+ height: RAIL,
649
+ borderRadius: 3,
650
+ cursor: "grab",
651
+ touchAction: "none",
652
+ background:
653
+ hoverColumn === column.index
654
+ ? "rgba(47,111,235,0.14)"
655
+ : "rgba(47,111,235,0.05)",
656
+ }}
657
+ >
658
+ {canDropColumn && hoverColumn === column.index ? (
659
+ <button
660
+ type="button"
661
+ data-dp-col-remove={column.index}
662
+ aria-label={t("detailPage.table.rail.removeColumn", {
663
+ column: column.index + 1,
664
+ })}
665
+ title={t("detailPage.table.rail.removeColumn", {
666
+ column: column.index + 1,
667
+ })}
668
+ onClick={() => run((current) => removeColumn(current, column.index))}
669
+ style={{ ...chip, left: column.size / 2 - 8, top: (RAIL - 16) / 2, width: 16, height: 16 }}
670
+ >
671
+ ×
672
+ </button>
673
+ ) : null}
674
+ </div>
675
+ ))}
676
+
677
+ {canAddColumn
678
+ ? geometry.columnBoundaries.map((boundary) => (
679
+ <button
680
+ key={`col-add-${boundary.index}`}
681
+ type="button"
682
+ data-dp-col-insert={boundary.index}
683
+ aria-label={t("detailPage.table.rail.insertColumn", {
684
+ at: boundary.index + 1,
685
+ })}
686
+ title={t("detailPage.table.rail.insertColumn", {
687
+ at: boundary.index + 1,
688
+ })}
689
+ onClick={() => run((current) => insertColumn(current, boundary.index))}
690
+ style={{
691
+ ...chip,
692
+ left: boundary.at - geometry.left - 7,
693
+ top: 0,
694
+ width: 14,
695
+ height: 14,
696
+ }}
697
+ >
698
+ +
699
+ </button>
700
+ ))
701
+ : null}
702
+ </div>
703
+ ) : null}
704
+ </div>
705
+ );
706
+ });
707
+ TableCanvasOverlay.displayName = "TableCanvasOverlay";