southpaw 0.2.7

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 (169) hide show
  1. package/README.md +51 -0
  2. package/api/client.ts +83 -0
  3. package/api/helpers/fs.ts +98 -0
  4. package/api/helpers/stream.ts +6 -0
  5. package/api/index.ts +50 -0
  6. package/api/server.ts +566 -0
  7. package/constants/fonts.ts +1583 -0
  8. package/constants/icons.ts +2124 -0
  9. package/constants/langs.ts +2592 -0
  10. package/constants/ssr-loading.tsx +17 -0
  11. package/contexts/dev.tsx +124 -0
  12. package/hooks/useDebounceCb.ts +21 -0
  13. package/modules/app.tsx +102 -0
  14. package/modules/behaviors.ts +161 -0
  15. package/modules/component.tsx +483 -0
  16. package/modules/config/index.ts +8 -0
  17. package/modules/database.ts +25 -0
  18. package/modules/document.tsx +23 -0
  19. package/modules/error.tsx +16 -0
  20. package/modules/page/autozoom.tsx +92 -0
  21. package/modules/page/browser.tsx +166 -0
  22. package/modules/page/index.tsx +327 -0
  23. package/modules/page/insert-modal.tsx +225 -0
  24. package/modules/page/inspect/index.tsx +237 -0
  25. package/modules/page/inspect/wrapper.tsx +88 -0
  26. package/modules/page/knobs/action.tsx +58 -0
  27. package/modules/page/knobs/color.tsx +78 -0
  28. package/modules/page/knobs/helpers.ts +71 -0
  29. package/modules/page/knobs/icon.tsx +85 -0
  30. package/modules/page/knobs/linker.tsx +188 -0
  31. package/modules/page/knobs/list-json.tsx +149 -0
  32. package/modules/page/knobs/list-text.tsx +129 -0
  33. package/modules/page/knobs/number.tsx +84 -0
  34. package/modules/page/knobs/segmented.tsx +81 -0
  35. package/modules/page/knobs/select.tsx +89 -0
  36. package/modules/page/knobs/size.tsx +106 -0
  37. package/modules/page/knobs/switch.tsx +75 -0
  38. package/modules/page/knobs/text.tsx +93 -0
  39. package/modules/page/left-tabs.tsx +36 -0
  40. package/modules/page/right-tabs.tsx +31 -0
  41. package/modules/page/seo.tsx +69 -0
  42. package/modules/page/tree.tsx +860 -0
  43. package/modules/state.ts +787 -0
  44. package/modules/store.ts +37 -0
  45. package/modules/theme.ts +105 -0
  46. package/modules/translations.ts +15 -0
  47. package/package.json +68 -0
  48. package/scripts/bin.js +607 -0
  49. package/scripts/test.js +145 -0
  50. package/themes/base.tsx +8962 -0
  51. package/themes/index.ts +4 -0
  52. package/themes/m2.ts +39 -0
  53. package/themes/newspaper.ts +39 -0
  54. package/types/api.ts +57 -0
  55. package/types/fiber_node.ts +69 -0
  56. package/types/react_element.ts +14 -0
  57. package/ui/accordion.tsx +70 -0
  58. package/ui/actionicon.tsx +41 -0
  59. package/ui/affix.tsx +14 -0
  60. package/ui/alert.tsx +37 -0
  61. package/ui/anchor.tsx +52 -0
  62. package/ui/arrow.tsx +116 -0
  63. package/ui/aspectratio.tsx +21 -0
  64. package/ui/autocomplete.tsx +34 -0
  65. package/ui/avatar.tsx +36 -0
  66. package/ui/backgroundimage.tsx +16 -0
  67. package/ui/badge.tsx +28 -0
  68. package/ui/blockquote.tsx +15 -0
  69. package/ui/blocks/faq.tsx +145 -0
  70. package/ui/blocks/features.tsx +167 -0
  71. package/ui/blocks/footer.tsx +182 -0
  72. package/ui/blocks/herobanner.tsx +391 -0
  73. package/ui/blocks/index.ts +44 -0
  74. package/ui/blocks/leftnav.tsx +141 -0
  75. package/ui/blocks/pricing.tsx +210 -0
  76. package/ui/blocks/testimonials.tsx +149 -0
  77. package/ui/blocks/uidocs.tsx +1429 -0
  78. package/ui/box.tsx +27 -0
  79. package/ui/breadcrumbs.tsx +33 -0
  80. package/ui/burger.tsx +23 -0
  81. package/ui/button.tsx +85 -0
  82. package/ui/card.tsx +28 -0
  83. package/ui/center.tsx +26 -0
  84. package/ui/checkbox.tsx +35 -0
  85. package/ui/chip.tsx +32 -0
  86. package/ui/closebutton.tsx +15 -0
  87. package/ui/code.tsx +104 -0
  88. package/ui/collapse.tsx +16 -0
  89. package/ui/colorinput.tsx +32 -0
  90. package/ui/colorpicker.tsx +26 -0
  91. package/ui/colorswatch.tsx +17 -0
  92. package/ui/combobox.tsx +80 -0
  93. package/ui/container.tsx +27 -0
  94. package/ui/dialog.tsx +15 -0
  95. package/ui/divider.tsx +58 -0
  96. package/ui/drawer.tsx +28 -0
  97. package/ui/fieldset.tsx +15 -0
  98. package/ui/filebutton.tsx +23 -0
  99. package/ui/fileinput.tsx +36 -0
  100. package/ui/flex.tsx +139 -0
  101. package/ui/focustrap.tsx +15 -0
  102. package/ui/form.tsx +26 -0
  103. package/ui/grid.tsx +69 -0
  104. package/ui/highlight.tsx +15 -0
  105. package/ui/hoverable.tsx +66 -0
  106. package/ui/hovercard.tsx +45 -0
  107. package/ui/icon.tsx +76 -0
  108. package/ui/image.tsx +32 -0
  109. package/ui/index.ts +1355 -0
  110. package/ui/indicator.tsx +42 -0
  111. package/ui/infobox.tsx +200 -0
  112. package/ui/input.tsx +18 -0
  113. package/ui/inviewport.tsx +23 -0
  114. package/ui/jsoninput.tsx +35 -0
  115. package/ui/kbd.tsx +20 -0
  116. package/ui/list.tsx +33 -0
  117. package/ui/loader.tsx +26 -0
  118. package/ui/loadingoverlay.tsx +22 -0
  119. package/ui/mark.tsx +15 -0
  120. package/ui/menu.tsx +89 -0
  121. package/ui/modal.tsx +27 -0
  122. package/ui/multiselect.tsx +42 -0
  123. package/ui/nativeselect.tsx +17 -0
  124. package/ui/navlink.tsx +40 -0
  125. package/ui/notification.tsx +25 -0
  126. package/ui/numberformatter.tsx +15 -0
  127. package/ui/numberinput.tsx +37 -0
  128. package/ui/overlay.tsx +15 -0
  129. package/ui/pagination.tsx +21 -0
  130. package/ui/paper.tsx +33 -0
  131. package/ui/passwordinput.tsx +36 -0
  132. package/ui/pill.tsx +17 -0
  133. package/ui/pillsinput.tsx +37 -0
  134. package/ui/pininput.tsx +30 -0
  135. package/ui/popover.tsx +43 -0
  136. package/ui/portal.tsx +15 -0
  137. package/ui/progress.tsx +49 -0
  138. package/ui/props.ts +91 -0
  139. package/ui/propsDef.ts +38 -0
  140. package/ui/radio.tsx +30 -0
  141. package/ui/rating.tsx +25 -0
  142. package/ui/ringprogress.tsx +17 -0
  143. package/ui/scrollarea.tsx +27 -0
  144. package/ui/segmentedcontrol.tsx +38 -0
  145. package/ui/select.tsx +43 -0
  146. package/ui/simplegrid.tsx +23 -0
  147. package/ui/skeleton.tsx +15 -0
  148. package/ui/slider.tsx +31 -0
  149. package/ui/space.tsx +19 -0
  150. package/ui/spoiler.tsx +23 -0
  151. package/ui/stepper.tsx +63 -0
  152. package/ui/switch.tsx +29 -0
  153. package/ui/table.tsx +93 -0
  154. package/ui/tabs.tsx +80 -0
  155. package/ui/tagsinput.tsx +19 -0
  156. package/ui/terminal.tsx +44 -0
  157. package/ui/text.tsx +168 -0
  158. package/ui/textarea.tsx +36 -0
  159. package/ui/textinput.tsx +34 -0
  160. package/ui/timeline.tsx +30 -0
  161. package/ui/title.tsx +42 -0
  162. package/ui/tooltip.tsx +32 -0
  163. package/ui/transition.tsx +21 -0
  164. package/ui/tree.tsx +42 -0
  165. package/ui/typographystylesprovider.tsx +20 -0
  166. package/ui/unstyledbutton.tsx +18 -0
  167. package/ui/video.tsx +16 -0
  168. package/ui/visuallyhidden.tsx +15 -0
  169. package/utils/index.ts +76 -0
@@ -0,0 +1,860 @@
1
+ import {
2
+ ActionIcon,
3
+ Center,
4
+ Flex,
5
+ Group,
6
+ Menu,
7
+ RenderTreeNodePayload,
8
+ Tabs,
9
+ Text,
10
+ Tree,
11
+ TreeProps,
12
+ useTree,
13
+ } from "@mantine/core";
14
+ import { modals } from "@mantine/modals";
15
+ import {
16
+ FC,
17
+ FunctionComponent,
18
+ useCallback,
19
+ useEffect,
20
+ useRef,
21
+ useState,
22
+ } from "react";
23
+ import { ReactElementType } from "../../types/react_element";
24
+ import {
25
+ IconChevronDown,
26
+ IconChevronRight,
27
+ IconChevronUp,
28
+ IconCircle,
29
+ IconComponents,
30
+ IconCopy,
31
+ IconDotsVertical,
32
+ IconGripVertical,
33
+ IconList,
34
+ IconPlus,
35
+ IconTrash,
36
+ } from "@tabler/icons-react";
37
+ import {
38
+ DndContext,
39
+ DragEndEvent,
40
+ DragMoveEvent,
41
+ MeasuringStrategy,
42
+ PointerSensor,
43
+ useSensor,
44
+ useSensors,
45
+ } from "@dnd-kit/core";
46
+ import { SortableContext, useSortable } from "@dnd-kit/sortable";
47
+ import { CSS as dndCSS } from "@dnd-kit/utilities";
48
+ import { ModalInsert } from "./insert-modal";
49
+ import { FETCH } from "southpaw/api/client";
50
+ import { TNode, useContextDev } from "southpaw/contexts/dev";
51
+ import { FiberNodeType } from "../../types/fiber_node";
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Types
55
+ // ---------------------------------------------------------------------------
56
+
57
+ type DropPosition = "before" | "after" | "within";
58
+
59
+ type DragOverData = {
60
+ overId: string | null;
61
+ position: DropPosition | null;
62
+ };
63
+
64
+ type TTreePayload = Omit<RenderTreeNodePayload, "node"> & { node: TNode };
65
+
66
+ type ReactJsxEl = ReactElementType | string | number | ReactJsxEl[];
67
+
68
+ type Props = { jsx: ReactElementType };
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Pure helpers — defined outside the component so closures are never stale
72
+ // ---------------------------------------------------------------------------
73
+
74
+ function flattenIds(items: TreeProps["data"]): string[] {
75
+ return items.reduce<string[]>((acc, item) => {
76
+ acc.push(item.value);
77
+ if (item.children?.length) acc.push(...flattenIds(item.children));
78
+ return acc;
79
+ }, []);
80
+ }
81
+
82
+ function findNodeByValue(
83
+ items: TreeProps["data"],
84
+ value: string,
85
+ ): TreeProps["data"][number] | null {
86
+ for (const item of items) {
87
+ if (item.value === value) return item;
88
+ if (item.children?.length) {
89
+ const found = findNodeByValue(item.children, value);
90
+ if (found) return found;
91
+ }
92
+ }
93
+ return null;
94
+ }
95
+
96
+ function moveNodeOptimistically(
97
+ currentData: TreeProps["data"],
98
+ activeId: string,
99
+ overId: string,
100
+ position: DropPosition,
101
+ ): TreeProps["data"] {
102
+ let activeNode: TreeProps["data"][number] | null = null;
103
+
104
+ const removeNode = (items: TreeProps["data"]): TreeProps["data"] =>
105
+ items
106
+ .filter((item) => {
107
+ if (item.value === activeId) {
108
+ activeNode = item;
109
+ return false;
110
+ }
111
+ return true;
112
+ })
113
+ .map((item) => ({ ...item, children: removeNode(item.children ?? []) }));
114
+
115
+ const withoutActive = removeNode(currentData);
116
+ if (!activeNode) return currentData;
117
+
118
+ const insertNode = (items: TreeProps["data"]): TreeProps["data"] => {
119
+ const idx = items.findIndex((i) => i.value === overId);
120
+ if (idx !== -1) {
121
+ const copy = [...items];
122
+ if (position === "before") {
123
+ copy.splice(idx, 0, activeNode!);
124
+ return copy;
125
+ }
126
+ if (position === "after") {
127
+ copy.splice(idx + 1, 0, activeNode!);
128
+ return copy;
129
+ }
130
+ // "within" — append as last child of the target
131
+ return copy.map((item) =>
132
+ item.value === overId
133
+ ? { ...item, children: [...(item.children ?? []), activeNode!] }
134
+ : item,
135
+ );
136
+ }
137
+ return items.map((item) => ({
138
+ ...item,
139
+ children: insertNode(item.children ?? []),
140
+ }));
141
+ };
142
+
143
+ return insertNode(withoutActive);
144
+ }
145
+
146
+ function safeParse(str: string): unknown {
147
+ try {
148
+ return JSON.parse(str);
149
+ } catch {
150
+ return str;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Returns the [data-value] of the element under the pointer.
156
+ *
157
+ * The actively-dragged SortableLeaf has `pointer-events: none`, so
158
+ * elementFromPoint skips the ghost and returns the actual drop target
159
+ * underneath — even when dragging a non-child over a parent that already
160
+ * has visible children.
161
+ */
162
+ function getNodeValueAtPoint(x: number, y: number): string | null {
163
+ const el = document.elementFromPoint(x, y);
164
+ if (!el) return null;
165
+ return el.closest<HTMLElement>("[data-value]")?.dataset.value ?? null;
166
+ }
167
+
168
+ /**
169
+ * Given a target node value and the current pointer Y, decide whether the
170
+ * pointer is in the top-25% (before), bottom-25% (after), or middle-50%
171
+ * (within) zone of that node's row.
172
+ */
173
+ function computeDropPosition(
174
+ overId: string,
175
+ pointerY: number,
176
+ overNode: TreeProps["data"][number] | null,
177
+ ): DropPosition {
178
+ const el = document.querySelector<HTMLElement>(
179
+ `[data-value="${CSS.escape(overId)}"]`,
180
+ );
181
+ if (!el) return "after";
182
+
183
+ const rect = el.getBoundingClientRect();
184
+ const relY = pointerY - rect.top;
185
+
186
+ const canNest =
187
+ overNode != null &&
188
+ Object.keys(overNode.nodeProps ?? {}).includes("children");
189
+
190
+ if (canNest) {
191
+ const threshold = rect.height / 4;
192
+ if (relY < threshold) return "before";
193
+ if (relY > rect.height - threshold) return "after";
194
+ return "within";
195
+ }
196
+
197
+ return relY < rect.height / 2 ? "before" : "after";
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // LeafIcon
202
+ // ---------------------------------------------------------------------------
203
+
204
+ const LeafIcon: FC<{ label: string; isFolder: boolean; expanded: boolean }> = ({
205
+ label,
206
+ isFolder,
207
+ expanded,
208
+ }) => {
209
+ if (isFolder) {
210
+ return expanded ? (
211
+ <IconChevronDown strokeWidth="1px" size={16} />
212
+ ) : (
213
+ <IconChevronRight strokeWidth="1px" size={16} />
214
+ );
215
+ }
216
+ return label.startsWith("Blocks.") ? (
217
+ <IconComponents size={16} />
218
+ ) : (
219
+ <IconCircle size={16} />
220
+ );
221
+ };
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // LeafMenu
225
+ // ---------------------------------------------------------------------------
226
+
227
+ const LeafMenu: FC<{
228
+ node: TNode;
229
+ level: number;
230
+ dispatch: ReturnType<typeof useContextDev>["dispatch"];
231
+ cannotInsertWithin: boolean;
232
+ isRoot: boolean;
233
+ }> = ({ node, level, dispatch, cannotInsertWithin, isRoot }) => (
234
+ <Menu shadow="md" width={200} withinPortal={false}>
235
+ <Menu.Target>
236
+ <ActionIcon
237
+ flex="none"
238
+ onClick={(e) => e.stopPropagation()}
239
+ mr="sm"
240
+ p={2}
241
+ size="compact-xs"
242
+ variant="subtle"
243
+ color="dark"
244
+ >
245
+ <IconDotsVertical size={20} />
246
+ </ActionIcon>
247
+ </Menu.Target>
248
+
249
+ <Menu.Dropdown>
250
+ <Menu.Item
251
+ disabled={level === 1}
252
+ onClick={(e) => {
253
+ e.stopPropagation();
254
+ dispatch({
255
+ type: "TREE_SET_MENU_NODE",
256
+ args: { node, insertMode: "before" },
257
+ });
258
+ dispatch({ type: "MODALS_OPEN", args: { name: "insert" } });
259
+ }}
260
+ leftSection={
261
+ <IconList size={20} style={{ transform: "scale(-1,1)" }} />
262
+ }
263
+ >
264
+ Insert before
265
+ </Menu.Item>
266
+
267
+ <Menu.Item
268
+ disabled={cannotInsertWithin}
269
+ onClick={(e) => {
270
+ e.stopPropagation();
271
+ dispatch({
272
+ type: "TREE_SET_MENU_NODE",
273
+ args: { node, insertMode: "within" },
274
+ });
275
+ dispatch({ type: "MODALS_OPEN", args: { name: "insert" } });
276
+ }}
277
+ leftSection={<IconPlus size={20} />}
278
+ >
279
+ Insert within
280
+ </Menu.Item>
281
+
282
+ <Menu.Item
283
+ disabled={level === 1}
284
+ onClick={(e) => {
285
+ e.stopPropagation();
286
+ dispatch({
287
+ type: "TREE_SET_MENU_NODE",
288
+ args: { node, insertMode: "after" },
289
+ });
290
+ dispatch({ type: "MODALS_OPEN", args: { name: "insert" } });
291
+ }}
292
+ leftSection={<IconList size={20} />}
293
+ >
294
+ Insert after
295
+ </Menu.Item>
296
+
297
+ <Menu.Divider />
298
+
299
+ <Menu.Item
300
+ disabled={isRoot}
301
+ onClick={async (e) => {
302
+ e.stopPropagation();
303
+ await FETCH.page.duplicateNode({
304
+ source: JSON.parse(node.value) as FiberNodeType["_debugSource"],
305
+ });
306
+ }}
307
+ leftSection={<IconCopy size={20} />}
308
+ >
309
+ Duplicate
310
+ </Menu.Item>
311
+
312
+ <Menu.Divider />
313
+
314
+ <Menu.Item
315
+ disabled={isRoot}
316
+ onClick={async (e) => {
317
+ e.stopPropagation();
318
+ await FETCH.page.moveUpNode({
319
+ source: JSON.parse(node.value) as FiberNodeType["_debugSource"],
320
+ });
321
+ }}
322
+ leftSection={<IconChevronUp size={20} />}
323
+ >
324
+ Move up
325
+ </Menu.Item>
326
+
327
+ <Menu.Item
328
+ disabled={isRoot}
329
+ onClick={async (e) => {
330
+ e.stopPropagation();
331
+ await FETCH.page.moveDownNode({
332
+ source: JSON.parse(node.value) as FiberNodeType["_debugSource"],
333
+ });
334
+ }}
335
+ leftSection={<IconChevronDown size={20} />}
336
+ >
337
+ Move down
338
+ </Menu.Item>
339
+
340
+ <Menu.Divider />
341
+
342
+ <Menu.Item
343
+ disabled={level === 1}
344
+ color="red"
345
+ onClick={(e) => {
346
+ e.stopPropagation();
347
+ modals.openConfirmModal({
348
+ title: "Delete node",
349
+ children: (
350
+ <Text size="sm">
351
+ Are you sure you want to delete this {node.label}? This action
352
+ cannot be undone.
353
+ </Text>
354
+ ),
355
+ labels: { confirm: "Delete", cancel: "Cancel" },
356
+ confirmProps: { color: "red" },
357
+ onConfirm: async () => {
358
+ await FETCH.page.deleteNode({
359
+ source: JSON.parse(node.value) as FiberNodeType["_debugSource"],
360
+ });
361
+ dispatch({
362
+ type: "TREE_SELECT_NODE",
363
+ args: { selectedNode: null },
364
+ });
365
+ },
366
+ });
367
+ }}
368
+ leftSection={<IconTrash size={20} />}
369
+ >
370
+ Delete
371
+ </Menu.Item>
372
+ </Menu.Dropdown>
373
+ </Menu>
374
+ );
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // SortableLeaf
378
+ // ---------------------------------------------------------------------------
379
+
380
+ const SortableLeaf: FC<TTreePayload & { dragOverData: DragOverData }> = ({
381
+ node,
382
+ expanded,
383
+ hasChildren,
384
+ elementProps,
385
+ selected,
386
+ level,
387
+ dragOverData,
388
+ }) => {
389
+ const { dispatch } = useContextDev();
390
+ const isRoot = level === 1;
391
+ const label = node.label!.toString();
392
+ const cannotInsertWithin = !Object.keys(node.nodeProps ?? {}).includes(
393
+ "children",
394
+ );
395
+ const isOverWithin =
396
+ dragOverData.overId === node.value && dragOverData.position === "within";
397
+ const isOverBefore =
398
+ dragOverData.overId === node.value && dragOverData.position === "before";
399
+ const isOverAfter =
400
+ dragOverData.overId === node.value && dragOverData.position === "after";
401
+
402
+ const { attributes, listeners, setNodeRef, transform, isDragging } =
403
+ useSortable({
404
+ id: node.value,
405
+ disabled: isRoot,
406
+ });
407
+
408
+ useEffect(() => {
409
+ if (selected) {
410
+ dispatch({ type: "TREE_SELECT_NODE", args: { selectedNode: node } });
411
+ }
412
+ }, [selected, dispatch, node]);
413
+
414
+ const { style: elementStyle, ...restElementProps } = elementProps ?? {};
415
+
416
+ return (
417
+ <div
418
+ ref={setNodeRef}
419
+ style={{
420
+ transform: dndCSS.Translate.toString(transform),
421
+ // No CSS transition — eliminates jitter on fast drags.
422
+ opacity: isDragging ? 0.3 : 1,
423
+ // KEY FIX: pointer-events:none while dragging makes document.elementFromPoint
424
+ // skip this element entirely. Without this, when dragging node X over a
425
+ // parent P that already has children, elementFromPoint would return X's
426
+ // ghost (still at its original DOM position) instead of P, so "within"
427
+ // would never fire for P.
428
+ pointerEvents: isDragging ? "none" : undefined,
429
+ cursor: "default",
430
+ position: "relative",
431
+ backgroundColor:
432
+ isOverWithin || selected
433
+ ? "var(--mantine-primary-color-light)"
434
+ : "transparent",
435
+ borderRadius: "var(--mantine-radius-sm)",
436
+ outline:
437
+ isOverWithin || selected
438
+ ? `1px ${isOverWithin ? "dashed" : "solid"} var(--mantine-primary-color-filled)`
439
+ : "none",
440
+ outlineOffset: -1,
441
+ ...elementStyle,
442
+ }}
443
+ {...restElementProps}
444
+ >
445
+ {!isRoot && (isOverBefore || isOverAfter) && (
446
+ <div
447
+ style={{
448
+ position: "absolute",
449
+ top: isOverBefore ? -2 : undefined,
450
+ bottom: isOverAfter ? -2 : undefined,
451
+ left: (level - 1) * 20,
452
+ right: 0,
453
+ height: 4,
454
+ backgroundColor: "var(--mantine-primary-color-filled)",
455
+ borderRadius: 2,
456
+ zIndex: 10,
457
+ pointerEvents: "none",
458
+ display: "flex",
459
+ alignItems: "center",
460
+ }}
461
+ >
462
+ <div
463
+ style={{
464
+ width: 8,
465
+ height: 8,
466
+ backgroundColor: "var(--mantine-primary-color-filled)",
467
+ borderRadius: "50%",
468
+ marginLeft: -4,
469
+ boxShadow: "0 0 4px rgba(0,0,0,0.2)",
470
+ }}
471
+ />
472
+ </div>
473
+ )}
474
+ <Flex align="center" gap={5} pl={isRoot ? 10 : undefined}>
475
+ <Group flex={1} wrap="nowrap" style={{ overflow: "hidden" }} gap={0}>
476
+ {!isRoot && (
477
+ <Center
478
+ p={4}
479
+ style={{ cursor: "grab" }}
480
+ {...attributes}
481
+ {...listeners}
482
+ onClick={(e) => e.stopPropagation()}
483
+ >
484
+ <IconGripVertical size={14} color="gray" />
485
+ </Center>
486
+ )}
487
+ <Center flex="none" ml={isRoot ? "sm" : 0}>
488
+ <LeafIcon
489
+ label={label}
490
+ isFolder={hasChildren}
491
+ expanded={expanded}
492
+ />
493
+ </Center>
494
+ <Text p="xs" truncate title={label}>
495
+ {label}
496
+ </Text>
497
+ </Group>
498
+
499
+ {label !== "Fragment" && (
500
+ <LeafMenu
501
+ node={node}
502
+ level={level}
503
+ dispatch={dispatch}
504
+ cannotInsertWithin={cannotInsertWithin}
505
+ isRoot={isRoot}
506
+ />
507
+ )}
508
+ </Flex>
509
+ </div>
510
+ );
511
+ };
512
+
513
+ // -------------------------------------------------------------------------
514
+ // generateData — static recursive helper
515
+ // -------------------------------------------------------------------------
516
+
517
+ const generateData = async (
518
+ reactJsxEl: ReactJsxEl,
519
+ index: number,
520
+ ): Promise<TreeProps["data"][number] | null> => {
521
+ if (
522
+ Array.isArray(reactJsxEl) ||
523
+ typeof reactJsxEl === "string" ||
524
+ typeof reactJsxEl === "number" ||
525
+ typeof reactJsxEl === "boolean" ||
526
+ typeof reactJsxEl === "function"
527
+ ) {
528
+ return null;
529
+ }
530
+
531
+ const isBasicComponent =
532
+ reactJsxEl.$$typeof === Symbol.for("react.element") &&
533
+ typeof reactJsxEl.type === "function";
534
+
535
+ if (isBasicComponent) {
536
+ const fn = reactJsxEl.type as FC;
537
+ return {
538
+ label: fn.displayName || fn.name,
539
+ value: JSON.stringify(reactJsxEl._source),
540
+ children: [],
541
+ nodeProps: reactJsxEl.props,
542
+ };
543
+ }
544
+
545
+ const isFragment = reactJsxEl.type.toString() === "Symbol(react.fragment)";
546
+ if (isFragment) {
547
+ const nodeProps = reactJsxEl.props;
548
+ const raw = nodeProps.children
549
+ ? Array.isArray(nodeProps.children)
550
+ ? nodeProps.children
551
+ : [nodeProps.children]
552
+ : [];
553
+ const children = (await Promise.all(raw.map(generateData))).filter(Boolean);
554
+ return {
555
+ label: "Fragment",
556
+ value: `fragment-${index}`,
557
+ children,
558
+ nodeProps,
559
+ };
560
+ }
561
+
562
+ const isHtmlElement = typeof reactJsxEl.type === "string";
563
+ if (isHtmlElement) {
564
+ const nodeProps = reactJsxEl.props;
565
+ const raw = nodeProps.children
566
+ ? Array.isArray(nodeProps.children)
567
+ ? nodeProps.children
568
+ : [nodeProps.children]
569
+ : [];
570
+ const children = (await Promise.all(raw.map(generateData))).filter(Boolean);
571
+ return {
572
+ label: reactJsxEl.type as string,
573
+ value: JSON.stringify(reactJsxEl._source),
574
+ children,
575
+ nodeProps,
576
+ };
577
+ }
578
+
579
+ const displayName = (reactJsxEl.type as FunctionComponent).displayName;
580
+ if (!displayName) return null;
581
+
582
+ const isUI = displayName.startsWith("UI.");
583
+ const isBlock = displayName.startsWith("Blocks.");
584
+ const isComponent = displayName.startsWith("Components.");
585
+ if (!isUI && !isBlock && !isComponent) return null;
586
+
587
+ const [, name] = displayName.split(".");
588
+ let comp: { params: { props: Record<string, unknown> } };
589
+
590
+ if (isUI) ({ default: comp } = await import(`southpaw/ui/${name}`));
591
+ else if (isBlock)
592
+ ({ default: comp } = await import(`southpaw/ui/blocks/${name}`));
593
+ else ({ default: comp } = await import(`@/components/${name}`));
594
+
595
+ const nodeProps = { ...comp.params.props, ...reactJsxEl.props };
596
+ const raw = nodeProps.children
597
+ ? Array.isArray(nodeProps.children)
598
+ ? nodeProps.children
599
+ : [nodeProps.children]
600
+ : [];
601
+ const children = (await Promise.all(raw.map(generateData))).filter(Boolean);
602
+
603
+ return {
604
+ label: displayName,
605
+ value: JSON.stringify(reactJsxEl._source),
606
+ nodeProps,
607
+ children,
608
+ };
609
+ };
610
+
611
+ // ---------------------------------------------------------------------------
612
+ // TabPageTree
613
+ // ---------------------------------------------------------------------------
614
+
615
+ export const TabPageTree: FC<Props> = ({ jsx }) => {
616
+ const { dispatch } = useContextDev();
617
+ const [data, setData] = useState<TreeProps["data"]>([]);
618
+ const [dragOverData, setDragOverData] = useState<DragOverData>({
619
+ overId: null,
620
+ position: null,
621
+ });
622
+
623
+ // Ref mirrors so event handlers always read fresh values without being in
624
+ // useCallback dependency arrays (which would cause unnecessary re-subscriptions).
625
+ const dragOverDataRef = useRef<DragOverData>({
626
+ overId: null,
627
+ position: null,
628
+ });
629
+ const dataRef = useRef<TreeProps["data"]>([]);
630
+ useEffect(() => {
631
+ dataRef.current = data;
632
+ }, [data]);
633
+
634
+ const tree = useTree();
635
+
636
+ // rAF handle for onDragMove throttling.
637
+ const rafRef = useRef<number | null>(null);
638
+
639
+ // Auto-expand: when hovering "within" a node for 600 ms, expand it.
640
+ const autoExpandTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
641
+
642
+ const clearAutoExpand = useCallback(() => {
643
+ if (autoExpandTimer.current !== null) {
644
+ clearTimeout(autoExpandTimer.current);
645
+ autoExpandTimer.current = null;
646
+ }
647
+ }, []);
648
+
649
+ useEffect(
650
+ () => () => {
651
+ clearAutoExpand();
652
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
653
+ },
654
+ [clearAutoExpand],
655
+ );
656
+
657
+ // -------------------------------------------------------------------------
658
+ // setDragOver — single write path that keeps state + ref in sync and manages
659
+ // the auto-expand timer. Skips update if nothing changed.
660
+ // Safe to call from rAF callbacks (no setState in side-effects).
661
+ // -------------------------------------------------------------------------
662
+
663
+ const setDragOver = useCallback(
664
+ (next: DragOverData) => {
665
+ const prev = dragOverDataRef.current;
666
+ if (prev.overId === next.overId && prev.position === next.position)
667
+ return;
668
+
669
+ clearAutoExpand();
670
+
671
+ if (next.position === "within" && next.overId !== null) {
672
+ const id = next.overId;
673
+ autoExpandTimer.current = setTimeout(() => tree.expand(id), 600);
674
+ }
675
+
676
+ dragOverDataRef.current = next;
677
+ setDragOverData(next);
678
+ },
679
+ [clearAutoExpand, tree],
680
+ );
681
+
682
+ const refresh = useCallback(async () => {
683
+ const result = (
684
+ await Promise.all([jsx].map((el, i) => generateData(el, i)))
685
+ ).filter(Boolean) as TreeProps["data"];
686
+ setData(result);
687
+ }, [jsx]);
688
+
689
+ useEffect(() => {
690
+ refresh();
691
+ }, [refresh]);
692
+
693
+ // -------------------------------------------------------------------------
694
+ // Sensors
695
+ // -------------------------------------------------------------------------
696
+
697
+ const sensors = useSensors(
698
+ useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
699
+ );
700
+
701
+ // -------------------------------------------------------------------------
702
+ // Drag handlers
703
+ // -------------------------------------------------------------------------
704
+
705
+ const handleDragStart = useCallback(() => {
706
+ setDragOver({ overId: null, position: null });
707
+ }, [setDragOver]);
708
+
709
+ /**
710
+ * WHY onDragMove instead of onDragOver:
711
+ *
712
+ * onDragOver fires only when dnd-kit's collision target changes — i.e. when
713
+ * the pointer crosses from one droppable rect into another. Moving within a
714
+ * single node's row (e.g. from the top-25% "before" zone down into the
715
+ * middle-50% "within" zone) never changes over.id, so onDragOver never fires
716
+ * again and the position indicator freezes. onDragMove fires on every pointer
717
+ * move, so our zone calculation stays live.
718
+ *
719
+ * We throttle to one DOM read per animation frame (~60 fps).
720
+ */
721
+ const handleDragMove = useCallback(
722
+ ({ active, activatorEvent, delta }: DragMoveEvent) => {
723
+ // Snapshot coordinates now — the event object may be reused/pooled.
724
+ const activeId = active.id as string;
725
+ const clientX = (activatorEvent as MouseEvent).clientX + delta.x;
726
+ const clientY = (activatorEvent as MouseEvent).clientY + delta.y;
727
+
728
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
729
+
730
+ rafRef.current = requestAnimationFrame(() => {
731
+ rafRef.current = null;
732
+
733
+ // elementFromPoint skips the dragged element (pointer-events:none)
734
+ // and returns the actual node row underneath.
735
+ const overId = getNodeValueAtPoint(clientX, clientY);
736
+
737
+ if (!overId || overId === activeId) {
738
+ setDragOver({ overId: null, position: null });
739
+ return;
740
+ }
741
+
742
+ const overNode = findNodeByValue(dataRef.current, overId);
743
+ let position = computeDropPosition(overId, clientY, overNode);
744
+
745
+ // If dragging over the root node, force 'within' position
746
+ const isRootNode = dataRef.current?.[0]?.value === overId;
747
+ if (isRootNode && (position === "before" || position === "after")) {
748
+ position = "within";
749
+ }
750
+
751
+ setDragOver({ overId, position });
752
+ });
753
+ },
754
+ [setDragOver],
755
+ );
756
+
757
+ /**
758
+ * Reads dragOverDataRef (always current) rather than event.over.
759
+ * event.over comes from dnd-kit's collision detection, which we've replaced
760
+ * with our elementFromPoint approach via onDragMove — so event.over may
761
+ * be stale or simply wrong for our use case.
762
+ */
763
+ const handleDragEnd = useCallback(
764
+ async ({ active }: DragEndEvent) => {
765
+ if (rafRef.current !== null) {
766
+ cancelAnimationFrame(rafRef.current);
767
+ rafRef.current = null;
768
+ }
769
+ clearAutoExpand();
770
+
771
+ const { overId, position } = dragOverDataRef.current;
772
+
773
+ // Reset visual state before any async work.
774
+ dragOverDataRef.current = { overId: null, position: null };
775
+ setDragOverData({ overId: null, position: null });
776
+
777
+ const activeValue = active.id as string;
778
+
779
+ if (
780
+ !overId ||
781
+ !position ||
782
+ overId === activeValue ||
783
+ !findNodeByValue(dataRef.current, activeValue) ||
784
+ !findNodeByValue(dataRef.current, overId)
785
+ ) {
786
+ return;
787
+ }
788
+
789
+ // Expand immediately so the newly-nested child is visible right away.
790
+ if (position === "within") tree.expand(overId);
791
+
792
+ // Optimistic local update before API round-trip.
793
+ setData((prev) =>
794
+ moveNodeOptimistically(prev, activeValue, overId, position),
795
+ );
796
+
797
+ await FETCH.page.moveNode({
798
+ source: safeParse(activeValue) as FiberNodeType["_debugSource"],
799
+ target: safeParse(overId) as FiberNodeType["_debugSource"],
800
+ position,
801
+ });
802
+ },
803
+ [clearAutoExpand, tree],
804
+ );
805
+
806
+ const handleDragCancel = useCallback(() => {
807
+ if (rafRef.current !== null) {
808
+ cancelAnimationFrame(rafRef.current);
809
+ rafRef.current = null;
810
+ }
811
+ clearAutoExpand();
812
+ dragOverDataRef.current = { overId: null, position: null };
813
+ setDragOverData({ overId: null, position: null });
814
+ }, [clearAutoExpand]);
815
+
816
+ // -------------------------------------------------------------------------
817
+ // Render
818
+ // -------------------------------------------------------------------------
819
+
820
+ const allIds = flattenIds(data);
821
+
822
+ return (
823
+ <Tabs.Panel
824
+ value="tree"
825
+ w={300}
826
+ style={{ overflow: "auto" }}
827
+ onClick={() => {
828
+ tree.clearSelected();
829
+ dispatch({ type: "TREE_SELECT_NODE", args: { selectedNode: null } });
830
+ }}
831
+ >
832
+ <ModalInsert />
833
+ <DndContext
834
+ sensors={sensors}
835
+ collisionDetection={() => []}
836
+ onDragStart={handleDragStart}
837
+ onDragMove={handleDragMove}
838
+ onDragEnd={handleDragEnd}
839
+ onDragCancel={handleDragCancel}
840
+ measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
841
+ >
842
+ <SortableContext items={allIds} strategy={() => null}>
843
+ <Tree
844
+ tree={tree}
845
+ p="md"
846
+ selectOnClick
847
+ data={data}
848
+ onClick={(e) => e.stopPropagation()}
849
+ renderNode={(payload) => (
850
+ <SortableLeaf
851
+ {...(payload as TTreePayload)}
852
+ dragOverData={dragOverData}
853
+ />
854
+ )}
855
+ />
856
+ </SortableContext>
857
+ </DndContext>
858
+ </Tabs.Panel>
859
+ );
860
+ };