@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,618 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, type ComponentType } from "react";
4
+ import { observer } from "./canvas-observer";
5
+ import { useTranslation } from "react-i18next";
6
+ import { selectedElementsDeep } from "./detail-page-selection";
7
+ import { setHoveredLayerId } from "./hovered-layer";
8
+ import {
9
+ ChevronDown,
10
+ ChevronRight,
11
+ Eye,
12
+ EyeOff,
13
+ Film,
14
+ Folder,
15
+ Image as ImageIcon,
16
+ Lock,
17
+ Minus,
18
+ Shapes,
19
+ Square,
20
+ Type as TypeIcon,
21
+ Trash2,
22
+ Unlock,
23
+ } from "lucide-react";
24
+ import { isGifSrc } from "../../lib/detail-page-canvas/export/gif-plan";
25
+ import { dropSpot, moveLayer, type DropZone } from "../../lib/detail-page/layer-move";
26
+
27
+ /**
28
+ * Figma-style layers tree for the detail-page Canvas editor.
29
+ *
30
+ * Replaces the stock editor's stock ``LayersSection`` panel, which renders a *flat* list
31
+ * of ``activePage.children`` — so a page built out of nested groups reads as an
32
+ * undifferentiated stack with no visible parent/child hierarchy (the user's
33
+ * complaint). This panel walks groups recursively, indents by depth, and lets
34
+ * each group collapse, mirroring Figma's layer tree. Front-most element sits at
35
+ * the top (children array is drawn back-to-front, so we render it reversed).
36
+ *
37
+ * Reordering lives in the right-hand properties inspector (정렬 순서 section) via
38
+ * ``parent.setElementZIndex`` — the one reorder API both Page and Group expose;
39
+ * ``element.moveUp()`` is unreliable for grouped elements because it targets the
40
+ * page, not the immediate group.
41
+ *
42
+ * Rows also drag: drop on the upper/lower edge of a row to reorder, or on the
43
+ * middle of a group row to move *into* that group. The move itself lives in
44
+ * ``@/lib/detail-page/layer-move`` (Canvas has no reparent API — see there).
45
+ * Backspace/Delete removes the focused row, matching the canvas.
46
+ */
47
+
48
+ type ElementLike = {
49
+ id: string;
50
+ type: string;
51
+ name?: string;
52
+ text?: string;
53
+ src?: string;
54
+ custom?: Record<string, unknown> | null;
55
+ /** 문서에 **없는 것이 정상**이다 — 숨긴 적이 있는 요소만 이 필드를 갖는다. */
56
+ visible?: boolean;
57
+ locked?: boolean;
58
+ /**
59
+ * 셋 다 **문서에 없는 것이 정상**이다 — 분해기가 플래그를 박아 둔 요소만 갖는다.
60
+ * 그래서 `!el.selectable` 같은 참/거짓 판정을 쓰면 안 된다(아래 `=== false` 참고).
61
+ */
62
+ removable?: boolean;
63
+ selectable?: boolean;
64
+ children?: ElementLike[];
65
+ set: (props: Record<string, unknown>) => void;
66
+ };
67
+
68
+ type StoreLike = {
69
+ activePage?: { children?: ElementLike[] };
70
+ selectedElements?: ElementLike[];
71
+ selectedElementsIds?: string[];
72
+ getElementById?: (id: string) => ElementLike | undefined;
73
+ selectElements: (ids: string[]) => void;
74
+ deleteElements: (ids: string[]) => void;
75
+ };
76
+
77
+ /** 행 위 커서 위치 → 놓을 자리. 그룹 행 가운데는 "그 안으로". */
78
+ export function zoneAt(ratio: number, isGroup: boolean): DropZone {
79
+ if (isGroup && ratio > 0.3 && ratio < 0.7) return "inside";
80
+ return ratio < 0.5 ? "before" : "after";
81
+ }
82
+
83
+ type DragState = {
84
+ id: string | null;
85
+ over: { id: string; zone: DropZone } | null;
86
+ start: (id: string) => void;
87
+ hover: (id: string, zone: DropZone) => void;
88
+ drop: (id: string, zone: DropZone) => void;
89
+ end: () => void;
90
+ };
91
+
92
+ type IconType = ComponentType<{ size?: number; className?: string }>;
93
+
94
+ type TFn = (key: string) => string;
95
+
96
+ /** GIF로 삽입된 요소인지(레이어 아이콘·라벨을 이미지 대신 GIF로 쓴다). */
97
+ function isGifLayer(el: ElementLike): boolean {
98
+ if (el.custom && (el.custom as { detailPageGif?: unknown }).detailPageGif) return true;
99
+ return (el.type === "image" || el.type === "svg") && isGifSrc(el.src);
100
+ }
101
+
102
+ function layerMeta(type: string, t: TFn): { Icon: IconType; label: string } {
103
+ switch (type) {
104
+ case "group":
105
+ return { Icon: Folder, label: t("detailPage.layers.group") };
106
+ case "text":
107
+ return { Icon: TypeIcon, label: t("detailPage.layers.text") };
108
+ case "image":
109
+ return { Icon: ImageIcon, label: t("detailPage.layers.image") };
110
+ case "svg":
111
+ return { Icon: Shapes, label: t("detailPage.layers.shape") };
112
+ case "figure":
113
+ return { Icon: Square, label: t("detailPage.layers.shape") };
114
+ case "line":
115
+ return { Icon: Minus, label: t("detailPage.layers.line") };
116
+ default:
117
+ return { Icon: Square, label: type };
118
+ }
119
+ }
120
+
121
+ // 텍스트 레이어는 내용을, 나머지는 이름을 라벨로. 편집기에서 요소를 짚어내기 쉽게.
122
+ function displayName(el: ElementLike, t: TFn): string {
123
+ if (el.type === "text") {
124
+ const raw = (el.text ?? "").replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
125
+ if (raw) return raw;
126
+ }
127
+ const name = (el.name ?? "").trim();
128
+ if (name) return name;
129
+ if (isGifLayer(el)) return "GIF";
130
+ return layerMeta(el.type, t).label;
131
+ }
132
+
133
+ // 잠금 토글: 스톡 편집기에는 단일 locked 세터가 없다. 스톡 레이어 패널과 동일하게
134
+ // 네 편집 플래그를 현재 locked 값으로 되돌린다(locked=true면 모두 true→해제).
135
+ function toggleLock(el: ElementLike) {
136
+ el.set({
137
+ draggable: el.locked,
138
+ contentEditable: el.locked,
139
+ styleEditable: el.locked,
140
+ resizable: el.locked,
141
+ removable: el.locked,
142
+ });
143
+ }
144
+
145
+ function IconButton({
146
+ title,
147
+ onClick,
148
+ disabled,
149
+ active,
150
+ children,
151
+ }: {
152
+ title: string;
153
+ onClick: () => void;
154
+ disabled?: boolean;
155
+ active?: boolean;
156
+ children: React.ReactNode;
157
+ }) {
158
+ return (
159
+ <button
160
+ type="button"
161
+ title={title}
162
+ aria-label={title}
163
+ disabled={disabled}
164
+ onClick={(e) => {
165
+ e.stopPropagation();
166
+ onClick();
167
+ }}
168
+ className={[
169
+ "flex h-6 w-6 items-center justify-center rounded transition-colors",
170
+ active ? "text-neutral-800" : "text-neutral-400",
171
+ disabled
172
+ ? "cursor-not-allowed opacity-30"
173
+ : "hover:bg-neutral-200/70 hover:text-neutral-800",
174
+ ].join(" ")}
175
+ >
176
+ {children}
177
+ </button>
178
+ );
179
+ }
180
+
181
+ /**
182
+ * The rows to tint as "the group you are working inside".
183
+ *
184
+ * With a group selected — or one shape inside it — the tree gives no clue which of
185
+ * the surrounding rows belong to that group; a decomposed chart is a dozen sibling
186
+ * rows all reading "도형". So the whole subtree of the ACTIVE group is washed in a
187
+ * pale sky, and the actually-selected row keeps the solid blue: at a glance you see
188
+ * both what you picked and what it lives in.
189
+ *
190
+ * The active group is the selected element itself when it IS a group, otherwise the
191
+ * group that contains it.
192
+ */
193
+ type TreeNode = { id: string; type: string; children?: readonly TreeNode[] };
194
+
195
+ export function groupContextIds(
196
+ children: ReadonlyArray<TreeNode>,
197
+ selectedIds: ReadonlyArray<string>,
198
+ ): Set<string> {
199
+ const out = new Set<string>();
200
+ if (!selectedIds.length) return out;
201
+ const wanted = new Set(selectedIds);
202
+
203
+ const subtree = (el: TreeNode) => {
204
+ out.add(el.id);
205
+ for (const c of el.children ?? []) subtree(c);
206
+ };
207
+
208
+ // `parent` is the nearest enclosing group, or null at the top level.
209
+ const walk = (els: ReadonlyArray<TreeNode>, parent: TreeNode | null) => {
210
+ for (const el of els) {
211
+ if (wanted.has(el.id)) {
212
+ const active = el.type === "group" && el.children ? el : parent;
213
+ if (active) subtree(active);
214
+ }
215
+ if (el.children?.length) walk(el.children, el);
216
+ }
217
+ };
218
+ walk(children, null);
219
+ return out;
220
+ }
221
+
222
+ /**
223
+ * Groups to force open so the current selection is actually visible: every ancestor
224
+ * group on the path down to a selected element, plus a selected group itself.
225
+ *
226
+ * Groups start collapsed; selecting a group (in the tree or on the canvas) opens it.
227
+ * Callers only ever ADD these ids to the open set — never remove — so the expansion
228
+ * is *sticky*: picking a different group later can't collapse one you already opened
229
+ * (no accordion). A nested selection opens the whole chain so the picked row shows.
230
+ */
231
+ export function selectionExpandIds(
232
+ children: ReadonlyArray<TreeNode>,
233
+ selectedIds: ReadonlyArray<string>,
234
+ ): Set<string> {
235
+ const out = new Set<string>();
236
+ if (!selectedIds.length) return out;
237
+ const wanted = new Set(selectedIds);
238
+
239
+ const walk = (els: ReadonlyArray<TreeNode>, ancestors: readonly string[]) => {
240
+ for (const el of els) {
241
+ const isGroup = el.type === "group" && !!el.children;
242
+ if (wanted.has(el.id)) {
243
+ for (const a of ancestors) out.add(a);
244
+ if (isGroup) out.add(el.id);
245
+ }
246
+ if (el.children?.length) {
247
+ walk(el.children, isGroup ? [...ancestors, el.id] : ancestors);
248
+ }
249
+ }
250
+ };
251
+ walk(children, []);
252
+ return out;
253
+ }
254
+
255
+ /**
256
+ * 화면에 실제로 보이는 줄들을, 보이는 순서대로.
257
+ *
258
+ * 예전에는 행이 자기 자식을 직접 그렸다. 그러면 "3번째와 10번째 사이"를 물어볼 자리가
259
+ * 없다 — 트리 어디에도 **평평한 순서**가 없기 때문이다. 범위 선택(⇧클릭)을 하려면
260
+ * 그 순서가 먼저 있어야 한다.
261
+ *
262
+ * 규칙은 그리던 것 그대로다. 앞(위)에 그려지는 것이 목록 위로 오고(역순), 접힌 그룹의
263
+ * 자식은 안 세고, 선택 불가로 **못 박은** 요소만 뺀다(`=== false` — 필드가 없는 것이
264
+ * 정상이다).
265
+ */
266
+ export function flattenLayers<
267
+ T extends { id: string; type: string; selectable?: boolean; children?: T[] },
268
+ >(children: readonly T[], expanded: ReadonlySet<string>): Array<{ el: T; depth: number }> {
269
+ const out: Array<{ el: T; depth: number }> = [];
270
+ const walk = (list: readonly T[], depth: number) => {
271
+ for (let i = list.length - 1; i >= 0; i -= 1) {
272
+ const el = list[i];
273
+ if (el.selectable === false) continue;
274
+ out.push({ el, depth });
275
+ const isGroup = el.type === "group" && Array.isArray(el.children);
276
+ if (isGroup && expanded.has(el.id)) walk(el.children ?? [], depth + 1);
277
+ }
278
+ };
279
+ walk(children, 0);
280
+ return out;
281
+ }
282
+
283
+ /**
284
+ * 두 줄 사이(양끝 포함)의 id들. 피그마의 ⇧클릭이 그렇다 — 3번째를 누르고 10번째를
285
+ * ⇧클릭하면 3~10이 통째로 잡힌다.
286
+ *
287
+ * 기준점이 목록에서 사라졌으면(그룹을 접었다든가) 누른 줄 하나만 준다.
288
+ */
289
+ export function rangeIds(
290
+ rows: ReadonlyArray<{ el: { id: string } }>,
291
+ anchorId: string | null,
292
+ targetId: string,
293
+ ): string[] {
294
+ const to = rows.findIndex((row) => row.el.id === targetId);
295
+ if (to < 0) return [targetId];
296
+ const from = anchorId ? rows.findIndex((row) => row.el.id === anchorId) : -1;
297
+ if (from < 0) return [targetId];
298
+ const [lo, hi] = from <= to ? [from, to] : [to, from];
299
+ return rows.slice(lo, hi + 1).map((row) => row.el.id);
300
+ }
301
+
302
+ /** 누른 줄 하나를 선택에서 넣거나 뺀다(⌘/Ctrl 클릭). */
303
+ export function toggleId(selected: readonly string[], id: string): string[] {
304
+ return selected.includes(id)
305
+ ? selected.filter((one) => one !== id)
306
+ : [...selected, id];
307
+ }
308
+
309
+ const LayerRow = observer(function LayerRow({
310
+ el,
311
+ depth,
312
+ store,
313
+ expanded,
314
+ onToggle,
315
+ onSelect,
316
+ context,
317
+ drag,
318
+ }: {
319
+ el: ElementLike;
320
+ depth: number;
321
+ store: StoreLike;
322
+ expanded: Set<string>;
323
+ onToggle: (id: string) => void;
324
+ onSelect: (id: string, mods: { shift: boolean; meta: boolean }) => void;
325
+ context: Set<string>;
326
+ drag: DragState;
327
+ }) {
328
+ const { t } = useTranslation("branding");
329
+
330
+ const isGroup = el.type === "group" && Array.isArray(el.children);
331
+ // Groups start collapsed; only an explicit toggle or a selection opens them.
332
+ const isCollapsed = !expanded.has(el.id);
333
+ // Resolve through selectedElementsIds so a GROUP CHILD reads as selected —
334
+ // the stock editor's selectedElements getter only sees top-level page children.
335
+ const selected = selectedElementsDeep(store).some((e) => e.id === el.id);
336
+ // GIF는 이미지 아이콘 대신 필름 아이콘으로 구분한다.
337
+ const { Icon } = isGifLayer(el) ? { Icon: Film } : layerMeta(el.type, t);
338
+ const label = displayName(el, t);
339
+ // `!el.visible`이 아니다. 분해기가 만든 문서에는 `visible` 필드가 아예 없어서
340
+ // undefined가 오고, 그러면 **모든 레이어가 숨김으로 읽힌다**(취소선 + 눈 감은 아이콘).
341
+ // 숨김의 정의는 렌더러가 갖고 있다 — `element-view.tsx`도 `=== false`에서만 안 그린다.
342
+ const hidden = el.visible === false;
343
+ const dragging = drag.id === el.id;
344
+ const over = drag.over?.id === el.id && drag.id && drag.id !== el.id ? drag.over.zone : null;
345
+
346
+ return (
347
+ <>
348
+ <div
349
+ role="button"
350
+ tabIndex={0}
351
+ draggable={!el.locked}
352
+ onDragStart={(e) => {
353
+ drag.start(el.id);
354
+ if (!e.dataTransfer) return;
355
+ e.dataTransfer.effectAllowed = "move";
356
+ // 파이어폭스는 데이터가 없으면 드래그를 시작하지 않는다.
357
+ e.dataTransfer.setData("text/plain", el.id);
358
+ }}
359
+ onDragOver={(e) => {
360
+ if (!drag.id || drag.id === el.id) return;
361
+ const rect = e.currentTarget.getBoundingClientRect();
362
+ const ratio = rect.height > 0 ? (e.clientY - rect.top) / rect.height : 0;
363
+ e.preventDefault();
364
+ // 놓을 자리부터 정한다. dataTransfer는 환경에 따라 없을 수 있어 나중에 만진다.
365
+ drag.hover(el.id, zoneAt(ratio, isGroup));
366
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
367
+ }}
368
+ onDrop={(e) => {
369
+ e.preventDefault();
370
+ e.stopPropagation();
371
+ const zone = drag.over?.id === el.id ? drag.over.zone : "after";
372
+ drag.drop(el.id, zone);
373
+ }}
374
+ onDragEnd={() => drag.end()}
375
+ onClick={(e) =>
376
+ onSelect(el.id, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey })
377
+ }
378
+ // A dozen rows all read "도형" — light the layer up on the canvas so the
379
+ // user can see which one this is without selecting (and disturbing) it.
380
+ onMouseEnter={() => setHoveredLayerId(el.id)}
381
+ onMouseLeave={() => setHoveredLayerId(null)}
382
+ onKeyDown={(e) => {
383
+ if (e.key === "Enter" || e.key === " ") {
384
+ e.preventDefault();
385
+ onSelect(el.id, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey });
386
+ return;
387
+ }
388
+ // 캔버스에서 되는 삭제가 레이어 패널에서도 되게 한다. Backspace는 막지
389
+ // 않으면 브라우저 뒤로가기로 새기도 한다.
390
+ if (e.key === "Delete" || e.key === "Backspace") {
391
+ e.preventDefault();
392
+ if (el.removable === false) return;
393
+ store.deleteElements([el.id]);
394
+ }
395
+ }}
396
+ className={[
397
+ "group relative flex items-center gap-1 rounded-md pr-1.5 text-neutral-700",
398
+ "min-h-[30px] cursor-default select-none",
399
+ dragging ? "opacity-40" : "",
400
+ over === "inside" ? "ring-2 ring-inset ring-sky-400" : "",
401
+ selected
402
+ ? "bg-sky-200 text-sky-950"
403
+ : context.has(el.id)
404
+ ? "bg-sky-50 text-sky-900 hover:bg-sky-100"
405
+ : "hover:bg-neutral-100",
406
+ ].join(" ")}
407
+ style={{ paddingLeft: 6 + depth * 14 }}
408
+ >
409
+ {over === "before" || over === "after" ? (
410
+ <span
411
+ aria-hidden="true"
412
+ data-testid={`drop-line-${over}`}
413
+ className={[
414
+ "pointer-events-none absolute inset-x-1 h-0.5 rounded-full bg-sky-500",
415
+ over === "before" ? "top-0" : "bottom-0",
416
+ ].join(" ")}
417
+ />
418
+ ) : null}
419
+
420
+ {isGroup ? (
421
+ <button
422
+ type="button"
423
+ title={isCollapsed ? t("detailPage.layers.expand") : t("detailPage.layers.collapse")}
424
+ aria-label={isCollapsed ? t("detailPage.layers.expand") : t("detailPage.layers.collapse")}
425
+ onClick={(e) => {
426
+ e.stopPropagation();
427
+ onToggle(el.id);
428
+ }}
429
+ className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-neutral-400 hover:bg-neutral-200/70 hover:text-neutral-700"
430
+ >
431
+ {isCollapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
432
+ </button>
433
+ ) : (
434
+ <span className="h-5 w-5 shrink-0" aria-hidden="true" />
435
+ )}
436
+
437
+ <Icon
438
+ size={14}
439
+ className={["shrink-0", selected ? "text-sky-500" : "text-neutral-400"].join(" ")}
440
+ />
441
+
442
+ <span
443
+ className={[
444
+ "flex-1 truncate text-[13px]",
445
+ hidden ? "text-neutral-300 line-through" : "",
446
+ ].join(" ")}
447
+ title={label}
448
+ >
449
+ {label}
450
+ </span>
451
+
452
+ <div
453
+ className={[
454
+ "flex shrink-0 items-center gap-0.5 transition-opacity",
455
+ hidden || selected
456
+ ? "opacity-100"
457
+ : "opacity-0 focus-within:opacity-100 group-hover:opacity-100",
458
+ ].join(" ")}
459
+ >
460
+ <IconButton
461
+ title={hidden ? t("detailPage.layers.show") : t("detailPage.layers.hide")}
462
+ active={hidden}
463
+ onClick={() => el.set({ visible: hidden })}
464
+ >
465
+ {hidden ? <EyeOff size={14} /> : <Eye size={14} />}
466
+ </IconButton>
467
+ <IconButton
468
+ title={el.locked ? t("detailPage.layers.unlock") : t("detailPage.layers.lock")}
469
+ active={el.locked}
470
+ onClick={() => toggleLock(el)}
471
+ >
472
+ {el.locked ? <Lock size={14} /> : <Unlock size={14} />}
473
+ </IconButton>
474
+ <IconButton
475
+ title={t("detailPage.layers.delete")}
476
+ disabled={el.removable === false}
477
+ onClick={() => store.deleteElements([el.id])}
478
+ >
479
+ <Trash2 size={14} />
480
+ </IconButton>
481
+ </div>
482
+ </div>
483
+
484
+ </>
485
+ );
486
+ });
487
+
488
+ export const DetailPageLayersPanel = observer(function DetailPageLayersPanel({
489
+ store,
490
+ }: {
491
+ store: unknown;
492
+ }) {
493
+ const { t } = useTranslation("branding");
494
+ const s = store as StoreLike;
495
+ const [expanded, setExpanded] = useState<Set<string>>(new Set());
496
+ const toggle = (id: string) =>
497
+ setExpanded((prev) => {
498
+ const next = new Set(prev);
499
+ if (next.has(id)) next.delete(id);
500
+ else next.add(id);
501
+ return next;
502
+ });
503
+
504
+ const [dragId, setDragId] = useState<string | null>(null);
505
+ const [over, setOver] = useState<{ id: string; zone: DropZone } | null>(null);
506
+
507
+ const children = s.activePage?.children ?? [];
508
+ // 화면 최상단(앞) 요소가 목록 맨 위로. 접힌 그룹의 자식은 안 들어온다.
509
+ const rows = flattenLayers(children, expanded);
510
+ /** ⇧클릭이 "여기서부터"로 삼을 줄. 평범한 클릭이 여기를 옮긴다. */
511
+ const [anchorId, setAnchorId] = useState<string | null>(null);
512
+
513
+ const drag: DragState = {
514
+ id: dragId,
515
+ over,
516
+ start: (id) => {
517
+ setDragId(id);
518
+ setOver(null);
519
+ },
520
+ hover: (id, zone) =>
521
+ setOver((prev) => (prev?.id === id && prev.zone === zone ? prev : { id, zone })),
522
+ drop: (id, zone) => {
523
+ const page = s.activePage;
524
+ const spot = dragId && page ? dropSpot(page, id, zone, dragId) : null;
525
+ if (spot && dragId) moveLayer(s, dragId, spot);
526
+ setDragId(null);
527
+ setOver(null);
528
+ },
529
+ end: () => {
530
+ setDragId(null);
531
+ setOver(null);
532
+ },
533
+ };
534
+ // Selection resolved deep (a group CHILD is invisible to the stock editor's own getter).
535
+ const selectedIds = selectedElementsDeep(s).map((e) => e.id);
536
+ const context = groupContextIds(children, selectedIds);
537
+
538
+ /**
539
+ * 줄 하나를 눌렀을 때. 캔버스와 손이 같아야 한다 — 거기서 ⇧클릭으로 여럿을 잡을 수
540
+ * 있는데 목록에서만 한 개씩이면 같은 문서를 두 가지 규칙으로 만지는 셈이 된다.
541
+ *
542
+ * - 그냥 클릭: 그 줄만. 여기가 다음 범위의 기준점이 된다.
543
+ * - ⇧클릭: 기준점부터 여기까지 통째로(피그마와 같다).
544
+ * - ⌘/Ctrl 클릭: 그 줄만 넣거나 뺀다.
545
+ */
546
+ const onSelect = (id: string, mods: { shift: boolean; meta: boolean }) => {
547
+ if (mods.shift) {
548
+ s.selectElements(rangeIds(rows, anchorId, id));
549
+ // 기준점은 그대로 둔다 — 범위를 잡았다 놨다 하며 늘릴 수 있어야 한다.
550
+ return;
551
+ }
552
+ if (mods.meta) {
553
+ s.selectElements(toggleId(selectedIds, id));
554
+ setAnchorId(id);
555
+ return;
556
+ }
557
+ s.selectElements([id]);
558
+ setAnchorId(id);
559
+ };
560
+
561
+ // Selecting a group (in the tree or on the canvas) opens it, and opening is
562
+ // sticky — we only ADD the path-to-selection to the open set, never remove — so
563
+ // picking another group can't collapse one you already opened.
564
+ const selectionKey = selectedIds.join(",");
565
+ useEffect(() => {
566
+ const want = selectionExpandIds(children, selectedIds);
567
+ if (!want.size) return;
568
+ setExpanded((prev) => {
569
+ let changed = false;
570
+ const next = new Set(prev);
571
+ for (const id of want) {
572
+ if (!next.has(id)) {
573
+ next.add(id);
574
+ changed = true;
575
+ }
576
+ }
577
+ return changed ? next : prev;
578
+ });
579
+ // `children`/`selectedIds` are fresh every render; the selection string is the
580
+ // real trigger, so key the effect on it alone.
581
+ // eslint-disable-next-line react-hooks/exhaustive-deps
582
+ }, [selectionKey]);
583
+
584
+ return (
585
+ <div className="flex h-full min-h-0 flex-col">
586
+ <div className="flex items-center justify-between px-3 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-neutral-400">
587
+ <span>{t("detailPage.layers.title")}</span>
588
+ {/* 머리글 숫자는 **맨 위 층의 줄 수**다. 그룹을 펴면 줄은 늘지만 레이어 수는
589
+ 그대로다 — 펼침 상태에 따라 숫자가 오르내리면 그건 개수가 아니다. */}
590
+ <span className="tabular-nums text-neutral-300">
591
+ {rows.filter((row) => row.depth === 0).length}
592
+ </span>
593
+ </div>
594
+ <div className="min-h-0 flex-1 overflow-y-auto px-1.5 pb-4">
595
+ {rows.length === 0 ? (
596
+ <p className="px-2 py-6 text-center text-xs text-neutral-400">
597
+ {t("detailPage.layers.empty")}
598
+ </p>
599
+ ) : (
600
+ rows.map(({ el, depth }) => (
601
+ <LayerRow
602
+ key={el.id}
603
+ el={el}
604
+ depth={depth}
605
+ store={s}
606
+ expanded={expanded}
607
+ onToggle={toggle}
608
+ onSelect={onSelect}
609
+ context={context}
610
+ drag={drag}
611
+ />
612
+ ))
613
+ )}
614
+ </div>
615
+ </div>
616
+ );
617
+ });
618
+ DetailPageLayersPanel.displayName = "DetailPageLayersPanel";