@pixel-point/toolcraft 0.0.2

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 (257) hide show
  1. package/LICENSE.md +98 -0
  2. package/README.md +41 -0
  3. package/bin/create-toolcraft-app.mjs +8 -0
  4. package/bin/toolcraft.mjs +8 -0
  5. package/package.json +24 -0
  6. package/scripts/prepare-pack.mjs +29 -0
  7. package/src/cli.mjs +392 -0
  8. package/src/cli.test.mjs +284 -0
  9. package/src/copy-recursive.mjs +86 -0
  10. package/src/generate.mjs +212 -0
  11. package/src/generate.test.mjs +322 -0
  12. package/src/import-map.mjs +14 -0
  13. package/src/package-json.mjs +80 -0
  14. package/src/package-json.test.mjs +67 -0
  15. package/src/rewrite-imports.mjs +85 -0
  16. package/src/rewrite-imports.test.mjs +58 -0
  17. package/templates/runtime/contracts/component-contracts.test.ts +1165 -0
  18. package/templates/runtime/contracts/component-contracts.ts +1340 -0
  19. package/templates/runtime/contracts/decision-contracts.test.ts +206 -0
  20. package/templates/runtime/contracts/decision-contracts.ts +283 -0
  21. package/templates/runtime/contracts/index.test.ts +14 -0
  22. package/templates/runtime/contracts/index.ts +3 -0
  23. package/templates/runtime/contracts/types.ts +56 -0
  24. package/templates/runtime/export/export.test.ts +203 -0
  25. package/templates/runtime/export/export.ts +132 -0
  26. package/templates/runtime/export/index.ts +1 -0
  27. package/templates/runtime/index.ts +14 -0
  28. package/templates/runtime/react/canvas-shell.test.tsx +424 -0
  29. package/templates/runtime/react/canvas-shell.tsx +408 -0
  30. package/templates/runtime/react/control-renderers.ts +31 -0
  31. package/templates/runtime/react/controls-panel.test.tsx +3736 -0
  32. package/templates/runtime/react/controls-panel.tsx +2327 -0
  33. package/templates/runtime/react/curve-geometry.test.ts +70 -0
  34. package/templates/runtime/react/index.ts +15 -0
  35. package/templates/runtime/react/layer-tree.ts +96 -0
  36. package/templates/runtime/react/layers-panel.test.tsx +487 -0
  37. package/templates/runtime/react/layers-panel.tsx +1348 -0
  38. package/templates/runtime/react/media-file.ts +82 -0
  39. package/templates/runtime/react/panel-host-config.ts +80 -0
  40. package/templates/runtime/react/panel-host-geometry.test.ts +66 -0
  41. package/templates/runtime/react/panel-host-geometry.ts +109 -0
  42. package/templates/runtime/react/panel-host-types.ts +74 -0
  43. package/templates/runtime/react/panel-host.test.tsx +102 -0
  44. package/templates/runtime/react/panel-host.tsx +353 -0
  45. package/templates/runtime/react/runtime-public-api.test.tsx +132 -0
  46. package/templates/runtime/react/settings-transfer.test.ts +150 -0
  47. package/templates/runtime/react/settings-transfer.ts +279 -0
  48. package/templates/runtime/react/storage-key-migration.ts +48 -0
  49. package/templates/runtime/react/theme-runtime.tsx +177 -0
  50. package/templates/runtime/react/timeline-panel.test.tsx +668 -0
  51. package/templates/runtime/react/timeline-panel.tsx +2953 -0
  52. package/templates/runtime/react/toolbar-panel.test.tsx +212 -0
  53. package/templates/runtime/react/toolbar-panel.tsx +205 -0
  54. package/templates/runtime/react/toolcraft-app.integration.test.tsx +350 -0
  55. package/templates/runtime/react/toolcraft-app.test.tsx +339 -0
  56. package/templates/runtime/react/toolcraft-app.tsx +81 -0
  57. package/templates/runtime/react/toolcraft-root.test.tsx +347 -0
  58. package/templates/runtime/react/toolcraft-root.tsx +203 -0
  59. package/templates/runtime/react/use-toolcraft.ts +41 -0
  60. package/templates/runtime/schema/define-toolcraft.test.ts +1524 -0
  61. package/templates/runtime/schema/define-toolcraft.ts +1442 -0
  62. package/templates/runtime/schema/keyframe-capability.test.ts +90 -0
  63. package/templates/runtime/schema/keyframe-capability.ts +51 -0
  64. package/templates/runtime/schema/runtime-targets.ts +40 -0
  65. package/templates/runtime/schema/types.ts +370 -0
  66. package/templates/runtime/state/canvas-zoom.ts +8 -0
  67. package/templates/runtime/state/create-template-state.test.ts +242 -0
  68. package/templates/runtime/state/create-template-state.ts +95 -0
  69. package/templates/runtime/state/keyframe-evaluation.test.ts +141 -0
  70. package/templates/runtime/state/keyframe-evaluation.ts +203 -0
  71. package/templates/runtime/state/persistence.test.ts +217 -0
  72. package/templates/runtime/state/persistence.ts +511 -0
  73. package/templates/runtime/state/reducer.test.ts +937 -0
  74. package/templates/runtime/state/reducer.ts +1212 -0
  75. package/templates/runtime/state/timeline-readiness.ts +43 -0
  76. package/templates/runtime/state/types.ts +242 -0
  77. package/templates/runtime/styles.css +125 -0
  78. package/templates/runtime/testing/performance.test.ts +1058 -0
  79. package/templates/runtime/testing/performance.ts +1078 -0
  80. package/templates/starter/AGENTS.md +186 -0
  81. package/templates/starter/LICENSE.md +98 -0
  82. package/templates/starter/NOTICE.md +8 -0
  83. package/templates/starter/docs/toolcraft/README.md +41 -0
  84. package/templates/starter/docs/toolcraft/acceptance-testing.md +205 -0
  85. package/templates/starter/docs/toolcraft/agent-worklog.md +81 -0
  86. package/templates/starter/docs/toolcraft/assembly-workflow.md +206 -0
  87. package/templates/starter/docs/toolcraft/component-rules.md +299 -0
  88. package/templates/starter/docs/toolcraft/custom-controls.md +71 -0
  89. package/templates/starter/docs/toolcraft/decision-contract.md +71 -0
  90. package/templates/starter/docs/toolcraft/performance.md +112 -0
  91. package/templates/starter/docs/toolcraft/renderer-technique.md +48 -0
  92. package/templates/starter/docs/toolcraft/schema-reference.md +265 -0
  93. package/templates/starter/docs/toolcraft/workflow.md +87 -0
  94. package/templates/starter/e2e/app-browser-acceptance.spec.ts +785 -0
  95. package/templates/starter/e2e/app-controls.spec.ts +41 -0
  96. package/templates/starter/e2e/app-performance.spec.ts +326 -0
  97. package/templates/starter/e2e/canvas-handle-helpers.ts +244 -0
  98. package/templates/starter/e2e/performance-helpers.ts +612 -0
  99. package/templates/starter/e2e/product-observable-helpers.ts +170 -0
  100. package/templates/starter/index.html +12 -0
  101. package/templates/starter/package.json +52 -0
  102. package/templates/starter/playwright.config.ts +43 -0
  103. package/templates/starter/scripts/check-ai-skills.mjs +95 -0
  104. package/templates/starter/scripts/check-toolcraft-docs.mjs +159 -0
  105. package/templates/starter/scripts/check-toolcraft-integrity.mjs +232 -0
  106. package/templates/starter/scripts/run-vite-on-free-port.mjs +48 -0
  107. package/templates/starter/scripts/toolcraft-port.mjs +54 -0
  108. package/templates/starter/scripts/toolcraft-port.test.mjs +73 -0
  109. package/templates/starter/src/app/starter-acceptance.test.ts +5959 -0
  110. package/templates/starter/src/app/starter-acceptance.ts +2646 -0
  111. package/templates/starter/src/app/starter-performance.test.ts +1390 -0
  112. package/templates/starter/src/app/starter-performance.ts +12 -0
  113. package/templates/starter/src/app/starter-schema.test.ts +70 -0
  114. package/templates/starter/src/app/starter-schema.ts +15 -0
  115. package/templates/starter/src/main.tsx +18 -0
  116. package/templates/starter/src/router.tsx +16 -0
  117. package/templates/starter/src/routes/index.tsx +7 -0
  118. package/templates/starter/src/routes/root.tsx +19 -0
  119. package/templates/starter/src/styles.css +120 -0
  120. package/templates/starter/tsconfig.json +11 -0
  121. package/templates/starter/vite.config.ts +13 -0
  122. package/templates/ui/components/composites/accordion.tsx +73 -0
  123. package/templates/ui/components/composites/alert-dialog.tsx +190 -0
  124. package/templates/ui/components/composites/alert.tsx +74 -0
  125. package/templates/ui/components/composites/aspect-ratio.tsx +22 -0
  126. package/templates/ui/components/composites/avatar.tsx +98 -0
  127. package/templates/ui/components/composites/badge.tsx +69 -0
  128. package/templates/ui/components/composites/breadcrumb.tsx +106 -0
  129. package/templates/ui/components/composites/card.tsx +91 -0
  130. package/templates/ui/components/composites/combobox.tsx +486 -0
  131. package/templates/ui/components/composites/command.tsx +296 -0
  132. package/templates/ui/components/composites/context-menu.tsx +247 -0
  133. package/templates/ui/components/composites/dialog.tsx +282 -0
  134. package/templates/ui/components/composites/dropdown-menu.tsx +299 -0
  135. package/templates/ui/components/composites/empty.tsx +110 -0
  136. package/templates/ui/components/composites/hover-card.tsx +44 -0
  137. package/templates/ui/components/composites/index.ts +30 -0
  138. package/templates/ui/components/composites/menubar.tsx +214 -0
  139. package/templates/ui/components/composites/navigation-menu.tsx +167 -0
  140. package/templates/ui/components/composites/pagination.tsx +131 -0
  141. package/templates/ui/components/composites/progress.tsx +72 -0
  142. package/templates/ui/components/composites/radio-group.tsx +84 -0
  143. package/templates/ui/components/composites/resizable.tsx +42 -0
  144. package/templates/ui/components/composites/sheet.tsx +153 -0
  145. package/templates/ui/components/composites/sidebar-structural.tsx +310 -0
  146. package/templates/ui/components/composites/sidebar.tsx +431 -0
  147. package/templates/ui/components/composites/sonner.tsx +35 -0
  148. package/templates/ui/components/composites/spinner.tsx +43 -0
  149. package/templates/ui/components/composites/table.tsx +108 -0
  150. package/templates/ui/components/composites/tabs.tsx +83 -0
  151. package/templates/ui/components/control-layout/index.tsx +437 -0
  152. package/templates/ui/components/controls/actions/actions-control.tsx +139 -0
  153. package/templates/ui/components/controls/actions/index.ts +9 -0
  154. package/templates/ui/components/controls/anchor-grid/anchor-grid-control.tsx +107 -0
  155. package/templates/ui/components/controls/anchor-grid/index.ts +4 -0
  156. package/templates/ui/components/controls/boolean/boolean-controls.tsx +79 -0
  157. package/templates/ui/components/controls/boolean/index.ts +4 -0
  158. package/templates/ui/components/controls/channel-mixer/channel-mixer-control.tsx +95 -0
  159. package/templates/ui/components/controls/channel-mixer/index.ts +4 -0
  160. package/templates/ui/components/controls/channel-tabs/channel-tabs.tsx +42 -0
  161. package/templates/ui/components/controls/channel-tabs/index.ts +6 -0
  162. package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +90 -0
  163. package/templates/ui/components/controls/code-textarea/index.ts +4 -0
  164. package/templates/ui/components/controls/color/color-control.tsx +571 -0
  165. package/templates/ui/components/controls/color/color-picker-popover.tsx +104 -0
  166. package/templates/ui/components/controls/color/index.ts +41 -0
  167. package/templates/ui/components/controls/color/palette-control-data.ts +436 -0
  168. package/templates/ui/components/controls/color/palette-control.tsx +535 -0
  169. package/templates/ui/components/controls/color/style-guide-color-picker-channel-utils.ts +162 -0
  170. package/templates/ui/components/controls/color/style-guide-color-picker-interactions.ts +190 -0
  171. package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +485 -0
  172. package/templates/ui/components/controls/color/style-guide-color-picker-parts.tsx +710 -0
  173. package/templates/ui/components/controls/color/style-guide-color-picker.tsx +503 -0
  174. package/templates/ui/components/controls/control-types.ts +43 -0
  175. package/templates/ui/components/controls/curves/curve-geometry.ts +355 -0
  176. package/templates/ui/components/controls/curves/curve-graph.tsx +390 -0
  177. package/templates/ui/components/controls/curves/curves-control.tsx +445 -0
  178. package/templates/ui/components/controls/curves/index.ts +6 -0
  179. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +191 -0
  180. package/templates/ui/components/controls/file-drop/index.ts +5 -0
  181. package/templates/ui/components/controls/font-picker/font-catalog.json +15360 -0
  182. package/templates/ui/components/controls/font-picker/font-catalog.ts +116 -0
  183. package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1202 -0
  184. package/templates/ui/components/controls/font-picker/font-preview-loader.ts +336 -0
  185. package/templates/ui/components/controls/font-picker/index.ts +24 -0
  186. package/templates/ui/components/controls/font-picker/use-hover-intent.ts +46 -0
  187. package/templates/ui/components/controls/gradient/gradient-control-utils.ts +190 -0
  188. package/templates/ui/components/controls/gradient/gradient-control.tsx +612 -0
  189. package/templates/ui/components/controls/gradient/gradient-stop-list.tsx +400 -0
  190. package/templates/ui/components/controls/gradient/gradient-toolbar.tsx +152 -0
  191. package/templates/ui/components/controls/gradient/index.ts +4 -0
  192. package/templates/ui/components/controls/image-picker/image-picker-control.tsx +139 -0
  193. package/templates/ui/components/controls/image-picker/index.ts +7 -0
  194. package/templates/ui/components/controls/index.ts +192 -0
  195. package/templates/ui/components/controls/range-input/index.ts +4 -0
  196. package/templates/ui/components/controls/range-input/range-input-control.tsx +173 -0
  197. package/templates/ui/components/controls/range-slider/index.ts +4 -0
  198. package/templates/ui/components/controls/range-slider/range-slider-control.tsx +122 -0
  199. package/templates/ui/components/controls/range-slider/range-slider-value.ts +61 -0
  200. package/templates/ui/components/controls/segmented/index.ts +8 -0
  201. package/templates/ui/components/controls/segmented/segmented-control.tsx +94 -0
  202. package/templates/ui/components/controls/select/index.ts +4 -0
  203. package/templates/ui/components/controls/select/select-control.tsx +223 -0
  204. package/templates/ui/components/controls/slider/index.ts +4 -0
  205. package/templates/ui/components/controls/slider/slider-control.tsx +150 -0
  206. package/templates/ui/components/controls/slider/slider-value.ts +56 -0
  207. package/templates/ui/components/controls/text-input/index.ts +4 -0
  208. package/templates/ui/components/controls/text-input/text-input-control.tsx +158 -0
  209. package/templates/ui/components/controls/use-measured-element-width.ts +42 -0
  210. package/templates/ui/components/controls/vector/index.ts +8 -0
  211. package/templates/ui/components/controls/vector/vector-control.tsx +401 -0
  212. package/templates/ui/components/panel/index.ts +19 -0
  213. package/templates/ui/components/panel/panel-actions.tsx +165 -0
  214. package/templates/ui/components/panel/panel-header.tsx +61 -0
  215. package/templates/ui/components/panel/panel-icon-button.tsx +96 -0
  216. package/templates/ui/components/panel/panel-section.tsx +168 -0
  217. package/templates/ui/components/panel/panel-surface.tsx +206 -0
  218. package/templates/ui/components/panel/panel.tsx +210 -0
  219. package/templates/ui/components/primitives/animated-loader.tsx +61 -0
  220. package/templates/ui/components/primitives/button-group.tsx +134 -0
  221. package/templates/ui/components/primitives/button.tsx +429 -0
  222. package/templates/ui/components/primitives/checkbox.tsx +62 -0
  223. package/templates/ui/components/primitives/editable-slider-value-label.tsx +337 -0
  224. package/templates/ui/components/primitives/field.tsx +225 -0
  225. package/templates/ui/components/primitives/index.ts +82 -0
  226. package/templates/ui/components/primitives/input-group.tsx +298 -0
  227. package/templates/ui/components/primitives/input.tsx +61 -0
  228. package/templates/ui/components/primitives/internal/button-loading.tsx +178 -0
  229. package/templates/ui/components/primitives/label.tsx +16 -0
  230. package/templates/ui/components/primitives/popover.tsx +126 -0
  231. package/templates/ui/components/primitives/portal-layer-context.tsx +33 -0
  232. package/templates/ui/components/primitives/primitive-arrow-icon.tsx +38 -0
  233. package/templates/ui/components/primitives/scroll-fade-logic.ts +441 -0
  234. package/templates/ui/components/primitives/scroll-fade-render.tsx +75 -0
  235. package/templates/ui/components/primitives/scroll-fade-types.ts +41 -0
  236. package/templates/ui/components/primitives/scroll-fade.tsx +72 -0
  237. package/templates/ui/components/primitives/select.tsx +408 -0
  238. package/templates/ui/components/primitives/selection-state.ts +31 -0
  239. package/templates/ui/components/primitives/separator.tsx +21 -0
  240. package/templates/ui/components/primitives/slider/index.ts +4 -0
  241. package/templates/ui/components/primitives/slider/slider-interaction.tsx +96 -0
  242. package/templates/ui/components/primitives/slider/slider-parts.tsx +303 -0
  243. package/templates/ui/components/primitives/slider/slider-reset.ts +152 -0
  244. package/templates/ui/components/primitives/slider/slider-value.ts +114 -0
  245. package/templates/ui/components/primitives/slider/slider.tsx +511 -0
  246. package/templates/ui/components/primitives/switch.tsx +35 -0
  247. package/templates/ui/components/primitives/textarea.tsx +49 -0
  248. package/templates/ui/components/primitives/toggle-group.tsx +114 -0
  249. package/templates/ui/components/primitives/toggle.tsx +46 -0
  250. package/templates/ui/components/primitives/tooltip.tsx +100 -0
  251. package/templates/ui/hooks/use-mobile.ts +21 -0
  252. package/templates/ui/index.ts +31 -0
  253. package/templates/ui/lib/control-outline.ts +3 -0
  254. package/templates/ui/lib/input-control-style.ts +131 -0
  255. package/templates/ui/lib/style-guide-color-utils.ts +111 -0
  256. package/templates/ui/lib/utils.ts +6 -0
  257. package/templates/ui/styles.css +291 -0
@@ -0,0 +1,1348 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import {
5
+ FolderSimpleIcon,
6
+ PlusIcon,
7
+ StackPlusIcon,
8
+ StackSimpleIcon,
9
+ } from "@phosphor-icons/react";
10
+ import {
11
+ Button,
12
+ PanelContentSurface,
13
+ PanelIconButton,
14
+ PanelSurface,
15
+ Popover,
16
+ PopoverContent,
17
+ PopoverTrigger,
18
+ PrimitiveArrowIcon,
19
+ ScrollFade,
20
+ stopPanelHeaderButtonPointerDown,
21
+ } from "@repo/ui";
22
+ import { Eye, EyeOff, Trash2 } from "lucide-react";
23
+
24
+ import type { ToolcraftLayer, ToolcraftPanelState } from "../state/types";
25
+ import {
26
+ getToolcraftLayerDepth,
27
+ getToolcraftVisibleLayerRows,
28
+ isToolcraftLayerInsideGroup,
29
+ isToolcraftLayerVisibleInTree,
30
+ } from "./layer-tree";
31
+ import { PanelContainer } from "./panel-host";
32
+ import type { PanelPlacement, PanelStateChange } from "./panel-host-types";
33
+ import { useToolcraft } from "./use-toolcraft";
34
+
35
+ export type LayersPanelProps = {
36
+ className?: string;
37
+ framed?: boolean;
38
+ groupCreation?: boolean;
39
+ onPanelStateChange?: PanelStateChange;
40
+ panelPlacement?: PanelPlacement;
41
+ panelState?: ToolcraftPanelState;
42
+ };
43
+
44
+ type LayerDropPlacement = "after" | "before";
45
+
46
+ type LayerInsertTarget = {
47
+ indicatorDepth?: number;
48
+ layerId: string;
49
+ parentGroupId?: string | null;
50
+ placement: LayerDropPlacement;
51
+ };
52
+
53
+ type LayerPointerTarget =
54
+ | {
55
+ element: HTMLElement;
56
+ kind: "row";
57
+ }
58
+ | {
59
+ kind: "gap";
60
+ target: LayerInsertTarget;
61
+ };
62
+
63
+ type LayerDragState = {
64
+ dragging: boolean;
65
+ layerId: string;
66
+ pointerId: number;
67
+ startX: number;
68
+ startY: number;
69
+ };
70
+
71
+ const selectedLayerSurfaceClassName =
72
+ "bg-[color:color-mix(in_oklab,var(--accent)_20%,transparent)]";
73
+ const hoveredLayerSurfaceClassName =
74
+ "hover:bg-[color:color-mix(in_oklab,var(--foreground)_10%,transparent)]";
75
+ const layerDepthIndentPx = 20;
76
+ const layerDragStartDistance = 4;
77
+ const layerGroupDropRatioStart = 0.25;
78
+ const layerGroupDropRatioEnd = 0.75;
79
+
80
+ function cn(...classNames: Array<string | false | null | undefined>): string {
81
+ return classNames.filter(Boolean).join(" ");
82
+ }
83
+
84
+ function getLayerDisplayName(layer: ToolcraftLayer): string {
85
+ const displayName = layer.displayName?.trim();
86
+
87
+ return displayName ? displayName : layer.name;
88
+ }
89
+
90
+ function isGroupLayer(layer: ToolcraftLayer): boolean {
91
+ return layer.kind === "group";
92
+ }
93
+
94
+ function getLayerBlockIds(
95
+ layers: readonly ToolcraftLayer[],
96
+ layerId: string,
97
+ ): Set<string> {
98
+ const blockIds = new Set<string>([layerId]);
99
+ let changed = true;
100
+
101
+ while (changed) {
102
+ changed = false;
103
+
104
+ for (const layer of layers) {
105
+ if (layer.parentGroupId && blockIds.has(layer.parentGroupId) && !blockIds.has(layer.id)) {
106
+ blockIds.add(layer.id);
107
+ changed = true;
108
+ }
109
+ }
110
+ }
111
+
112
+ return blockIds;
113
+ }
114
+
115
+ function getReorderedLayers({
116
+ draggingLayerId,
117
+ layers,
118
+ target,
119
+ }: {
120
+ draggingLayerId: string;
121
+ layers: readonly ToolcraftLayer[];
122
+ target: LayerInsertTarget;
123
+ }): ToolcraftLayer[] | null {
124
+ if (draggingLayerId === target.layerId) {
125
+ return null;
126
+ }
127
+
128
+ const blockIds = getLayerBlockIds(layers, draggingLayerId);
129
+
130
+ if (blockIds.has(target.layerId)) {
131
+ return null;
132
+ }
133
+
134
+ const movingBlock = layers.filter((layer) => blockIds.has(layer.id));
135
+ const remainingLayers = layers.filter((layer) => !blockIds.has(layer.id));
136
+ const targetIndex = remainingLayers.findIndex((layer) => layer.id === target.layerId);
137
+
138
+ if (targetIndex < 0) {
139
+ return null;
140
+ }
141
+
142
+ const targetLayer = remainingLayers[targetIndex];
143
+ const insertIndex =
144
+ target.placement === "after" ? targetIndex + 1 : targetIndex;
145
+ const parentGroupId =
146
+ target.parentGroupId === undefined
147
+ ? targetLayer?.parentGroupId
148
+ : (target.parentGroupId ?? undefined);
149
+ const updatedMovingBlock = movingBlock.map((layer) =>
150
+ layer.id === draggingLayerId ? { ...layer, parentGroupId } : layer,
151
+ );
152
+
153
+ return [
154
+ ...remainingLayers.slice(0, insertIndex),
155
+ ...updatedMovingBlock,
156
+ ...remainingLayers.slice(insertIndex),
157
+ ];
158
+ }
159
+
160
+ function getLayerDropRatioFromClientY(
161
+ element: HTMLElement,
162
+ clientY: number,
163
+ fallbackRatio = 0.5,
164
+ ): number {
165
+ const rect = element.getBoundingClientRect();
166
+
167
+ if (rect.height <= 0 || !Number.isFinite(clientY)) {
168
+ return fallbackRatio;
169
+ }
170
+
171
+ return Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
172
+ }
173
+
174
+ function canMoveLayerIntoGroup(
175
+ layers: readonly ToolcraftLayer[],
176
+ draggingLayerId: string,
177
+ groupLayerId: string,
178
+ ): boolean {
179
+ if (draggingLayerId === groupLayerId) {
180
+ return false;
181
+ }
182
+
183
+ return !getLayerBlockIds(layers, draggingLayerId).has(groupLayerId);
184
+ }
185
+
186
+ function getLayerSubtreeEndIndex(
187
+ layers: readonly ToolcraftLayer[],
188
+ groupLayerId: string,
189
+ ): number {
190
+ const groupIndex = layers.findIndex((layer) => layer.id === groupLayerId);
191
+
192
+ if (groupIndex < 0) {
193
+ return layers.length;
194
+ }
195
+
196
+ let endIndex = groupIndex;
197
+
198
+ for (let index = groupIndex + 1; index < layers.length; index += 1) {
199
+ const layer = layers[index];
200
+
201
+ if (!layer || !isToolcraftLayerInsideGroup(layers, layer, groupLayerId)) {
202
+ continue;
203
+ }
204
+
205
+ endIndex = index;
206
+ }
207
+
208
+ return endIndex + 1;
209
+ }
210
+
211
+ function LayerNameEditor({
212
+ displayName,
213
+ draftName,
214
+ onCancel,
215
+ onCommit,
216
+ onDraftNameChange,
217
+ }: {
218
+ displayName: string;
219
+ draftName: string;
220
+ onCancel: () => void;
221
+ onCommit: () => void;
222
+ onDraftNameChange: (value: string) => void;
223
+ }): React.JSX.Element {
224
+ const inputRef = React.useRef<HTMLInputElement | null>(null);
225
+
226
+ React.useEffect(() => {
227
+ const input = inputRef.current;
228
+
229
+ if (!input) {
230
+ return;
231
+ }
232
+
233
+ input.focus();
234
+ input.select();
235
+ }, []);
236
+
237
+ return (
238
+ <div className="flex w-full min-w-0 cursor-text items-center text-left select-text">
239
+ <input
240
+ aria-label={`Layer name for ${displayName}`}
241
+ className="min-w-0 flex-1 cursor-text border-0 bg-transparent p-0 text-xs leading-normal font-medium text-[color:var(--foreground)] outline-none select-text"
242
+ onBlur={onCommit}
243
+ onChange={(event) => onDraftNameChange(event.currentTarget.value)}
244
+ onClick={(event) => event.stopPropagation()}
245
+ onKeyDown={(event) => {
246
+ if (event.key === "Enter") {
247
+ event.preventDefault();
248
+ onCommit();
249
+ }
250
+
251
+ if (event.key === "Escape") {
252
+ event.preventDefault();
253
+ onCancel();
254
+ }
255
+ }}
256
+ onPointerDown={(event) => event.stopPropagation()}
257
+ ref={inputRef}
258
+ value={draftName}
259
+ />
260
+ </div>
261
+ );
262
+ }
263
+
264
+ function LayerNameContent({
265
+ displayName,
266
+ isVisible,
267
+ }: {
268
+ displayName: string;
269
+ isVisible: boolean;
270
+ }): React.JSX.Element {
271
+ return (
272
+ <div className="flex w-full min-w-0 cursor-default items-center text-left select-none">
273
+ <ScrollFade
274
+ className="no-scrollbar min-w-0"
275
+ containerClassName="min-w-0 flex-1"
276
+ preset="compact"
277
+ side="right"
278
+ watch={[displayName]}
279
+ >
280
+ <span
281
+ className={cn(
282
+ "block min-w-max pr-2 text-xs font-medium whitespace-nowrap text-[color:var(--foreground)] transition-opacity duration-150 ease-out select-none",
283
+ !isVisible && "opacity-30",
284
+ )}
285
+ title={displayName}
286
+ >
287
+ {displayName}
288
+ </span>
289
+ </ScrollFade>
290
+ </div>
291
+ );
292
+ }
293
+
294
+ function LayerRowIcon({
295
+ collapsed,
296
+ displayName,
297
+ hasMedia,
298
+ isGroup,
299
+ isVisible,
300
+ onToggleCollapsed,
301
+ }: {
302
+ collapsed?: boolean;
303
+ displayName: string;
304
+ hasMedia: boolean;
305
+ isGroup: boolean;
306
+ isVisible: boolean;
307
+ onToggleCollapsed?: () => void;
308
+ }): React.JSX.Element {
309
+ const iconClassName = cn(
310
+ "size-3 shrink-0 text-[color:var(--foreground)] transition-opacity duration-150 ease-out",
311
+ isVisible ? "opacity-60" : "opacity-30",
312
+ );
313
+
314
+ return (
315
+ <span
316
+ aria-hidden="true"
317
+ className="group/layer-row-icon-hit flex h-8 w-3 shrink-0 cursor-default items-center justify-center"
318
+ data-layer-row-icon-hit-area={isGroup ? "group" : undefined}
319
+ data-layer-row-icon-label={
320
+ isGroup ? (collapsed ? `Expand ${displayName}` : `Collapse ${displayName}`) : undefined
321
+ }
322
+ onClick={
323
+ isGroup
324
+ ? (event) => {
325
+ event.stopPropagation();
326
+ onToggleCollapsed?.();
327
+ }
328
+ : undefined
329
+ }
330
+ onDoubleClick={isGroup ? (event) => event.stopPropagation() : undefined}
331
+ onPointerDown={isGroup ? (event) => event.stopPropagation() : undefined}
332
+ >
333
+ {isGroup ? (
334
+ <svg
335
+ className={cn(
336
+ iconClassName,
337
+ "transition-[opacity,transform] duration-150 ease-out",
338
+ isVisible && "group-hover/layer-row-icon-hit:opacity-100",
339
+ collapsed && "-rotate-90",
340
+ )}
341
+ data-layer-row-icon="group"
342
+ fill="none"
343
+ viewBox="0 0 256 256"
344
+ >
345
+ <path
346
+ d="M58 93L125.879 160.879C127.05 162.05 128.95 162.05 130.121 160.879L198 93"
347
+ stroke="currentColor"
348
+ strokeLinecap="round"
349
+ strokeWidth="24"
350
+ />
351
+ </svg>
352
+ ) : hasMedia ? (
353
+ <svg
354
+ aria-hidden="true"
355
+ className={iconClassName}
356
+ data-layer-row-icon="image"
357
+ fill="none"
358
+ viewBox="0 0 256 256"
359
+ >
360
+ <rect
361
+ height="196"
362
+ rx="20"
363
+ stroke="currentColor"
364
+ strokeLinecap="round"
365
+ strokeWidth="16"
366
+ width="196"
367
+ x="30"
368
+ y="30"
369
+ />
370
+ <path
371
+ d="M60 162.719L88.809 123.726C90.2597 121.762 93.1154 121.545 94.8465 123.266L168 196"
372
+ stroke="currentColor"
373
+ strokeLinecap="round"
374
+ strokeWidth="16"
375
+ />
376
+ <circle cx="164" cy="92" fill="currentColor" r="22" />
377
+ </svg>
378
+ ) : (
379
+ <StackSimpleIcon aria-hidden="true" className={iconClassName} data-layer-row-icon="layer" />
380
+ )}
381
+ </span>
382
+ );
383
+ }
384
+
385
+ function LayerActionButtons({
386
+ displayName,
387
+ isEditingName,
388
+ isDragging,
389
+ isReorderDragging,
390
+ isVisible,
391
+ layer,
392
+ onDelete,
393
+ onToggleVisibility,
394
+ }: {
395
+ displayName: string;
396
+ isEditingName: boolean;
397
+ isDragging: boolean;
398
+ isReorderDragging: boolean;
399
+ isVisible: boolean;
400
+ layer: ToolcraftLayer;
401
+ onDelete: () => void;
402
+ onToggleVisibility: () => void;
403
+ }): React.JSX.Element | null {
404
+ if (isEditingName) {
405
+ return null;
406
+ }
407
+
408
+ const mutedIconStyle = isVisible ? undefined : { opacity: 0.3 };
409
+
410
+ return (
411
+ <div
412
+ className={cn(
413
+ "inline-flex w-0 translate-x-[7px] shrink-0 items-center self-center gap-px overflow-hidden opacity-0",
414
+ isDragging && "pointer-events-none w-auto overflow-visible opacity-100 transition-none",
415
+ !isDragging &&
416
+ (isReorderDragging
417
+ ? "pointer-events-none transition-none"
418
+ : "transition-none group-hover/layer:w-auto group-hover/layer:overflow-visible group-hover/layer:opacity-100"),
419
+ )}
420
+ data-layer-actions=""
421
+ data-visible={isVisible ? "true" : "false"}
422
+ >
423
+ <Button
424
+ aria-label={layer.visible ? `Hide ${displayName}` : `Show ${displayName}`}
425
+ className="cursor-default!"
426
+ onClick={(event) => {
427
+ event.stopPropagation();
428
+ onToggleVisibility();
429
+ }}
430
+ onDoubleClick={(event) => event.stopPropagation()}
431
+ onPointerDown={(event) => event.stopPropagation()}
432
+ size="icon-sm"
433
+ type="button"
434
+ variant="ghost"
435
+ >
436
+ {layer.visible ? <Eye style={mutedIconStyle} /> : <EyeOff style={mutedIconStyle} />}
437
+ </Button>
438
+ <Button
439
+ aria-label={`Delete ${displayName}`}
440
+ className="cursor-default!"
441
+ onClick={(event) => {
442
+ event.stopPropagation();
443
+ onDelete();
444
+ }}
445
+ onDoubleClick={(event) => event.stopPropagation()}
446
+ onPointerDown={(event) => event.stopPropagation()}
447
+ size="icon-sm"
448
+ type="button"
449
+ variant="ghost"
450
+ >
451
+ <Trash2 style={mutedIconStyle} />
452
+ </Button>
453
+ </div>
454
+ );
455
+ }
456
+
457
+ function useLayerNameEditing({
458
+ displayName,
459
+ onRename,
460
+ }: {
461
+ displayName: string;
462
+ onRename: (displayName: string) => void;
463
+ }) {
464
+ const [isEditingName, setIsEditingName] = React.useState(false);
465
+ const [draftName, setDraftName] = React.useState(displayName);
466
+ const skipNextBlurCommitRef = React.useRef(false);
467
+
468
+ const startEditingName = (): void => {
469
+ setDraftName(displayName);
470
+ setIsEditingName(true);
471
+ };
472
+
473
+ const commitEditingName = (): void => {
474
+ if (skipNextBlurCommitRef.current) {
475
+ skipNextBlurCommitRef.current = false;
476
+ return;
477
+ }
478
+
479
+ const nextDisplayName = draftName.trim().replace(/\s+/g, " ");
480
+
481
+ if (nextDisplayName && nextDisplayName !== displayName) {
482
+ onRename(nextDisplayName);
483
+ }
484
+
485
+ setIsEditingName(false);
486
+ };
487
+
488
+ const cancelEditingName = (): void => {
489
+ skipNextBlurCommitRef.current = true;
490
+ setDraftName(displayName);
491
+ setIsEditingName(false);
492
+ };
493
+
494
+ return {
495
+ cancelEditingName,
496
+ commitEditingName,
497
+ draftName,
498
+ isEditingName,
499
+ setDraftName,
500
+ startEditingName,
501
+ };
502
+ }
503
+
504
+ function LayerRow({
505
+ depth,
506
+ hasMedia,
507
+ insertIndicatorDepth,
508
+ insertPlacement,
509
+ isDragging,
510
+ isDropTarget,
511
+ isGroupDropAvailable,
512
+ isGroupHighlighted,
513
+ isReorderDragging,
514
+ isSelected,
515
+ isVisible,
516
+ layer,
517
+ onDelete,
518
+ onPointerCancel,
519
+ onPointerDown,
520
+ onPointerMove,
521
+ onPointerUp,
522
+ onRename,
523
+ onSelect,
524
+ onToggleCollapsed,
525
+ onToggleVisibility,
526
+ }: {
527
+ depth: number;
528
+ hasMedia: boolean;
529
+ insertIndicatorDepth?: number;
530
+ insertPlacement?: LayerDropPlacement;
531
+ isDragging: boolean;
532
+ isDropTarget: boolean;
533
+ isGroupDropAvailable: boolean;
534
+ isGroupHighlighted: boolean;
535
+ isReorderDragging: boolean;
536
+ isSelected: boolean;
537
+ isVisible: boolean;
538
+ layer: ToolcraftLayer;
539
+ onDelete: () => void;
540
+ onPointerCancel: React.PointerEventHandler<HTMLElement>;
541
+ onPointerDown: React.PointerEventHandler<HTMLElement>;
542
+ onPointerMove: React.PointerEventHandler<HTMLElement>;
543
+ onPointerUp: React.PointerEventHandler<HTMLElement>;
544
+ onRename: (displayName: string) => void;
545
+ onSelect: () => void;
546
+ onToggleCollapsed: () => void;
547
+ onToggleVisibility: () => void;
548
+ }): React.JSX.Element {
549
+ const displayName = getLayerDisplayName(layer);
550
+ const isGroup = isGroupLayer(layer);
551
+ const nameEditing = useLayerNameEditing({ displayName, onRename });
552
+
553
+ return (
554
+ <li
555
+ aria-label={displayName}
556
+ aria-selected={isSelected}
557
+ className={cn(
558
+ "group/layer relative flex h-8 cursor-default! touch-none select-none items-center gap-px",
559
+ isDragging && "cursor-grabbing",
560
+ )}
561
+ data-dragging={isDragging ? "true" : undefined}
562
+ data-drop-indicator={insertPlacement}
563
+ data-drop-target={isDropTarget ? "true" : undefined}
564
+ data-layer-id={layer.id}
565
+ data-reorder-dragging={isReorderDragging ? "true" : undefined}
566
+ data-selected={isSelected}
567
+ data-visible={isVisible ? "true" : "false"}
568
+ data-template-layer-depth={depth}
569
+ data-template-layer-kind={isGroup ? "group" : "layer"}
570
+ data-template-layer-name={layer.id}
571
+ data-template-layer-parent={layer.parentGroupId}
572
+ onClick={onSelect}
573
+ onDoubleClick={(event) => {
574
+ event.preventDefault();
575
+ nameEditing.startEditingName();
576
+ }}
577
+ onKeyDown={(event) => {
578
+ if (
579
+ nameEditing.isEditingName ||
580
+ !(event.key === "Enter" || event.key === " ") ||
581
+ (event.target instanceof Element && event.target.closest("button,input,select,textarea"))
582
+ ) {
583
+ return;
584
+ }
585
+
586
+ event.preventDefault();
587
+ onSelect();
588
+ }}
589
+ onPointerCancel={onPointerCancel}
590
+ onPointerDown={onPointerDown}
591
+ onPointerMove={onPointerMove}
592
+ onPointerUp={onPointerUp}
593
+ role="option"
594
+ tabIndex={0}
595
+ >
596
+ {insertPlacement ? (
597
+ <span
598
+ aria-hidden="true"
599
+ className={cn(
600
+ "pointer-events-none absolute right-2 z-20 h-px rounded-full bg-[color:var(--foreground)]",
601
+ insertPlacement === "before" ? "-top-px" : isGroup ? "bottom-0" : "-bottom-px",
602
+ )}
603
+ data-layer-drop-indicator={insertPlacement}
604
+ style={{
605
+ left: `${
606
+ 8 + Math.max(0, insertIndicatorDepth ?? depth) * layerDepthIndentPx
607
+ }px`,
608
+ }}
609
+ />
610
+ ) : null}
611
+ <div
612
+ className={cn(
613
+ "grid h-8 min-w-0 flex-1 cursor-default! grid-cols-[minmax(0,1fr)_auto] grid-rows-[minmax(0,1fr)] items-center gap-1.5 rounded-lg border pr-2.5 pl-[7px]",
614
+ "border-transparent transition-none",
615
+ !isSelected && !isReorderDragging && hoveredLayerSurfaceClassName,
616
+ isSelected && selectedLayerSurfaceClassName,
617
+ isGroupDropAvailable && "border-[color:var(--accent)]",
618
+ isDropTarget &&
619
+ "bg-[color:color-mix(in_oklab,var(--accent)_10%,transparent)]",
620
+ isGroupHighlighted &&
621
+ "border-[color:var(--accent)] bg-[color:color-mix(in_oklab,var(--accent)_15%,transparent)]",
622
+ )}
623
+ data-layer-row-surface=""
624
+ >
625
+ <div
626
+ className="flex min-w-0 items-center gap-2"
627
+ style={depth > 0 ? { marginLeft: `${depth * layerDepthIndentPx}px` } : undefined}
628
+ >
629
+ <LayerRowIcon
630
+ collapsed={isGroup ? layer.collapsed === true : undefined}
631
+ displayName={displayName}
632
+ hasMedia={hasMedia}
633
+ isGroup={isGroup}
634
+ isVisible={isVisible}
635
+ onToggleCollapsed={isGroup ? onToggleCollapsed : undefined}
636
+ />
637
+ {nameEditing.isEditingName ? (
638
+ <LayerNameEditor
639
+ displayName={displayName}
640
+ draftName={nameEditing.draftName}
641
+ onCancel={nameEditing.cancelEditingName}
642
+ onCommit={nameEditing.commitEditingName}
643
+ onDraftNameChange={nameEditing.setDraftName}
644
+ />
645
+ ) : (
646
+ <LayerNameContent displayName={displayName} isVisible={isVisible} />
647
+ )}
648
+ </div>
649
+ <LayerActionButtons
650
+ displayName={displayName}
651
+ isEditingName={nameEditing.isEditingName}
652
+ isDragging={isDragging}
653
+ isReorderDragging={isReorderDragging}
654
+ isVisible={isVisible}
655
+ layer={layer}
656
+ onDelete={onDelete}
657
+ onToggleVisibility={onToggleVisibility}
658
+ />
659
+ </div>
660
+ </li>
661
+ );
662
+ }
663
+
664
+ function AddLayerPicker({
665
+ groupCreation,
666
+ onAddGroup,
667
+ onAddLayer,
668
+ }: {
669
+ groupCreation: boolean;
670
+ onAddGroup: () => void;
671
+ onAddLayer: () => void;
672
+ }): React.JSX.Element {
673
+ const [open, setOpen] = React.useState(false);
674
+
675
+ const addLayer = (): void => {
676
+ onAddLayer();
677
+ setOpen(false);
678
+ };
679
+ const addGroup = (): void => {
680
+ onAddGroup();
681
+ setOpen(false);
682
+ };
683
+
684
+ if (!groupCreation) {
685
+ return (
686
+ <Button
687
+ aria-label="Add layer"
688
+ data-icon-active={false}
689
+ onClick={addLayer}
690
+ onPointerDown={stopPanelHeaderButtonPointerDown}
691
+ size="icon"
692
+ type="button"
693
+ variant="ghost"
694
+ >
695
+ <PlusIcon />
696
+ </Button>
697
+ );
698
+ }
699
+
700
+ return (
701
+ <Popover onOpenChange={setOpen} open={open}>
702
+ <PopoverTrigger
703
+ aria-label="Add layer"
704
+ onPointerDown={stopPanelHeaderButtonPointerDown}
705
+ render={<Button data-icon-active={open} size="icon" type="button" variant="ghost" />}
706
+ >
707
+ <PlusIcon />
708
+ </PopoverTrigger>
709
+ <PopoverContent align="end" className="w-[148px] gap-1 p-1" side="bottom" sideOffset={4}>
710
+ <Button
711
+ className="h-8 justify-start gap-2 px-2 text-xs"
712
+ onClick={addLayer}
713
+ size="sm"
714
+ type="button"
715
+ variant="ghost"
716
+ >
717
+ <StackPlusIcon className="size-4" />
718
+ Layer
719
+ </Button>
720
+ <Button
721
+ className="h-8 justify-start gap-2 px-2 text-xs"
722
+ onClick={addGroup}
723
+ size="sm"
724
+ type="button"
725
+ variant="ghost"
726
+ >
727
+ <FolderSimpleIcon className="size-4" />
728
+ Group
729
+ </Button>
730
+ </PopoverContent>
731
+ </Popover>
732
+ );
733
+ }
734
+
735
+ function LayersPanelHeader({
736
+ collapsed,
737
+ groupCreation,
738
+ onAddGroup,
739
+ onAddLayer,
740
+ onToggleCollapsed,
741
+ }: {
742
+ collapsed: boolean;
743
+ groupCreation: boolean;
744
+ onAddGroup: () => void;
745
+ onAddLayer: () => void;
746
+ onToggleCollapsed: () => void;
747
+ }): React.JSX.Element {
748
+ return (
749
+ <div className="shrink-0" data-slot="layers-panel-header-shell">
750
+ <div
751
+ className="flex h-9 touch-none items-center justify-between gap-3 pr-1 pl-3 hover:cursor-grab active:cursor-grabbing"
752
+ data-panel-drag-handle=""
753
+ data-slot="layers-panel-header"
754
+ >
755
+ <p className="m-0 min-w-0 truncate text-xs-plus font-medium text-[color:var(--foreground)]">
756
+ Layers
757
+ </p>
758
+ <div className="inline-flex shrink-0 items-center gap-1">
759
+ {collapsed ? null : (
760
+ <AddLayerPicker
761
+ groupCreation={groupCreation}
762
+ onAddGroup={onAddGroup}
763
+ onAddLayer={onAddLayer}
764
+ />
765
+ )}
766
+ <PanelIconButton
767
+ label={collapsed ? "Expand layers" : "Collapse layers"}
768
+ onClick={onToggleCollapsed}
769
+ onPointerDown={stopPanelHeaderButtonPointerDown}
770
+ >
771
+ <PrimitiveArrowIcon direction={collapsed ? "down" : "up"} />
772
+ </PanelIconButton>
773
+ </div>
774
+ </div>
775
+ </div>
776
+ );
777
+ }
778
+
779
+ export function LayersPanel({
780
+ className,
781
+ framed = true,
782
+ groupCreation = true,
783
+ onPanelStateChange,
784
+ panelPlacement,
785
+ panelState,
786
+ }: LayersPanelProps): React.JSX.Element | null {
787
+ const { dispatch, state } = useToolcraft();
788
+ const [internalCollapsed, setInternalCollapsed] = React.useState(false);
789
+ const [dragState, setDragState] = React.useState<LayerDragState | null>(null);
790
+ const [dropTargetGroupId, setDropTargetGroupId] = React.useState<string | null>(null);
791
+ const [highlightedGroupId, setHighlightedGroupId] = React.useState<string | null>(null);
792
+ const [insertTarget, setInsertTarget] = React.useState<LayerInsertTarget | null>(null);
793
+ const dragStateRef = React.useRef<LayerDragState | null>(null);
794
+ const dropTargetGroupIdRef = React.useRef<string | null>(null);
795
+ const highlightedGroupIdRef = React.useRef<string | null>(null);
796
+ const insertTargetRef = React.useRef<LayerInsertTarget | null>(null);
797
+ const listRef = React.useRef<HTMLUListElement | null>(null);
798
+ const collapsed = panelState?.collapsed ?? internalCollapsed;
799
+ const placement = panelPlacement ?? (framed ? "frame" : "surface");
800
+ const visibleLayers = React.useMemo(() => getToolcraftVisibleLayerRows(state.layers), [
801
+ state.layers,
802
+ ]);
803
+
804
+ if (!state.schema.panels.layers) {
805
+ return null;
806
+ }
807
+
808
+ const updateCollapsed = (nextCollapsed: boolean): void => {
809
+ if (panelState?.collapsed === undefined) {
810
+ setInternalCollapsed(nextCollapsed);
811
+ }
812
+
813
+ onPanelStateChange?.({ collapsed: nextCollapsed });
814
+ };
815
+
816
+ const addLayer = (): void => {
817
+ const selectedLayer = state.layers.find((layer) => layer.id === state.selectedLayerId);
818
+ const parentGroupId =
819
+ selectedLayer?.kind === "group" ? selectedLayer.id : selectedLayer?.parentGroupId;
820
+ const insertIndex = selectedLayer
821
+ ? selectedLayer.kind === "group"
822
+ ? getLayerSubtreeEndIndex(state.layers, selectedLayer.id)
823
+ : state.layers.findIndex((layer) => layer.id === selectedLayer.id) + 1
824
+ : state.layers.length;
825
+
826
+ dispatch({
827
+ insertIndex,
828
+ layer: { kind: "layer", parentGroupId },
829
+ type: "layers.add",
830
+ });
831
+ };
832
+
833
+ const addGroup = (): void => {
834
+ const selectedLayer = state.layers.find((layer) => layer.id === state.selectedLayerId);
835
+ const parentGroupId =
836
+ selectedLayer?.kind === "group" ? selectedLayer.id : selectedLayer?.parentGroupId;
837
+ const insertIndex = selectedLayer
838
+ ? selectedLayer.kind === "group"
839
+ ? getLayerSubtreeEndIndex(state.layers, selectedLayer.id)
840
+ : state.layers.findIndex((layer) => layer.id === selectedLayer.id) + 1
841
+ : state.layers.length;
842
+
843
+ dispatch({
844
+ insertIndex,
845
+ layer: { kind: "group", parentGroupId },
846
+ type: "layers.add",
847
+ });
848
+ };
849
+
850
+ const clearDragState = (): void => {
851
+ dragStateRef.current = null;
852
+ dropTargetGroupIdRef.current = null;
853
+ highlightedGroupIdRef.current = null;
854
+ insertTargetRef.current = null;
855
+ setDragState(null);
856
+ setDropTargetGroupId(null);
857
+ setHighlightedGroupId(null);
858
+ setInsertTarget(null);
859
+ };
860
+
861
+ const updateDragState = (nextDragState: LayerDragState | null): void => {
862
+ dragStateRef.current = nextDragState;
863
+ setDragState(nextDragState);
864
+ };
865
+
866
+ const updateDropTargetGroupId = (nextDropTargetGroupId: string | null): void => {
867
+ dropTargetGroupIdRef.current = nextDropTargetGroupId;
868
+ setDropTargetGroupId(nextDropTargetGroupId);
869
+ };
870
+
871
+ const updateHighlightedGroupId = (nextHighlightedGroupId: string | null): void => {
872
+ highlightedGroupIdRef.current = nextHighlightedGroupId;
873
+ setHighlightedGroupId(nextHighlightedGroupId);
874
+ };
875
+
876
+ const updateInsertTarget = (nextInsertTarget: LayerInsertTarget | null): void => {
877
+ insertTargetRef.current = nextInsertTarget;
878
+ setInsertTarget(nextInsertTarget);
879
+ };
880
+
881
+ const getCanonicalInsertTarget = (
882
+ layer: ToolcraftLayer,
883
+ placement: LayerDropPlacement,
884
+ ): LayerInsertTarget => {
885
+ if (placement === "before") {
886
+ return { layerId: layer.id, placement };
887
+ }
888
+
889
+ const visibleIndex = visibleLayers.findIndex((visibleLayer) => visibleLayer.id === layer.id);
890
+ const nextVisibleLayer = visibleIndex >= 0 ? visibleLayers[visibleIndex + 1] : undefined;
891
+
892
+ return nextVisibleLayer
893
+ ? { layerId: nextVisibleLayer.id, placement: "before" }
894
+ : { layerId: layer.id, placement: "after" };
895
+ };
896
+
897
+ const getVisibleLayerIndex = (layerId: string): number =>
898
+ visibleLayers.findIndex((visibleLayer) => visibleLayer.id === layerId);
899
+
900
+ const isLastVisibleLayerInParentGroup = (layer: ToolcraftLayer): boolean => {
901
+ if (!layer.parentGroupId) {
902
+ return false;
903
+ }
904
+
905
+ const visibleIndex = getVisibleLayerIndex(layer.id);
906
+ const nextVisibleLayer = visibleIndex >= 0 ? visibleLayers[visibleIndex + 1] : undefined;
907
+
908
+ return (
909
+ !nextVisibleLayer ||
910
+ !isToolcraftLayerInsideGroup(state.layers, nextVisibleLayer, layer.parentGroupId)
911
+ );
912
+ };
913
+
914
+ const isPointerAtNestedInsertDepth = (
915
+ layer: ToolcraftLayer,
916
+ clientX: number,
917
+ ): boolean => {
918
+ if (!Number.isFinite(clientX)) {
919
+ return false;
920
+ }
921
+
922
+ const listLeft = listRef.current?.getBoundingClientRect().left ?? 0;
923
+ const layerDepth = getToolcraftLayerDepth(state.layers, layer);
924
+
925
+ return clientX >= listLeft + 8 + layerDepth * layerDepthIndentPx;
926
+ };
927
+
928
+ const getParentGroupLayer = (layer: ToolcraftLayer): ToolcraftLayer | undefined =>
929
+ layer.parentGroupId
930
+ ? state.layers.find(
931
+ (currentLayer) => currentLayer.id === layer.parentGroupId && isGroupLayer(currentLayer),
932
+ )
933
+ : undefined;
934
+
935
+ const getInsertTargetAfterLayerSubtree = (layer: ToolcraftLayer): LayerInsertTarget => {
936
+ const layerDepth = getToolcraftLayerDepth(state.layers, layer);
937
+ const visibleIndex = getVisibleLayerIndex(layer.id);
938
+
939
+ if (visibleIndex < 0) {
940
+ return getCanonicalInsertTarget(layer, "after");
941
+ }
942
+
943
+ let lastVisibleLayer = layer;
944
+
945
+ for (const nextVisibleLayer of visibleLayers.slice(visibleIndex + 1)) {
946
+ if (getToolcraftLayerDepth(state.layers, nextVisibleLayer) <= layerDepth) {
947
+ return { layerId: nextVisibleLayer.id, placement: "before" };
948
+ }
949
+
950
+ lastVisibleLayer = nextVisibleLayer;
951
+ }
952
+
953
+ return {
954
+ indicatorDepth: layerDepth,
955
+ layerId: lastVisibleLayer.id,
956
+ parentGroupId: layer.parentGroupId ?? null,
957
+ placement: "after",
958
+ };
959
+ };
960
+
961
+ const getOutsideParentGroupInsertTarget = (layer: ToolcraftLayer): LayerInsertTarget => {
962
+ const parentGroupLayer = getParentGroupLayer(layer);
963
+
964
+ return parentGroupLayer
965
+ ? getInsertTargetAfterLayerSubtree(parentGroupLayer)
966
+ : getCanonicalInsertTarget(layer, "after");
967
+ };
968
+
969
+ const getNestedBoundaryLayerForTarget = (
970
+ target: LayerInsertTarget,
971
+ ): ToolcraftLayer | undefined => {
972
+ const targetLayer = state.layers.find((layer) => layer.id === target.layerId);
973
+
974
+ if (!targetLayer) {
975
+ return undefined;
976
+ }
977
+
978
+ if (target.placement === "after") {
979
+ return isLastVisibleLayerInParentGroup(targetLayer) ? targetLayer : undefined;
980
+ }
981
+
982
+ const visibleIndex = getVisibleLayerIndex(targetLayer.id);
983
+ const previousVisibleLayer =
984
+ visibleIndex > 0 ? visibleLayers[visibleIndex - 1] : undefined;
985
+
986
+ return previousVisibleLayer && isLastVisibleLayerInParentGroup(previousVisibleLayer)
987
+ ? previousVisibleLayer
988
+ : undefined;
989
+ };
990
+
991
+ const getResolvedInsertTarget = (
992
+ target: LayerInsertTarget,
993
+ clientX: number,
994
+ ): LayerInsertTarget | null => {
995
+ const boundaryLayer = getNestedBoundaryLayerForTarget(target);
996
+
997
+ if (boundaryLayer) {
998
+ return isPointerAtNestedInsertDepth(boundaryLayer, clientX)
999
+ ? { layerId: boundaryLayer.id, placement: "after" }
1000
+ : getOutsideParentGroupInsertTarget(boundaryLayer);
1001
+ }
1002
+
1003
+ const targetLayer = state.layers.find((layer) => layer.id === target.layerId);
1004
+
1005
+ return targetLayer ? getCanonicalInsertTarget(targetLayer, target.placement) : null;
1006
+ };
1007
+
1008
+ const getNearestInsertTarget = (
1009
+ layer: ToolcraftLayer,
1010
+ dropRatio: number,
1011
+ clientX: number,
1012
+ ): LayerInsertTarget | null => {
1013
+ if (dropRatio < 0.5) {
1014
+ return { layerId: layer.id, placement: "before" };
1015
+ }
1016
+
1017
+ return getResolvedInsertTarget({ layerId: layer.id, placement: "after" }, clientX);
1018
+ };
1019
+
1020
+ const getInsertIndicatorTarget = (target: LayerInsertTarget): LayerInsertTarget => {
1021
+ if (target.placement !== "before") {
1022
+ return target;
1023
+ }
1024
+
1025
+ const visibleIndex = visibleLayers.findIndex(
1026
+ (visibleLayer) => visibleLayer.id === target.layerId,
1027
+ );
1028
+ const previousVisibleLayer =
1029
+ visibleIndex > 0 ? visibleLayers[visibleIndex - 1] : undefined;
1030
+
1031
+ return previousVisibleLayer && isGroupLayer(previousVisibleLayer)
1032
+ ? { layerId: previousVisibleLayer.id, placement: "after" }
1033
+ : target;
1034
+ };
1035
+
1036
+ const getPointerTarget = (clientX: number, clientY: number): LayerPointerTarget | null => {
1037
+ const targetElement = document
1038
+ .elementFromPoint(clientX, clientY)
1039
+ ?.closest<HTMLElement>("[data-layer-id]");
1040
+
1041
+ if (targetElement) {
1042
+ return { element: targetElement, kind: "row" };
1043
+ }
1044
+
1045
+ const listElement = listRef.current;
1046
+
1047
+ if (!listElement) {
1048
+ return null;
1049
+ }
1050
+
1051
+ const listRect = listElement.getBoundingClientRect();
1052
+
1053
+ if (
1054
+ clientX < listRect.left ||
1055
+ clientX > listRect.right ||
1056
+ clientY < listRect.top ||
1057
+ clientY > listRect.bottom
1058
+ ) {
1059
+ return null;
1060
+ }
1061
+
1062
+ const rowElements = Array.from(listElement.querySelectorAll<HTMLElement>("[data-layer-id]"));
1063
+
1064
+ for (const rowElement of rowElements) {
1065
+ const rowRect = rowElement.getBoundingClientRect();
1066
+ const layerId = rowElement.dataset.layerId;
1067
+
1068
+ if (!layerId) {
1069
+ continue;
1070
+ }
1071
+
1072
+ if (clientY < rowRect.top) {
1073
+ return { kind: "gap", target: { layerId, placement: "before" } };
1074
+ }
1075
+
1076
+ if (clientY <= rowRect.bottom) {
1077
+ return { element: rowElement, kind: "row" };
1078
+ }
1079
+ }
1080
+
1081
+ const lastRowElement = rowElements.at(-1);
1082
+ const lastLayerId = lastRowElement?.dataset.layerId;
1083
+
1084
+ return lastLayerId
1085
+ ? { kind: "gap", target: { layerId: lastLayerId, placement: "after" } }
1086
+ : null;
1087
+ };
1088
+
1089
+ const clearDragTarget = (): void => {
1090
+ updateDropTargetGroupId(null);
1091
+ updateHighlightedGroupId(null);
1092
+ updateInsertTarget(null);
1093
+ };
1094
+
1095
+ const updateDragTarget = (
1096
+ pointerTarget: LayerPointerTarget | null,
1097
+ clientX: number,
1098
+ clientY: number,
1099
+ activeLayerId: string,
1100
+ ): void => {
1101
+ if (!pointerTarget) {
1102
+ clearDragTarget();
1103
+ return;
1104
+ }
1105
+
1106
+ if (pointerTarget.kind === "gap") {
1107
+ const nextInsertTarget = getResolvedInsertTarget(pointerTarget.target, clientX);
1108
+
1109
+ updateDropTargetGroupId(null);
1110
+ updateHighlightedGroupId(null);
1111
+ updateInsertTarget(nextInsertTarget);
1112
+ return;
1113
+ }
1114
+
1115
+ const layerId = pointerTarget.element.dataset.layerId;
1116
+
1117
+ if (!layerId || layerId === activeLayerId) {
1118
+ clearDragTarget();
1119
+ return;
1120
+ }
1121
+
1122
+ const targetLayer = state.layers.find((layer) => layer.id === layerId);
1123
+
1124
+ if (!targetLayer) {
1125
+ clearDragTarget();
1126
+ return;
1127
+ }
1128
+
1129
+ const canDropIntoGroup =
1130
+ targetLayer.kind === "group" &&
1131
+ canMoveLayerIntoGroup(state.layers, activeLayerId, targetLayer.id);
1132
+ const highlightedGroup = targetLayer.kind === "group" ? targetLayer.id : null;
1133
+ const dropRatio = getLayerDropRatioFromClientY(
1134
+ pointerTarget.element,
1135
+ clientY,
1136
+ canDropIntoGroup ? 0.5 : 0,
1137
+ );
1138
+
1139
+ if (
1140
+ canDropIntoGroup &&
1141
+ dropRatio > layerGroupDropRatioStart &&
1142
+ dropRatio < layerGroupDropRatioEnd
1143
+ ) {
1144
+ updateDropTargetGroupId(targetLayer.id);
1145
+ updateHighlightedGroupId(targetLayer.id);
1146
+ updateInsertTarget(null);
1147
+ return;
1148
+ }
1149
+
1150
+ updateDropTargetGroupId(null);
1151
+ updateHighlightedGroupId(highlightedGroup);
1152
+ updateInsertTarget(getNearestInsertTarget(targetLayer, dropRatio, clientX));
1153
+ };
1154
+
1155
+ const commitDrag = (): void => {
1156
+ const activeDragState = dragStateRef.current ?? dragState;
1157
+ const activeDropTargetGroupId = dropTargetGroupIdRef.current ?? dropTargetGroupId;
1158
+ const activeInsertTarget = insertTargetRef.current ?? insertTarget;
1159
+
1160
+ if (!activeDragState?.dragging) {
1161
+ clearDragState();
1162
+ return;
1163
+ }
1164
+
1165
+ if (activeDropTargetGroupId) {
1166
+ dispatch({
1167
+ layerIds: [activeDragState.layerId],
1168
+ parentGroupId: activeDropTargetGroupId,
1169
+ type: "layers.moveToGroup",
1170
+ });
1171
+ clearDragState();
1172
+ return;
1173
+ }
1174
+
1175
+ if (!activeInsertTarget) {
1176
+ clearDragState();
1177
+ return;
1178
+ }
1179
+
1180
+ const layers = getReorderedLayers({
1181
+ draggingLayerId: activeDragState.layerId,
1182
+ layers: state.layers,
1183
+ target: activeInsertTarget,
1184
+ });
1185
+
1186
+ if (layers) {
1187
+ dispatch({ layers, type: "layers.reorder" });
1188
+ }
1189
+
1190
+ clearDragState();
1191
+ };
1192
+
1193
+ const panelSurface = (
1194
+ <PanelSurface
1195
+ className={cn(
1196
+ "pointer-events-auto flex max-h-[calc(100dvh-1.25rem)] w-[240px] flex-col overflow-hidden rounded-lg p-0",
1197
+ className,
1198
+ )}
1199
+ data-toolcraft-layers-panel=""
1200
+ data-panel-id="layers"
1201
+ >
1202
+ <LayersPanelHeader
1203
+ collapsed={collapsed}
1204
+ groupCreation={groupCreation}
1205
+ onAddGroup={addGroup}
1206
+ onAddLayer={addLayer}
1207
+ onToggleCollapsed={() => updateCollapsed(!collapsed)}
1208
+ />
1209
+ {collapsed ? null : (
1210
+ <PanelContentSurface data-slot="layers-panel-content">
1211
+ <ul
1212
+ aria-label="Layers"
1213
+ className="flex min-h-0 flex-col gap-0.5 p-1"
1214
+ data-layer-list=""
1215
+ data-layer-list-dragging={dragState?.dragging ? "true" : undefined}
1216
+ ref={listRef}
1217
+ role="listbox"
1218
+ >
1219
+ {visibleLayers.map((layer) => {
1220
+ const depth = getToolcraftLayerDepth(state.layers, layer);
1221
+ const displayName = getLayerDisplayName(layer);
1222
+ const isDragging = dragState?.layerId === layer.id && dragState.dragging;
1223
+ const isReorderDragging = dragState?.dragging === true;
1224
+ const isVisible = isToolcraftLayerVisibleInTree(state.layers, layer);
1225
+ const hasMedia = state.mediaAssets.some((asset) => asset.layerId === layer.id);
1226
+ const insertIndicatorTarget = insertTarget
1227
+ ? getInsertIndicatorTarget(insertTarget)
1228
+ : null;
1229
+ const rowInsertPlacement =
1230
+ insertIndicatorTarget?.layerId === layer.id
1231
+ ? insertIndicatorTarget.placement
1232
+ : undefined;
1233
+
1234
+ return (
1235
+ <LayerRow
1236
+ depth={depth}
1237
+ hasMedia={hasMedia}
1238
+ insertIndicatorDepth={insertIndicatorTarget?.indicatorDepth ?? depth}
1239
+ insertPlacement={rowInsertPlacement}
1240
+ isDragging={isDragging}
1241
+ isDropTarget={dropTargetGroupId === layer.id}
1242
+ isGroupDropAvailable={
1243
+ dragState?.dragging === true &&
1244
+ layer.kind === "group" &&
1245
+ dropTargetGroupId === layer.id &&
1246
+ canMoveLayerIntoGroup(state.layers, dragState.layerId, layer.id)
1247
+ }
1248
+ isGroupHighlighted={highlightedGroupId === layer.id}
1249
+ isReorderDragging={isReorderDragging}
1250
+ isSelected={state.selectedLayerId === layer.id}
1251
+ isVisible={isVisible}
1252
+ key={layer.id}
1253
+ layer={layer}
1254
+ onDelete={() => dispatch({ layerId: layer.id, type: "layers.delete" })}
1255
+ onPointerCancel={clearDragState}
1256
+ onPointerDown={(event) => {
1257
+ if (event.button !== 0) {
1258
+ return;
1259
+ }
1260
+
1261
+ event.currentTarget.setPointerCapture?.(event.pointerId);
1262
+ updateDragState({
1263
+ dragging: false,
1264
+ layerId: layer.id,
1265
+ pointerId: event.pointerId,
1266
+ startX: event.clientX,
1267
+ startY: event.clientY,
1268
+ });
1269
+ }}
1270
+ onPointerMove={(event) => {
1271
+ const activeDragState = dragStateRef.current ?? dragState;
1272
+
1273
+ if (!activeDragState || activeDragState.pointerId !== event.pointerId) {
1274
+ return;
1275
+ }
1276
+
1277
+ const distance = Math.hypot(
1278
+ event.clientX - activeDragState.startX,
1279
+ event.clientY - activeDragState.startY,
1280
+ );
1281
+
1282
+ if (!activeDragState.dragging && distance < layerDragStartDistance) {
1283
+ return;
1284
+ }
1285
+
1286
+ event.preventDefault();
1287
+ const nextDragState = { ...activeDragState, dragging: true };
1288
+
1289
+ updateDragState(nextDragState);
1290
+ updateDragTarget(
1291
+ getPointerTarget(event.clientX, event.clientY),
1292
+ event.clientX,
1293
+ event.clientY,
1294
+ activeDragState.layerId,
1295
+ );
1296
+ }}
1297
+ onPointerUp={(event) => {
1298
+ const activeDragState = dragStateRef.current ?? dragState;
1299
+
1300
+ if (activeDragState?.pointerId !== event.pointerId) {
1301
+ return;
1302
+ }
1303
+
1304
+ commitDrag();
1305
+ }}
1306
+ onRename={(name) =>
1307
+ dispatch({
1308
+ layerId: layer.id,
1309
+ name,
1310
+ type: "layers.rename",
1311
+ })
1312
+ }
1313
+ onSelect={() => dispatch({ layerId: layer.id, type: "layers.select" })}
1314
+ onToggleCollapsed={() =>
1315
+ dispatch({ layerId: layer.id, type: "layers.toggleCollapsed" })
1316
+ }
1317
+ onToggleVisibility={() =>
1318
+ dispatch({ layerId: layer.id, type: "layers.toggleVisibility" })
1319
+ }
1320
+ />
1321
+ );
1322
+ })}
1323
+ {visibleLayers.length === 0 ? (
1324
+ <li className="px-2 py-3 text-xs text-[color:color-mix(in_oklab,var(--foreground)_55%,transparent)]">
1325
+ No layers
1326
+ </li>
1327
+ ) : null}
1328
+ </ul>
1329
+ </PanelContentSurface>
1330
+ )}
1331
+ </PanelSurface>
1332
+ );
1333
+
1334
+ if (placement === "surface") {
1335
+ return panelSurface;
1336
+ }
1337
+
1338
+ return (
1339
+ <PanelContainer
1340
+ onPanelStateChange={onPanelStateChange}
1341
+ panelState={panelState}
1342
+ panelType="layers"
1343
+ placement={placement}
1344
+ >
1345
+ {panelSurface}
1346
+ </PanelContainer>
1347
+ );
1348
+ }