diff --git a/package-lock.json b/package-lock.json index 4cfac47..5901cd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { "name": "jsonjoy-builder", - "version": "0.3.1", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jsonjoy-builder", - "version": "0.3.1", + "version": "0.3.2", "license": "MIT", "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.7.9", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", @@ -293,6 +295,27 @@ "node": ">= 10" } }, + "node_modules/@atlaskit/pragmatic-drag-and-drop": { + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.7.9.tgz", + "integrity": "sha512-m/bcw5flyjfcF/rdX4JeomtIBrWuDNOwcQieiywHv7zkfIRmUC34Q9ZLeNGVoz73UiGsRqxysMuw4tC7lSJ89g==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.0.0", + "bind-event-listener": "^3.0.0", + "raf-schd": "^4.0.3" + } + }, + "node_modules/@atlaskit/pragmatic-drag-and-drop-hitbox": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop-hitbox/-/pragmatic-drag-and-drop-hitbox-1.1.0.tgz", + "integrity": "sha512-JWt6eVp6Br2FPHRM8s0dUIHQk/jFInGP1f3ti5CdtM1Ji5/pt8Akm44wDC063Gv2i5RGseixtbW0z/t6RYtbdg==", + "license": "Apache-2.0", + "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.6.0", + "@babel/runtime": "^7.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -324,7 +347,6 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -3529,6 +3551,12 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bind-event-listener": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bind-event-listener/-/bind-event-listener-3.0.0.tgz", + "integrity": "sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==", + "license": "MIT" + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -4573,6 +4601,12 @@ "node": ">=6" } }, + "node_modules/raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==", + "license": "MIT" + }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", diff --git a/package.json b/package.json index 570b22b..7618864 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,8 @@ "prepack": "npm run build" }, "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.7.9", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", diff --git a/src/components/SchemaEditor/DragContext.tsx b/src/components/SchemaEditor/DragContext.tsx new file mode 100644 index 0000000..0f71ed6 --- /dev/null +++ b/src/components/SchemaEditor/DragContext.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from "react"; +import { createContext, useContext, useState } from "react"; + +interface DragStateContextType { + draggingId: string | null; + overId: string | null; + overEdge: "top" | "bottom" | null; + setDragging: (id: string | null) => void; + setOver: (id: string | null, edge: "top" | "bottom" | null) => void; +} + +const DragContext = createContext({ + draggingId: null, + overId: null, + overEdge: null, + setDragging: () => {}, + setOver: () => {}, +}); + +export const useDragContext = () => useContext(DragContext); + +interface DragProviderProps { + children: ReactNode; +} + +export const DragProvider: React.FC = ({ children }) => { + const [draggingId, setDraggingId] = useState(null); + const [overId, setOverId] = useState(null); + const [overEdge, setOverEdge] = useState<"top" | "bottom" | null>(null); + + const setDragging = (id: string | null) => setDraggingId(id); + + const setOver = (id: string | null, edge: "top" | "bottom" | null) => { + setOverId(id); + setOverEdge(edge); + }; + + return ( + + {children} + + ); +}; + +export default DragContext; diff --git a/src/components/SchemaEditor/JsonSchemaEditor.tsx b/src/components/SchemaEditor/JsonSchemaEditor.tsx index 46b868e..62c9e2d 100644 --- a/src/components/SchemaEditor/JsonSchemaEditor.tsx +++ b/src/components/SchemaEditor/JsonSchemaEditor.tsx @@ -14,6 +14,7 @@ import { import { useTranslation } from "../../hooks/use-translation.ts"; import { cn } from "../../lib/utils.ts"; import type { JSONSchema } from "../../types/jsonSchema.ts"; +import { DragProvider } from "./DragContext.tsx"; import JsonSchemaVisualizer from "./JsonSchemaVisualizer.tsx"; import SchemaVisualEditor from "./SchemaVisualEditor.tsx"; @@ -80,116 +81,118 @@ const JsonSchemaEditor: FC = ({ }; return ( -
- {/* For mobile screens - show as tabs */} -
- -
-

{t.schemaEditorTitle}

-
- - - - {t.schemaEditorEditModeVisual} - - - {t.schemaEditorEditModeJson} - - -
-
- - - - - - - - -
-
- - {/* For large screens - show side by side */} +
-
-

{t.schemaEditorTitle}

- + {/* For mobile screens - show as tabs */} +
+ +
+

{t.schemaEditorTitle}

+
+ + + + {t.schemaEditorEditModeVisual} + + + {t.schemaEditorEditModeJson} + + +
+
+ + + + + + + + +
-
-
- + + {/* For large screens - show side by side */} +
+
+

{t.schemaEditorTitle}

+
- {/** biome-ignore lint/a11y/noStaticElementInteractions: What exactly does this div do? */} -
-
- +
+ +
+ {/** biome-ignore lint/a11y/noStaticElementInteractions: What exactly does this div do? */} +
+
+ +
-
+ ); }; diff --git a/src/components/SchemaEditor/SchemaField.tsx b/src/components/SchemaEditor/SchemaField.tsx index 41a881a..e4b721e 100644 --- a/src/components/SchemaEditor/SchemaField.tsx +++ b/src/components/SchemaEditor/SchemaField.tsx @@ -112,6 +112,7 @@ const SchemaField: React.FC = (props) => { onRequiredChange={handleRequiredChange} onSchemaChange={handleSchemaChange} depth={depth} + parentPath={[]} /> ); }; diff --git a/src/components/SchemaEditor/SchemaFieldList.tsx b/src/components/SchemaEditor/SchemaFieldList.tsx index 518d2d2..75b7175 100644 --- a/src/components/SchemaEditor/SchemaFieldList.tsx +++ b/src/components/SchemaEditor/SchemaFieldList.tsx @@ -1,6 +1,12 @@ -import { type FC, useMemo } from "react"; +import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; +import { type FC, useEffect, useMemo } from "react"; import { useTranslation } from "../../hooks/use-translation.ts"; -import { getSchemaProperties } from "../../lib/schemaEditor.ts"; +import { + type FieldDropTarget, + type FieldMoveLocation, + getSchemaProperties, +} from "../../lib/schemaEditor.ts"; import type { JSONSchema as JSONSchemaType, NewField, @@ -8,14 +14,16 @@ import type { SchemaType, } from "../../types/jsonSchema.ts"; import { buildValidationTree } from "../../types/validation.ts"; +import { useDragContext } from "./DragContext.tsx"; import SchemaPropertyEditor from "./SchemaPropertyEditor.tsx"; interface SchemaFieldListProps { schema: JSONSchemaType; readOnly: boolean; - onAddField: (newField: NewField) => void; onEditField: (name: string, updatedField: NewField) => void; onDeleteField: (name: string) => void; + parentPath: string[]; + onFieldDrop?: (source: FieldMoveLocation, target: FieldDropTarget) => void; } const SchemaFieldList: FC = ({ @@ -23,26 +31,21 @@ const SchemaFieldList: FC = ({ onEditField, onDeleteField, readOnly = false, + parentPath, + onFieldDrop, }) => { const t = useTranslation(); + const { setDragging, setOver } = useDragContext(); - // Get the properties from the schema const properties = getSchemaProperties(schema); - // Get schema type as a valid SchemaType const getValidSchemaType = (propSchema: JSONSchemaType): SchemaType => { if (typeof propSchema === "boolean") return "object"; - - // Handle array of types by picking the first one const type = propSchema.type; - if (Array.isArray(type)) { - return type[0] || "object"; - } - + if (Array.isArray(type)) return type[0] || "object"; return type || "object"; }; - // Handle field name change (generates an edit event) const handleNameChange = (oldName: string, newName: string) => { const property = properties.find((prop) => prop.name === oldName); if (!property) return; @@ -62,7 +65,6 @@ const SchemaFieldList: FC = ({ }); }; - // Handle required status change const handleRequiredChange = (name: string, required: boolean) => { const property = properties.find((prop) => prop.name === name); if (!property) return; @@ -82,7 +84,6 @@ const SchemaFieldList: FC = ({ }); }; - // Handle schema change const handleSchemaChange = ( name: string, updatedSchema: ObjectJSONSchema, @@ -91,7 +92,6 @@ const SchemaFieldList: FC = ({ if (!property) return; const type = updatedSchema.type || "object"; - // Ensure we're using a single type, not an array of types const validType = Array.isArray(type) ? type[0] || "object" : type; onEditField(name, { @@ -103,13 +103,72 @@ const SchemaFieldList: FC = ({ }); }; + // Global monitor — drives both the drop indicator and the actual field move. + useEffect(() => { + // Returns the innermost drop target that is not an ancestor of the source. + const resolveTarget = ( + sourceData: { parentPath: string[]; name: string }, + dropTargets: Array<{ data: Record }>, + ) => { + const isAncestor = (td: { parentPath: string[]; name: string }) => { + const prefix = [...td.parentPath, "properties", td.name]; + return ( + sourceData.parentPath.length >= prefix.length && + prefix.every((s, i) => s === sourceData.parentPath[i]) + ); + }; + return ( + dropTargets.find( + (t) => !isAncestor(t.data as { parentPath: string[]; name: string }), + ) ?? null + ); + }; + + return monitorForElements({ + onDragStart: ({ source }) => { + const src = source.data as { parentPath: string[]; name: string }; + setDragging([...src.parentPath, src.name].join("/") || src.name); + }, + onDrag: ({ source, location }) => { + const src = source.data as { parentPath: string[]; name: string }; + const target = resolveTarget(src, location.current.dropTargets); + if (!target) { + setOver(null, null); + return; + } + const td = target.data as { parentPath: string[]; name: string }; + const overId = [...td.parentPath, td.name].join("/") || td.name; + const edge = extractClosestEdge(target.data) as "top" | "bottom" | null; + setOver(overId, edge); + }, + onDrop: ({ source, location }) => { + setDragging(null); + setOver(null, null); + if (!onFieldDrop) return; + const src = source.data as { parentPath: string[]; name: string }; + const target = resolveTarget(src, location.current.dropTargets); + if (!target) return; + const td = target.data as { parentPath: string[]; name: string }; + const edge = extractClosestEdge(target.data); + onFieldDrop( + { parentPath: src.parentPath, name: src.name }, + { + parentPath: td.parentPath, + anchorName: td.name, + position: (edge as "top" | "bottom") ?? "bottom", + }, + ); + }, + }); + }, [onFieldDrop, setDragging, setOver]); + const validationTree = useMemo( () => buildValidationTree(schema, t), [schema, t], ); return ( -
+
{properties.map((property) => ( = ({ } onSchemaChange={(schema) => handleSchemaChange(property.name, schema)} readOnly={readOnly} + parentPath={parentPath} + onFieldDrop={onFieldDrop} /> ))} -
+ ); }; diff --git a/src/components/SchemaEditor/SchemaPropertyEditor.tsx b/src/components/SchemaEditor/SchemaPropertyEditor.tsx index 1e6cf75..9d3c960 100644 --- a/src/components/SchemaEditor/SchemaPropertyEditor.tsx +++ b/src/components/SchemaEditor/SchemaPropertyEditor.tsx @@ -1,7 +1,17 @@ -import { ChevronDown, ChevronRight, X } from "lucide-react"; -import { useEffect, useState } from "react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { + draggable, + dropTargetForElements, +} from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { attachClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; +import { ChevronDown, ChevronRight, GripVertical, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { Input } from "../../components/ui/input.tsx"; import { useTranslation } from "../../hooks/use-translation.ts"; +import type { + FieldDropTarget, + FieldMoveLocation, +} from "../../lib/schemaEditor.ts"; import { cn } from "../../lib/utils.ts"; import type { JSONSchema, @@ -16,8 +26,10 @@ import { import type { ValidationTreeNode } from "../../types/validation.ts"; import { Badge } from "../ui/badge.tsx"; import { ButtonToggle } from "../ui/button-toggle.tsx"; +import { useDragContext } from "./DragContext.tsx"; import TypeDropdown from "./TypeDropdown.tsx"; import TypeEditor from "./TypeEditor.tsx"; + export interface SchemaPropertyEditorProps { name: string; schema: JSONSchema; @@ -28,7 +40,16 @@ export interface SchemaPropertyEditorProps { onNameChange: (newName: string) => void; onRequiredChange: (required: boolean) => void; onSchemaChange: (schema: ObjectJSONSchema) => void; + /** + * Path to the object schema that owns this property. + */ + parentPath: string[]; depth?: number; + /** + * Centralized field drop handler, forwarded down to nested editors so + * they can report drag-and-drop operations back to the visual editor. + */ + onFieldDrop?: (source: FieldMoveLocation, target: FieldDropTarget) => void; } export const SchemaPropertyEditor: React.FC = ({ @@ -41,7 +62,9 @@ export const SchemaPropertyEditor: React.FC = ({ onNameChange, onRequiredChange, onSchemaChange, + parentPath, depth = 0, + onFieldDrop, }) => { const t = useTranslation(); const [expanded, setExpanded] = useState(false); @@ -55,12 +78,75 @@ export const SchemaPropertyEditor: React.FC = ({ "object" as SchemaType, ); + const fieldsetRef = useRef(null); + const handleRef = useRef(null); + // Keep a ref to the latest parentPath/name so callbacks inside the effect + // always read fresh values without needing to re-run the effect. + const propsRef = useRef({ parentPath, name }); + propsRef.current = { parentPath, name }; + + const { draggingId, overId, overEdge } = useDragContext(); + const sortableId = [...parentPath, name].join("/") || name; + + const isDragging = draggingId === sortableId; + const isDragOver = overId === sortableId; + const dropPosition = isDragOver ? overEdge : null; + // Update temp values when props change useEffect(() => { setTempName(name); setTempDesc(getSchemaDescription(schema)); }, [name, schema]); + // Wire up Pragmatic DnD — only re-run when the element identity or readOnly changes. + // All callbacks read from propsRef so they always have fresh parentPath/name. + // Visual state (overId, draggingId) is driven by the global monitor in SchemaFieldList. + // biome-ignore lint/correctness/useExhaustiveDependencies: sortableId is intentionally listed to re-run DnD setup when field identity changes + useEffect(() => { + if (readOnly || !handleRef.current || !fieldsetRef.current) return; + return combine( + draggable({ + element: fieldsetRef.current, + dragHandle: handleRef.current, + getInitialData: () => ({ + parentPath: propsRef.current.parentPath, + name: propsRef.current.name, + }), + }), + dropTargetForElements({ + element: fieldsetRef.current, + canDrop: ({ source }) => { + const src = source.data as { parentPath: string[]; name: string }; + const { parentPath: tp, name: tn } = propsRef.current; + // Prevent dropping onto self + if ( + src.name === tn && + src.parentPath.length === tp.length && + src.parentPath.every((s, i) => s === tp[i]) + ) + return false; + // Prevent dropping onto a descendant of the source + const srcSubtreePrefix = [...src.parentPath, "properties", src.name]; + if ( + tp.length >= srcSubtreePrefix.length && + srcSubtreePrefix.every((s, i) => s === tp[i]) + ) + return false; + return true; + }, + getData: ({ input, element }) => + attachClosestEdge( + { + parentPath: propsRef.current.parentPath, + name: propsRef.current.name, + }, + { element, input, allowedEdges: ["top", "bottom"] }, + ), + }), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [readOnly, sortableId]); + const handleNameSubmit = () => { const trimmedName = tempName.trim(); if (trimmedName && trimmedName !== name) { @@ -94,145 +180,196 @@ export const SchemaPropertyEditor: React.FC = ({ }; return ( -
0 && "ml-0 sm:ml-4 border-l border-l-border/40", +
+ {/* Drop indicator above the item */} + {isDragOver && dropPosition === "top" && ( +