From eb4038391cf197c0af265e153f0ead4a5fd5636e Mon Sep 17 00:00:00 2001 From: Cody Swartz Date: Mon, 19 Jan 2026 22:42:36 -0800 Subject: [PATCH 1/7] Initial drag and drop Still need to patch duplication that occurs when moving an item from a child to another location outside of its current container. --- src/components/SchemaEditor/DragContext.tsx | 68 ++++ .../SchemaEditor/JsonSchemaEditor.tsx | 20 +- .../SchemaEditor/SchemaFieldList.tsx | 202 +++++++++- .../SchemaEditor/SchemaPropertyEditor.tsx | 353 ++++++++++++------ .../SchemaEditor/SchemaVisualEditor.tsx | 1 + .../SchemaEditor/types/ObjectEditor.tsx | 176 ++++++++- src/lib/schemaEditor.ts | 120 ++++++ .../SchemaEditor/SchemaVisualEditor.test.tsx | 49 ++- .../SchemaVisualEditor.test.tsx.snapshot | 4 +- .../SchemaEditor/types/ObjectEditor.test.tsx | 57 +-- .../types/ObjectEditor.test.tsx.snapshot | 4 +- 11 files changed, 871 insertions(+), 183 deletions(-) create mode 100644 src/components/SchemaEditor/DragContext.tsx diff --git a/src/components/SchemaEditor/DragContext.tsx b/src/components/SchemaEditor/DragContext.tsx new file mode 100644 index 0000000..30793cf --- /dev/null +++ b/src/components/SchemaEditor/DragContext.tsx @@ -0,0 +1,68 @@ +import { createContext, useContext, useState } from "react"; +import type { ReactNode } from "react"; + +export interface DraggedItem { + id: string; + sourceContainerId: string; + propertySchema: any; + required: boolean; + /** + * Optional callback that removes this property from its source container. + * Used to implement true "move" semantics when dragging between containers. + */ + removeFromSource?: () => void; +} + +interface DragContextType { + draggedItem: DraggedItem | null; + setDraggedItem: (item: DraggedItem | null) => void; + dragOverItem: string | null; + setDragOverItem: (item: string | null) => void; + dropPosition: "top" | "bottom" | null; + setDropPosition: (position: "top" | "bottom" | null) => void; + clearDragState: () => void; +} + +const DragContext = createContext(undefined); + +export const useDragContext = () => { + const context = useContext(DragContext); + if (!context) { + throw new Error("useDragContext must be used within a DragProvider"); + } + return context; +}; + +interface DragProviderProps { + children: ReactNode; +} + +export const DragProvider: React.FC = ({ children }) => { + const [draggedItem, setDraggedItem] = useState(null); + const [dragOverItem, setDragOverItem] = useState(null); + const [dropPosition, setDropPosition] = useState<"top" | "bottom" | null>(null); + + const clearDragState = () => { + setDraggedItem(null); + setDragOverItem(null); + setDropPosition(null); + }; + + return ( + + {children} + + ); +}; + +export default DragContext; diff --git a/src/components/SchemaEditor/JsonSchemaEditor.tsx b/src/components/SchemaEditor/JsonSchemaEditor.tsx index 46b868e..957f28c 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"; @@ -43,7 +44,6 @@ const JsonSchemaEditor: FC = ({ const [leftPanelWidth, setLeftPanelWidth] = useState(50); // percentage const resizeRef = useRef(null); const containerRef = useRef(null); - const isDraggingRef = useRef(false); const toggleFullscreen = () => { setIsFullscreen(!isFullscreen); @@ -80,14 +80,15 @@ const JsonSchemaEditor: FC = ({ }; return ( -
+ +
{/* For mobile screens - show as tabs */}
@@ -190,6 +191,7 @@ const JsonSchemaEditor: FC = ({
+ ); }; diff --git a/src/components/SchemaEditor/SchemaFieldList.tsx b/src/components/SchemaEditor/SchemaFieldList.tsx index 518d2d2..6f57377 100644 --- a/src/components/SchemaEditor/SchemaFieldList.tsx +++ b/src/components/SchemaEditor/SchemaFieldList.tsx @@ -1,13 +1,22 @@ import { type FC, useMemo } from "react"; import { useTranslation } from "../../hooks/use-translation.ts"; -import { getSchemaProperties } from "../../lib/schemaEditor.ts"; +import { + getSchemaProperties, + moveProperty, + removeObjectProperty, + reorderProperty, + updateObjectProperty, + updatePropertyRequired, +} from "../../lib/schemaEditor.ts"; import type { JSONSchema as JSONSchemaType, NewField, ObjectJSONSchema, SchemaType, } from "../../types/jsonSchema.ts"; +import { asObjectSchema, isBooleanSchema } from "../../types/jsonSchema.ts"; import { buildValidationTree } from "../../types/validation.ts"; +import { useDragContext } from "./DragContext.tsx"; import SchemaPropertyEditor from "./SchemaPropertyEditor.tsx"; interface SchemaFieldListProps { @@ -16,6 +25,7 @@ interface SchemaFieldListProps { onAddField: (newField: NewField) => void; onEditField: (name: string, updatedField: NewField) => void; onDeleteField: (name: string) => void; + onSchemaChange?: (schema: JSONSchemaType) => void; } const SchemaFieldList: FC = ({ @@ -23,8 +33,23 @@ const SchemaFieldList: FC = ({ onEditField, onDeleteField, readOnly = false, + onSchemaChange, }) => { const t = useTranslation(); + const containerId = useMemo( + () => `schema-field-list-${Math.random().toString(36).substr(2, 9)}`, + [], + ); + + const { + draggedItem, + setDraggedItem, + dragOverItem, + setDragOverItem, + dropPosition, + setDropPosition, + clearDragState, + } = useDragContext(); // Get the properties from the schema const properties = getSchemaProperties(schema); @@ -103,13 +128,171 @@ const SchemaFieldList: FC = ({ }); }; + // Handle drag start + const handleDragStart = (_e: React.DragEvent, name: string) => { + const property = properties.find((prop) => prop.name === name); + if (!property) return; + + setDraggedItem({ + id: name, + sourceContainerId: containerId, + propertySchema: property.schema, + required: property.required, + // Enable true move semantics for cross-container drops by providing + // a callback that removes this property from the current schema. + removeFromSource: () => { + if (!onSchemaChange || isBooleanSchema(schema)) return; + const objectSchema = asObjectSchema(schema); + const updated = removeObjectProperty(objectSchema, name); + onSchemaChange(updated); + }, + }); + }; + + // Handle drag over for items + const handleDragOver = (e: React.DragEvent, name: string) => { + e.preventDefault(); + if ( + !draggedItem || + (draggedItem.sourceContainerId === containerId && draggedItem.id === name) + ) { + setDropPosition(null); + return; + } + + setDragOverItem(name); + + // Calculate drop position based on mouse Y position relative to element + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const relativeY = e.clientY - rect.top; + const threshold = rect.height / 2; + + if (relativeY < threshold) { + setDropPosition("top"); + } else { + setDropPosition("bottom"); + } + }; + + // Handle drop + const handleDrop = (e: React.DragEvent, targetName: string | null = null) => { + e.preventDefault(); + if (!draggedItem) { + clearDragState(); + return; + } + + if (onSchemaChange && !isBooleanSchema(schema)) { + const objectSchema = asObjectSchema(schema); + + let newSchema: ObjectJSONSchema; + + // Check if this is a cross-container drop + if (draggedItem.sourceContainerId !== containerId) { + // Cross-container drop: move the property to this container. + // Compute the intended insertion index based on the current + // target name and drop position (top/bottom), which matches + // the visual separator shown in the UI. + const propertyKeys = Object.keys(objectSchema.properties || {}); + const baseIndex = targetName ? propertyKeys.indexOf(targetName) : -1; + const targetIndex = + baseIndex >= 0 + ? baseIndex + (dropPosition === "bottom" ? 1 : 0) + : propertyKeys.length; + + // Generate a unique name for the moved property in this container + let newName = draggedItem.id; + let counter = 1; + while (objectSchema.properties && objectSchema.properties[newName]) { + newName = `${draggedItem.id}_${counter}`; + counter++; + } + + // Add the property to the schema + newSchema = updateObjectProperty( + objectSchema, + newName, + draggedItem.propertySchema, + ); + + // Update required status if needed + if (draggedItem.required) { + newSchema = updatePropertyRequired(newSchema, newName, true); + } + + // Reorder the newly added property so it matches the visual + // drop position indicated by the separator. + newSchema = reorderProperty(newSchema, newName, targetIndex); + + // Finally, remove the field from its original container to + // implement move (not copy) semantics across containers. + draggedItem.removeFromSource?.(); + } else if (targetName) { + // Same container drop: move relative to target item + if (dropPosition === "top") { + // Move before the target item + newSchema = moveProperty( + objectSchema, + draggedItem.id, + targetName, + false, + ); + } else { + // Move after the target item (default) + newSchema = moveProperty( + objectSchema, + draggedItem.id, + targetName, + true, + ); + } + } else { + newSchema = objectSchema; + } + + onSchemaChange(newSchema); + } + + clearDragState(); + }; + + // Handle drag end + const handleDragEnd = () => { + clearDragState(); + }; + + // Handle drag over on the container to allow drops anywhere in the list + const handleContainerDragOver = (e: React.DragEvent) => { + // Only handle drag events that are directly on this container, not + // those bubbled from child elements (like nested object editors). + if (e.target !== e.currentTarget) return; + + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + }; + + // Handle drop on the container using the existing dragOverItem and dropPosition state + const handleContainerDrop = (e: React.DragEvent) => { + // Ignore drops that originated from child elements; those components + // handle their own drag-and-drop behavior. + if (e.target !== e.currentTarget) return; + + // Use the existing dragOverItem and dropPosition state + // This allows dropping in dead zones while respecting the UI state + handleDrop(e, dragOverItem); + }; + const validationTree = useMemo( () => buildValidationTree(schema, t), [schema, t], ); return ( -
+
{properties.map((property) => ( = ({ } onSchemaChange={(schema) => handleSchemaChange(property.name, schema)} readOnly={readOnly} + onDragStart={handleDragStart} + onDragOver={handleDragOver} + onDrop={handleDrop} + onDragEnd={handleDragEnd} + isDragging={ + draggedItem?.id === property.name && + draggedItem.sourceContainerId === containerId + } + isDragOver={dragOverItem === property.name} + dropPosition={ + dragOverItem === property.name && + (dropPosition === "top" || dropPosition === "bottom") + ? dropPosition + : null + } /> ))}
diff --git a/src/components/SchemaEditor/SchemaPropertyEditor.tsx b/src/components/SchemaEditor/SchemaPropertyEditor.tsx index dfe4a2e..af40307 100644 --- a/src/components/SchemaEditor/SchemaPropertyEditor.tsx +++ b/src/components/SchemaEditor/SchemaPropertyEditor.tsx @@ -1,4 +1,4 @@ -import { ChevronDown, ChevronRight, X } from "lucide-react"; +import { ChevronDown, ChevronRight, GripVertical, X } from "lucide-react"; import { useEffect, useState } from "react"; import { Input } from "../../components/ui/input.tsx"; import { useTranslation } from "../../hooks/use-translation.ts"; @@ -18,6 +18,27 @@ import { Badge } from "../ui/badge.tsx"; import TypeDropdown from "./TypeDropdown.tsx"; import TypeEditor from "./TypeEditor.tsx"; +interface DropIndicatorProps { + onDrop: (e: React.DragEvent) => void; + position: "top" | "bottom"; +} + +const DropIndicator: React.FC = ({ onDrop, position }) => ( +
{ + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + }} + onDrop={onDrop} + > +
+
+); + export interface SchemaPropertyEditorProps { name: string; schema: JSONSchema; @@ -29,6 +50,13 @@ export interface SchemaPropertyEditorProps { onRequiredChange: (required: boolean) => void; onSchemaChange: (schema: ObjectJSONSchema) => void; depth?: number; + onDragStart?: (e: React.DragEvent, name: string) => void; + onDragOver?: (e: React.DragEvent, name: string) => void; + onDrop?: (e: React.DragEvent, targetName: string) => void; + onDragEnd?: () => void; + isDragging?: boolean; + isDragOver?: boolean; + dropPosition?: "top" | "bottom" | null; } export const SchemaPropertyEditor: React.FC = ({ @@ -42,6 +70,13 @@ export const SchemaPropertyEditor: React.FC = ({ onRequiredChange, onSchemaChange, depth = 0, + onDragStart, + onDragOver, + onDrop, + onDragEnd, + isDragging = false, + isDragOver = false, + dropPosition = null, }) => { const t = useTranslation(); const [expanded, setExpanded] = useState(false); @@ -61,6 +96,39 @@ export const SchemaPropertyEditor: React.FC = ({ setTempDesc(getSchemaDescription(schema)); }, [name, schema]); + // Handle drag start + const handleDragStart = (e: React.DragEvent) => { + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", name); + onDragStart?.(e, name); + }; + + // Handle drag over + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + // Prevent parent containers or ancestor field editors from also handling + // this drag-over event. Only the innermost field under the cursor should + // control drag state (drop indicators, dragOverItem, etc.). + e.stopPropagation(); + e.dataTransfer.dropEffect = "move"; + onDragOver?.(e, name); + }; + + // Handle drop + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + // Ensure only the intended container/field processes this drop. + // Without this, ancestor field editors (e.g. top-level fields) would + // also run their drop logic, causing duplicates at higher levels. + e.stopPropagation(); + onDrop?.(e, name); + }; + + // Handle drag end + const handleDragEnd = () => { + onDragEnd?.(); + }; + const handleNameSubmit = () => { const trimmedName = tempName.trim(); if (trimmedName && trimmedName !== name) { @@ -94,148 +162,187 @@ 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" && ( + )} - > -
-
- {/* Expand/collapse button */} - - - {/* Property name */} -
-
- {!readOnly && isEditingName ? ( - setTempName(e.target.value)} - onBlur={handleNameSubmit} - onKeyDown={(e) => e.key === "Enter" && handleNameSubmit()} - className="h-8 text-sm font-medium min-w-[120px] max-w-full z-10" - autoFocus - onFocus={(e) => e.target.select()} - /> + +
0 && "ml-0 sm:ml-4 border-l border-l-border/40", + isDragging && "opacity-50", + isDragOver && "border-primary ring-2 ring-primary/20", + )} + > +
+
+ {/* Drag handle */} + {!readOnly && ( + + )} + + {/* Expand/collapse button */} + + )} + + + {/* Property name */} +
+
+ {!readOnly && isEditingName ? ( + setTempName(e.target.value)} + onBlur={handleNameSubmit} + onKeyDown={(e) => e.key === "Enter" && handleNameSubmit()} + className="h-8 text-sm font-medium min-w-[120px] max-w-full z-10" + autoFocus + onFocus={(e) => e.target.select()} + /> + ) : ( + + )} - {/* Description */} - {!readOnly && isEditingDesc ? ( - setTempDesc(e.target.value)} - onBlur={handleDescSubmit} - onKeyDown={(e) => e.key === "Enter" && handleDescSubmit()} - placeholder={t.propertyDescriptionPlaceholder} - className="h-8 text-xs text-muted-foreground italic flex-1 min-w-[150px] z-10" - autoFocus - onFocus={(e) => e.target.select()} + {/* Description */} + {!readOnly && isEditingDesc ? ( + setTempDesc(e.target.value)} + onBlur={handleDescSubmit} + onKeyDown={(e) => e.key === "Enter" && handleDescSubmit()} + placeholder={t.propertyDescriptionPlaceholder} + className="h-8 text-xs text-muted-foreground italic flex-1 min-w-[150px] z-10" + autoFocus + onFocus={(e) => e.target.select()} + /> + ) : tempDesc ? ( + + ) : ( + + )} +
+ + {/* Type display */} +
+ { + onSchemaChange({ + ...asObjectSchema(schema), + type: newType, + }); + }} /> - ) : tempDesc ? ( - - ) : ( + + {/* Required toggle */} - )} +
+
+ + {/* Error badge */} + {validationNode?.cumulativeChildrenErrors > 0 && ( + + {validationNode.cumulativeChildrenErrors} + + )} - {/* Type display */} -
- { - onSchemaChange({ - ...asObjectSchema(schema), - type: newType, - }); - }} - /> - - {/* Required toggle */} + {/* Delete button */} + {!readOnly && ( +
-
+ )}
- {/* Error badge */} - {validationNode?.cumulativeChildrenErrors > 0 && ( - - {validationNode.cumulativeChildrenErrors} - - )} - - {/* Delete button */} - {!readOnly && ( -
- + {/* Type-specific editor */} + {expanded && ( +
+ {readOnly && tempDesc &&

{tempDesc}

} +
)} -
- - {/* Type-specific editor */} - {expanded && ( -
- {readOnly && tempDesc &&

{tempDesc}

} - -
+
+ {/* Drop indicator below the item */} + {isDragOver && dropPosition === "bottom" && ( + )}
); diff --git a/src/components/SchemaEditor/SchemaVisualEditor.tsx b/src/components/SchemaEditor/SchemaVisualEditor.tsx index 36a069a..c7ec62c 100644 --- a/src/components/SchemaEditor/SchemaVisualEditor.tsx +++ b/src/components/SchemaEditor/SchemaVisualEditor.tsx @@ -124,6 +124,7 @@ const SchemaVisualEditor: FC = ({ onAddField={handleAddField} onEditField={handleEditField} onDeleteField={handleDeleteField} + onSchemaChange={onChange} /> )}
diff --git a/src/components/SchemaEditor/types/ObjectEditor.tsx b/src/components/SchemaEditor/types/ObjectEditor.tsx index 64d0cde..bf99ca2 100644 --- a/src/components/SchemaEditor/types/ObjectEditor.tsx +++ b/src/components/SchemaEditor/types/ObjectEditor.tsx @@ -1,15 +1,19 @@ +import { useMemo } from "react"; import { useTranslation } from "../../../hooks/use-translation.ts"; import { getSchemaProperties, + moveProperty, removeObjectProperty, updateObjectProperty, updatePropertyRequired, + reorderProperty, } from "../../../lib/schemaEditor.ts"; import type { NewField, ObjectJSONSchema } from "../../../types/jsonSchema.ts"; import { asObjectSchema, isBooleanSchema } from "../../../types/jsonSchema.ts"; import AddFieldButton from "../AddFieldButton.tsx"; import SchemaPropertyEditor from "../SchemaPropertyEditor.tsx"; import type { TypeEditorProps } from "../TypeEditor.tsx"; +import { useDragContext } from "../DragContext.tsx"; const ObjectEditor: React.FC = ({ schema, @@ -19,6 +23,17 @@ const ObjectEditor: React.FC = ({ readOnly = false, }) => { const t = useTranslation(); + const containerId = useMemo(() => `object-editor-${depth}-${Math.random().toString(36).substr(2, 9)}`, [depth]); + + const { + draggedItem, + setDraggedItem, + dragOverItem, + setDragOverItem, + dropPosition, + setDropPosition, + clearDragState, + } = useDragContext(); // Get object properties const properties = getSchemaProperties(schema); @@ -109,10 +124,157 @@ const ObjectEditor: React.FC = ({ onChange(newSchema); }; + // Handle drag start + const handleDragStart = (_e: React.DragEvent, name: string) => { + const property = properties.find((p) => p.name === name); + if (!property) return; + + setDraggedItem({ + id: name, + sourceContainerId: containerId, + propertySchema: property.schema, + required: property.required, + // Allow moving this field out into another container by providing + // a callback that removes it from this object schema. + removeFromSource: () => { + const updated = removeObjectProperty(normalizedSchema, name); + onChange(updated); + }, + }); + }; + + // Handle drag over for items + const handleDragOver = (e: React.DragEvent, name: string) => { + e.preventDefault(); + if (!draggedItem || (draggedItem.sourceContainerId === containerId && draggedItem.id === name)) { + setDropPosition(null); + return; + } + + setDragOverItem(name); + + // Calculate drop position based on mouse Y position relative to element + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const relativeY = e.clientY - rect.top; + const threshold = rect.height / 2; + + if (relativeY < threshold) { + setDropPosition("top"); + } else { + setDropPosition("bottom"); + } + }; + + // Handle drop + const handleDrop = (e: React.DragEvent, targetName: string | null = null) => { + e.preventDefault(); + if (!draggedItem) { + clearDragState(); + return; + } + + let newSchema: ObjectJSONSchema; + + // Check if this is a cross-container drop + if (draggedItem.sourceContainerId !== containerId) { + // Cross-container drop: move the property to this object. + // Compute the intended insertion index from the target name and + // drop position so it matches the visual separator. + const propertyKeys = Object.keys(normalizedSchema.properties || {}); + const baseIndex = targetName ? propertyKeys.indexOf(targetName) : -1; + const targetIndex = + baseIndex >= 0 + ? baseIndex + (dropPosition === "bottom" ? 1 : 0) + : propertyKeys.length; + + // Generate a unique name for the moved property in this object + let newName = draggedItem.id; + let counter = 1; + while (normalizedSchema.properties && normalizedSchema.properties[newName]) { + newName = `${draggedItem.id}_${counter}`; + counter++; + } + + // Add the property to the schema + newSchema = updateObjectProperty( + normalizedSchema, + newName, + draggedItem.propertySchema, + ); + + // Update required status if needed + if (draggedItem.required) { + newSchema = updatePropertyRequired(newSchema, newName, true); + } + + // Reorder the newly added property into the intended position + // indicated by the separator. + newSchema = reorderProperty(newSchema, newName, targetIndex); + + // Remove the field from its original container to complete the move. + draggedItem.removeFromSource?.(); + } else if (targetName) { + // Same container drop: move relative to target item + if (dropPosition === "top") { + // Move before the target item + newSchema = moveProperty( + normalizedSchema, + draggedItem.id, + targetName, + false, + ); + } else { + // Move after the target item (default) + newSchema = moveProperty( + normalizedSchema, + draggedItem.id, + targetName, + true, + ); + } + } else { + newSchema = normalizedSchema; + } + + onChange(newSchema); + + clearDragState(); + }; + + // Handle drag end + const handleDragEnd = () => { + clearDragState(); + }; + + // Handle drag over on the container to allow drops anywhere in the list + const handleContainerDragOver = (e: React.DragEvent) => { + // Only handle drag events that are directly on this container, not + // those bubbled from child elements (like deeper nested editors). + if (e.target !== e.currentTarget) return; + + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + }; + + // Handle drop on the container using the existing dragOverItem and dropPosition state + const handleContainerDrop = (e: React.DragEvent) => { + // Ignore drops that originated from child elements; those components + // handle their own drag-and-drop behavior. + if (e.target !== e.currentTarget) return; + + // Use the existing dragOverItem and dropPosition state + // This allows dropping in dead zones while respecting the UI state + handleDrop(e, dragOverItem); + }; + return (
{properties.length > 0 ? ( -
+
{properties.map((property) => ( = ({ handlePropertySchemaChange(property.name, schema) } depth={depth} + onDragStart={handleDragStart} + onDragOver={handleDragOver} + onDrop={handleDrop} + onDragEnd={handleDragEnd} + isDragging={draggedItem?.id === property.name && draggedItem.sourceContainerId === containerId} + isDragOver={dragOverItem === property.name} + dropPosition={ + dragOverItem === property.name && + (dropPosition === "top" || dropPosition === "bottom") + ? dropPosition + : null + } /> ))}
diff --git a/src/lib/schemaEditor.ts b/src/lib/schemaEditor.ts index 14f7685..28d35ad 100644 --- a/src/lib/schemaEditor.ts +++ b/src/lib/schemaEditor.ts @@ -207,3 +207,123 @@ export function hasChildren(schema: JSONSchema): boolean { return false; } + +/** + * Reorders properties in an object schema by moving one property to a new position + */ +export function reorderProperty( + schema: ObjectJSONSchema, + propertyName: string, + newIndex: number, +): ObjectJSONSchema { + if (!isObjectSchema(schema) || !schema.properties) return schema; + + const propertyNames = Object.keys(schema.properties); + const currentIndex = propertyNames.indexOf(propertyName); + + if (currentIndex === -1 || currentIndex === newIndex) return schema; + + const newSchema = copySchema(schema); + const newProperties: Record = {}; + + // Remove the property from its current position + const [movedName] = propertyNames.splice(currentIndex, 1); + + // Insert at the new position + propertyNames.splice(newIndex, 0, movedName); + + // Rebuild properties object in the new order + for (const name of propertyNames) { + newProperties[name] = newSchema.properties?.[name]; + } + + newSchema.properties = newProperties; + + return newSchema; +} + +/** + * Moves a property from one position to another in an object schema + */ +export function moveProperty( + schema: ObjectJSONSchema, + fromName: string, + toName: string, + after: boolean = true, +): ObjectJSONSchema { + if (!isObjectSchema(schema) || !schema.properties) return schema; + + const propertyNames = Object.keys(schema.properties); + const fromIndex = propertyNames.indexOf(fromName); + const toIndex = propertyNames.indexOf(toName); + + if (fromIndex === -1 || toIndex === -1) return schema; + + let newIndex = after ? toIndex + 1 : toIndex; + + // Adjust if moving forward + if (fromIndex < newIndex) { + newIndex--; + } + + return reorderProperty(schema, fromName, newIndex); +} + +/** + * Moves a property from one schema to another + */ +export function movePropertyBetweenSchemas( + sourceSchema: ObjectJSONSchema, + targetSchema: ObjectJSONSchema, + propertyName: string, + newName: string, + targetIndex: number, +): { + updatedSource: ObjectJSONSchema; + updatedTarget: ObjectJSONSchema; +} { + if (!isObjectSchema(sourceSchema) || !sourceSchema.properties) { + return { updatedSource: sourceSchema, updatedTarget: targetSchema }; + } + + if (!isObjectSchema(targetSchema)) { + targetSchema = { type: "object", properties: {} }; + } + + if (!targetSchema.properties) { + targetSchema.properties = {}; + } + + // Get the property from source + const property = sourceSchema.properties[propertyName]; + const required = sourceSchema.required?.includes(propertyName); + + // Remove from source + const updatedSource = removeObjectProperty(sourceSchema, propertyName); + + // Insert into target at the specified position + const targetPropertyNames = Object.keys(targetSchema.properties); + const newProperties: Record = {}; + + // Add properties before the target index + for (let i = 0; i < targetIndex && i < targetPropertyNames.length; i++) { + newProperties[targetPropertyNames[i]] = targetSchema.properties[targetPropertyNames[i]]; + } + + // Add the moved property + newProperties[newName] = property; + + // Add properties after the target index + for (let i = targetIndex; i < targetPropertyNames.length; i++) { + newProperties[targetPropertyNames[i]] = targetSchema.properties[targetPropertyNames[i]]; + } + + const updatedTarget = { ...targetSchema, properties: newProperties }; + + // Update required status in target if needed + if (required) { + updatedTarget.required = [...(updatedTarget.required || []), newName]; + } + + return { updatedSource, updatedTarget }; +} diff --git a/test/components/SchemaEditor/SchemaVisualEditor.test.tsx b/test/components/SchemaEditor/SchemaVisualEditor.test.tsx index 28065dd..40b07ce 100644 --- a/test/components/SchemaEditor/SchemaVisualEditor.test.tsx +++ b/test/components/SchemaEditor/SchemaVisualEditor.test.tsx @@ -3,36 +3,45 @@ import "global-jsdom/register"; import { describe, test } from "node:test"; import React from "react"; import SchemaVisualEditor from "../../../src/components/SchemaEditor/SchemaVisualEditor.tsx"; +import { DragProvider } from "../../../src/components/SchemaEditor/DragContext.tsx"; describe("SchemaVisualEditor", () => { test("write mode does show constraints", (t) => { - const element = React.createElement(SchemaVisualEditor, { - readOnly: false, - onChange: () => {}, - schema: { - type: "object", - properties: { - name: { - type: "string", + const element = React.createElement( + DragProvider, + null, + React.createElement(SchemaVisualEditor, { + readOnly: false, + onChange: () => {}, + schema: { + type: "object", + properties: { + name: { + type: "string", + }, }, }, - }, - }); + }), + ); t.assert.snapshot(render(element).container.innerHTML); }); test("read-only mode doesn't show constraints", (t) => { - const element = React.createElement(SchemaVisualEditor, { - readOnly: true, - onChange: () => {}, - schema: { - type: "object", - properties: { - name: { - type: "string", + const element = React.createElement( + DragProvider, + null, + React.createElement(SchemaVisualEditor, { + readOnly: true, + onChange: () => {}, + schema: { + type: "object", + properties: { + name: { + type: "string", + }, }, }, - }, - }); + }), + ); t.assert.snapshot(render(element).container.innerHTML); }); }); diff --git a/test/components/SchemaEditor/SchemaVisualEditor.test.tsx.snapshot b/test/components/SchemaEditor/SchemaVisualEditor.test.tsx.snapshot index 27a474a..c3aaf9c 100644 --- a/test/components/SchemaEditor/SchemaVisualEditor.test.tsx.snapshot +++ b/test/components/SchemaEditor/SchemaVisualEditor.test.tsx.snapshot @@ -1,7 +1,7 @@ exports[`SchemaVisualEditor > read-only mode doesn't show constraints 1`] = ` -"
" +"
" `; exports[`SchemaVisualEditor > write mode does show constraints 1`] = ` -"
" +"
" `; diff --git a/test/components/SchemaEditor/types/ObjectEditor.test.tsx b/test/components/SchemaEditor/types/ObjectEditor.test.tsx index 9743a2e..1e538e6 100644 --- a/test/components/SchemaEditor/types/ObjectEditor.test.tsx +++ b/test/components/SchemaEditor/types/ObjectEditor.test.tsx @@ -3,40 +3,49 @@ import "global-jsdom/register"; import { describe, test } from "node:test"; import React from "react"; import ObjectEditor from "../../../../src/components/SchemaEditor/types/ObjectEditor.tsx"; +import { DragProvider } from "../../../../src/components/SchemaEditor/DragContext.tsx"; describe("ObjectEditor", () => { test("write mode does show constraints", (t) => { - const element = React.createElement(ObjectEditor, { - readOnly: false, - onChange: () => {}, - depth: 0, - validationNode: undefined, - schema: { - type: "object", - properties: { - name: { - type: "string", + const element = React.createElement( + DragProvider, + null, + React.createElement(ObjectEditor, { + readOnly: false, + onChange: () => {}, + depth: 0, + validationNode: undefined, + schema: { + type: "object", + properties: { + name: { + type: "string", + }, }, }, - }, - }); + }), + ); t.assert.snapshot(render(element).container.innerHTML); }); test("read-only mode doesn't show constraints", (t) => { - const element = React.createElement(ObjectEditor, { - readOnly: true, - onChange: () => {}, - depth: 0, - validationNode: undefined, - schema: { - type: "object", - properties: { - name: { - type: "string", + const element = React.createElement( + DragProvider, + null, + React.createElement(ObjectEditor, { + readOnly: true, + onChange: () => {}, + depth: 0, + validationNode: undefined, + schema: { + type: "object", + properties: { + name: { + type: "string", + }, }, }, - }, - }); + }), + ); t.assert.snapshot(render(element).container.innerHTML); }); }); diff --git a/test/components/SchemaEditor/types/ObjectEditor.test.tsx.snapshot b/test/components/SchemaEditor/types/ObjectEditor.test.tsx.snapshot index 881eef1..14b7ad7 100644 --- a/test/components/SchemaEditor/types/ObjectEditor.test.tsx.snapshot +++ b/test/components/SchemaEditor/types/ObjectEditor.test.tsx.snapshot @@ -1,7 +1,7 @@ exports[`ObjectEditor > read-only mode doesn't show constraints 1`] = ` -"
" +"
" `; exports[`ObjectEditor > write mode does show constraints 1`] = ` -"
" +"
" `; From 1d3d73aa87139922a8f1a399f0bdcab25a231e3e Mon Sep 17 00:00:00 2001 From: Cody Swartz Date: Tue, 20 Jan 2026 00:14:05 -0800 Subject: [PATCH 2/7] Handle child to parent drag and drop, as well as visa versa There were two write operations happening at the same time which made it so that the inner child node would duplicate. This had to do some consolidation to get them into a single operation. --- src/components/SchemaEditor/DragContext.tsx | 35 +++- .../SchemaEditor/SchemaFieldList.tsx | 146 +++++-------- .../SchemaEditor/SchemaPropertyEditor.tsx | 19 ++ .../SchemaEditor/SchemaVisualEditor.tsx | 16 +- src/components/SchemaEditor/TypeEditor.tsx | 17 ++ .../SchemaEditor/types/ArrayEditor.tsx | 4 + .../SchemaEditor/types/ObjectEditor.tsx | 163 +++++---------- src/lib/schemaEditor.ts | 195 +++++++++++++++++- .../SchemaVisualEditor.test.tsx.snapshot | 4 +- .../SchemaEditor/types/ArrayEditor.test.tsx | 4 + .../SchemaEditor/types/ObjectEditor.test.tsx | 4 + .../types/ObjectEditor.test.tsx.snapshot | 4 +- 12 files changed, 392 insertions(+), 219 deletions(-) diff --git a/src/components/SchemaEditor/DragContext.tsx b/src/components/SchemaEditor/DragContext.tsx index 30793cf..04718f7 100644 --- a/src/components/SchemaEditor/DragContext.tsx +++ b/src/components/SchemaEditor/DragContext.tsx @@ -1,16 +1,35 @@ -import { createContext, useContext, useState } from "react"; import type { ReactNode } from "react"; +import { createContext, useContext, useState } from "react"; +import type { JSONSchema } from "../../types/jsonSchema.ts"; export interface DraggedItem { + /** + * The property name being dragged. + */ id: string; + /** + * Path (from the root schema of the visual editor) to the object schema + * that currently owns this property. This is the container whose + * `properties` collection includes `id`. + * + * For example, the root object has path [], and a nested object field + * `address` would live at ["properties", "address"]. + */ + parentPath: string[]; + /** + * Original container identifier, still used for quick equality checks in + * the UI (e.g. highlighting the dragged row). This is purely a view-level + * identifier and not used for schema updates anymore. + */ sourceContainerId: string; - propertySchema: any; - required: boolean; /** - * Optional callback that removes this property from its source container. - * Used to implement true "move" semantics when dragging between containers. + * The JSON Schema for the dragged property. + */ + propertySchema: JSONSchema; + /** + * Whether the dragged property is required in its source container. */ - removeFromSource?: () => void; + required: boolean; } interface DragContextType { @@ -40,7 +59,9 @@ interface DragProviderProps { export const DragProvider: React.FC = ({ children }) => { const [draggedItem, setDraggedItem] = useState(null); const [dragOverItem, setDragOverItem] = useState(null); - const [dropPosition, setDropPosition] = useState<"top" | "bottom" | null>(null); + const [dropPosition, setDropPosition] = useState<"top" | "bottom" | null>( + null, + ); const clearDragState = () => { setDraggedItem(null); diff --git a/src/components/SchemaEditor/SchemaFieldList.tsx b/src/components/SchemaEditor/SchemaFieldList.tsx index 6f57377..88c5668 100644 --- a/src/components/SchemaEditor/SchemaFieldList.tsx +++ b/src/components/SchemaEditor/SchemaFieldList.tsx @@ -1,12 +1,9 @@ -import { type FC, useMemo } from "react"; +import { useMemo, type FC } from "react"; import { useTranslation } from "../../hooks/use-translation.ts"; import { getSchemaProperties, - moveProperty, - removeObjectProperty, - reorderProperty, - updateObjectProperty, - updatePropertyRequired, + type FieldDropTarget, + type FieldMoveLocation, } from "../../lib/schemaEditor.ts"; import type { JSONSchema as JSONSchemaType, @@ -14,7 +11,6 @@ import type { ObjectJSONSchema, SchemaType, } from "../../types/jsonSchema.ts"; -import { asObjectSchema, isBooleanSchema } from "../../types/jsonSchema.ts"; import { buildValidationTree } from "../../types/validation.ts"; import { useDragContext } from "./DragContext.tsx"; import SchemaPropertyEditor from "./SchemaPropertyEditor.tsx"; @@ -22,10 +18,10 @@ 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; - onSchemaChange?: (schema: JSONSchemaType) => void; + parentPath: string[]; + onFieldDrop?: (source: FieldMoveLocation, target: FieldDropTarget) => void; } const SchemaFieldList: FC = ({ @@ -33,13 +29,14 @@ const SchemaFieldList: FC = ({ onEditField, onDeleteField, readOnly = false, - onSchemaChange, + parentPath, + onFieldDrop, }) => { const t = useTranslation(); - const containerId = useMemo( - () => `schema-field-list-${Math.random().toString(36).substr(2, 9)}`, - [], - ); + const containerId = useMemo(() => { + const uniqueId = crypto.randomUUID(); + return `schema-field-list-${uniqueId}`; + }, []); const { draggedItem, @@ -135,17 +132,10 @@ const SchemaFieldList: FC = ({ setDraggedItem({ id: name, + parentPath, sourceContainerId: containerId, propertySchema: property.schema, required: property.required, - // Enable true move semantics for cross-container drops by providing - // a callback that removes this property from the current schema. - removeFromSource: () => { - if (!onSchemaChange || isBooleanSchema(schema)) return; - const objectSchema = asObjectSchema(schema); - const updated = removeObjectProperty(objectSchema, name); - onSchemaChange(updated); - }, }); }; @@ -174,85 +164,26 @@ const SchemaFieldList: FC = ({ } }; - // Handle drop + // Handle drop – delegate to the centralized handler in the visual editor const handleDrop = (e: React.DragEvent, targetName: string | null = null) => { e.preventDefault(); - if (!draggedItem) { + if (!draggedItem || !onFieldDrop) { clearDragState(); return; } - if (onSchemaChange && !isBooleanSchema(schema)) { - const objectSchema = asObjectSchema(schema); - - let newSchema: ObjectJSONSchema; - - // Check if this is a cross-container drop - if (draggedItem.sourceContainerId !== containerId) { - // Cross-container drop: move the property to this container. - // Compute the intended insertion index based on the current - // target name and drop position (top/bottom), which matches - // the visual separator shown in the UI. - const propertyKeys = Object.keys(objectSchema.properties || {}); - const baseIndex = targetName ? propertyKeys.indexOf(targetName) : -1; - const targetIndex = - baseIndex >= 0 - ? baseIndex + (dropPosition === "bottom" ? 1 : 0) - : propertyKeys.length; - - // Generate a unique name for the moved property in this container - let newName = draggedItem.id; - let counter = 1; - while (objectSchema.properties && objectSchema.properties[newName]) { - newName = `${draggedItem.id}_${counter}`; - counter++; - } - - // Add the property to the schema - newSchema = updateObjectProperty( - objectSchema, - newName, - draggedItem.propertySchema, - ); - - // Update required status if needed - if (draggedItem.required) { - newSchema = updatePropertyRequired(newSchema, newName, true); - } - - // Reorder the newly added property so it matches the visual - // drop position indicated by the separator. - newSchema = reorderProperty(newSchema, newName, targetIndex); - - // Finally, remove the field from its original container to - // implement move (not copy) semantics across containers. - draggedItem.removeFromSource?.(); - } else if (targetName) { - // Same container drop: move relative to target item - if (dropPosition === "top") { - // Move before the target item - newSchema = moveProperty( - objectSchema, - draggedItem.id, - targetName, - false, - ); - } else { - // Move after the target item (default) - newSchema = moveProperty( - objectSchema, - draggedItem.id, - targetName, - true, - ); - } - } else { - newSchema = objectSchema; - } - - onSchemaChange(newSchema); - } + const source: FieldMoveLocation = { + parentPath: draggedItem.parentPath, + name: draggedItem.id, + }; + + const target: FieldDropTarget = { + parentPath, + anchorName: targetName ?? dragOverItem, + position: dropPosition, + }; + onFieldDrop(source, target); clearDragState(); }; @@ -271,6 +202,23 @@ const SchemaFieldList: FC = ({ e.dataTransfer.dropEffect = "move"; }; + // Handle keyboard events for accessibility + const handleKeyDown = (e: React.KeyboardEvent) => { + // Escape key cancels any active drag operation + if (e.key === "Escape" && draggedItem) { + clearDragState(); + e.preventDefault(); + } + }; + + // Handle focus events for accessibility + const handleFocus = () => { + // When container receives focus, announce its purpose + if (draggedItem) { + // Container is a valid drop target + } + }; + // Handle drop on the container using the existing dragOverItem and dropPosition state const handleContainerDrop = (e: React.DragEvent) => { // Ignore drops that originated from child elements; those components @@ -288,8 +236,12 @@ const SchemaFieldList: FC = ({ ); return ( -
@@ -307,10 +259,12 @@ const SchemaFieldList: FC = ({ } onSchemaChange={(schema) => handleSchemaChange(property.name, schema)} readOnly={readOnly} + parentPath={parentPath} onDragStart={handleDragStart} onDragOver={handleDragOver} onDrop={handleDrop} onDragEnd={handleDragEnd} + onFieldDrop={onFieldDrop} isDragging={ draggedItem?.id === property.name && draggedItem.sourceContainerId === containerId @@ -324,7 +278,7 @@ const SchemaFieldList: FC = ({ } /> ))} -
+ ); }; diff --git a/src/components/SchemaEditor/SchemaPropertyEditor.tsx b/src/components/SchemaEditor/SchemaPropertyEditor.tsx index af40307..9064a28 100644 --- a/src/components/SchemaEditor/SchemaPropertyEditor.tsx +++ b/src/components/SchemaEditor/SchemaPropertyEditor.tsx @@ -8,6 +8,10 @@ import type { ObjectJSONSchema, SchemaType, } from "../../types/jsonSchema.ts"; +import type { + FieldDropTarget, + FieldMoveLocation, +} from "../../lib/schemaEditor.ts"; import { asObjectSchema, getSchemaDescription, @@ -25,6 +29,8 @@ interface DropIndicatorProps { const DropIndicator: React.FC = ({ onDrop, position }) => ( )} diff --git a/src/components/SchemaEditor/SchemaVisualEditor.tsx b/src/components/SchemaEditor/SchemaVisualEditor.tsx index c7ec62c..4ced715 100644 --- a/src/components/SchemaEditor/SchemaVisualEditor.tsx +++ b/src/components/SchemaEditor/SchemaVisualEditor.tsx @@ -5,6 +5,9 @@ import { updateObjectProperty, updatePropertyRequired, renameObjectProperty, + moveFieldInSchema, + type FieldDropTarget, + type FieldMoveLocation, } from "../../lib/schemaEditor.ts"; import type { JSONSchema, NewField } from "../../types/jsonSchema.ts"; import { asObjectSchema, isBooleanSchema } from "../../types/jsonSchema.ts"; @@ -46,7 +49,7 @@ const SchemaVisualEditor: FC = ({ onChange(newSchema); }; - // Handle editing a top-level field +// Handle editing a top-level field const handleEditField = (name: string, updatedField: NewField) => { // Create a field schema based on the updated field data const fieldSchema = createFieldSchema(updatedField); @@ -103,6 +106,15 @@ const SchemaVisualEditor: FC = ({ schema.properties && Object.keys(schema.properties).length > 0; + const handleFieldDrop = ( + source: FieldMoveLocation, + target: FieldDropTarget, + ) => { + if (!onChange) return; + const updated = moveFieldInSchema(schema, source, target); + onChange(updated); + }; + return (
{!readOnly && ( @@ -125,6 +137,8 @@ const SchemaVisualEditor: FC = ({ onEditField={handleEditField} onDeleteField={handleDeleteField} onSchemaChange={onChange} + parentPath={[]} + onFieldDrop={handleFieldDrop} /> )}
diff --git a/src/components/SchemaEditor/TypeEditor.tsx b/src/components/SchemaEditor/TypeEditor.tsx index 62b349f..0d9eb17 100644 --- a/src/components/SchemaEditor/TypeEditor.tsx +++ b/src/components/SchemaEditor/TypeEditor.tsx @@ -21,6 +21,17 @@ export interface TypeEditorProps { validationNode: ValidationTreeNode | undefined; onChange: (schema: ObjectJSONSchema) => void; depth?: number; + /** + * Path (from the root visual-editor schema) to this type's schema node. + */ + path: string[]; + /** + * Centralized field drop handler forwarded down to object/field editors. + */ + onFieldDrop?: ( + source: import("../../lib/schemaEditor.ts").FieldMoveLocation, + target: import("../../lib/schemaEditor.ts").FieldDropTarget, + ) => void; } const TypeEditor: React.FC = ({ @@ -29,6 +40,8 @@ const TypeEditor: React.FC = ({ onChange, depth = 0, readOnly = false, + path, + onFieldDrop, }) => { const t = useTranslation(); const type = withObjectSchema( @@ -83,6 +96,8 @@ const TypeEditor: React.FC = ({ onChange={onChange} depth={depth} validationNode={validationNode} + path={path} + onFieldDrop={onFieldDrop} /> )} {type === "array" && ( @@ -92,6 +107,8 @@ const TypeEditor: React.FC = ({ onChange={onChange} depth={depth} validationNode={validationNode} + path={path} + onFieldDrop={onFieldDrop} /> )} diff --git a/src/components/SchemaEditor/types/ArrayEditor.tsx b/src/components/SchemaEditor/types/ArrayEditor.tsx index 15953e6..9305600 100644 --- a/src/components/SchemaEditor/types/ArrayEditor.tsx +++ b/src/components/SchemaEditor/types/ArrayEditor.tsx @@ -23,6 +23,8 @@ const ArrayEditor: React.FC = ({ validationNode, onChange, depth = 0, + path, + onFieldDrop, }) => { const t = useTranslation(); const [minItems, setMinItems] = useState( @@ -248,6 +250,8 @@ const ArrayEditor: React.FC = ({ validationNode={validationNode} onChange={handleItemSchemaChange} depth={depth + 1} + path={[...path, "items"]} + onFieldDrop={onFieldDrop} />
diff --git a/src/components/SchemaEditor/types/ObjectEditor.tsx b/src/components/SchemaEditor/types/ObjectEditor.tsx index bf99ca2..e6cc012 100644 --- a/src/components/SchemaEditor/types/ObjectEditor.tsx +++ b/src/components/SchemaEditor/types/ObjectEditor.tsx @@ -2,11 +2,8 @@ import { useMemo } from "react"; import { useTranslation } from "../../../hooks/use-translation.ts"; import { getSchemaProperties, - moveProperty, - removeObjectProperty, - updateObjectProperty, - updatePropertyRequired, - reorderProperty, + type FieldDropTarget, + type FieldMoveLocation, } from "../../../lib/schemaEditor.ts"; import type { NewField, ObjectJSONSchema } from "../../../types/jsonSchema.ts"; import { asObjectSchema, isBooleanSchema } from "../../../types/jsonSchema.ts"; @@ -21,6 +18,8 @@ const ObjectEditor: React.FC = ({ onChange, depth = 0, readOnly = false, + path, + onFieldDrop, }) => { const t = useTranslation(); const containerId = useMemo(() => `object-editor-${depth}-${Math.random().toString(36).substr(2, 9)}`, [depth]); @@ -131,15 +130,10 @@ const ObjectEditor: React.FC = ({ setDraggedItem({ id: name, + parentPath: path, sourceContainerId: containerId, propertySchema: property.schema, required: property.required, - // Allow moving this field out into another container by providing - // a callback that removes it from this object schema. - removeFromSource: () => { - const updated = removeObjectProperty(normalizedSchema, name); - onChange(updated); - }, }); }; @@ -165,79 +159,26 @@ const ObjectEditor: React.FC = ({ } }; - // Handle drop + // Handle drop – delegate to the centralized handler in the visual editor const handleDrop = (e: React.DragEvent, targetName: string | null = null) => { e.preventDefault(); - if (!draggedItem) { + if (!draggedItem || !onFieldDrop) { clearDragState(); return; } - let newSchema: ObjectJSONSchema; - - // Check if this is a cross-container drop - if (draggedItem.sourceContainerId !== containerId) { - // Cross-container drop: move the property to this object. - // Compute the intended insertion index from the target name and - // drop position so it matches the visual separator. - const propertyKeys = Object.keys(normalizedSchema.properties || {}); - const baseIndex = targetName ? propertyKeys.indexOf(targetName) : -1; - const targetIndex = - baseIndex >= 0 - ? baseIndex + (dropPosition === "bottom" ? 1 : 0) - : propertyKeys.length; - - // Generate a unique name for the moved property in this object - let newName = draggedItem.id; - let counter = 1; - while (normalizedSchema.properties && normalizedSchema.properties[newName]) { - newName = `${draggedItem.id}_${counter}`; - counter++; - } - - // Add the property to the schema - newSchema = updateObjectProperty( - normalizedSchema, - newName, - draggedItem.propertySchema, - ); - - // Update required status if needed - if (draggedItem.required) { - newSchema = updatePropertyRequired(newSchema, newName, true); - } - - // Reorder the newly added property into the intended position - // indicated by the separator. - newSchema = reorderProperty(newSchema, newName, targetIndex); - - // Remove the field from its original container to complete the move. - draggedItem.removeFromSource?.(); - } else if (targetName) { - // Same container drop: move relative to target item - if (dropPosition === "top") { - // Move before the target item - newSchema = moveProperty( - normalizedSchema, - draggedItem.id, - targetName, - false, - ); - } else { - // Move after the target item (default) - newSchema = moveProperty( - normalizedSchema, - draggedItem.id, - targetName, - true, - ); - } - } else { - newSchema = normalizedSchema; - } + const source: FieldMoveLocation = { + parentPath: draggedItem.parentPath, + name: draggedItem.id, + }; - onChange(newSchema); + const target: FieldDropTarget = { + parentPath: path, + anchorName: targetName ?? dragOverItem, + position: dropPosition, + }; + onFieldDrop(source, target); clearDragState(); }; @@ -270,45 +211,49 @@ const ObjectEditor: React.FC = ({ return (
{properties.length > 0 ? ( -
{properties.map((property) => ( - handleDeleteProperty(property.name)} - onNameChange={(newName) => - handlePropertyNameChange(property.name, newName) - } - onRequiredChange={(required) => - handlePropertyRequiredChange(property.name, required) - } - onSchemaChange={(schema) => - handlePropertySchemaChange(property.name, schema) - } - depth={depth} - onDragStart={handleDragStart} - onDragOver={handleDragOver} - onDrop={handleDrop} - onDragEnd={handleDragEnd} - isDragging={draggedItem?.id === property.name && draggedItem.sourceContainerId === containerId} - isDragOver={dragOverItem === property.name} - dropPosition={ - dragOverItem === property.name && - (dropPosition === "top" || dropPosition === "bottom") - ? dropPosition - : null - } - /> +
  • + handleDeleteProperty(property.name)} + onNameChange={(newName) => + handlePropertyNameChange(property.name, newName) + } + onRequiredChange={(required) => + handlePropertyRequiredChange(property.name, required) + } + onSchemaChange={(schema) => + handlePropertySchemaChange(property.name, schema) + } + depth={depth} + parentPath={path} + onDragStart={handleDragStart} + onDragOver={handleDragOver} + onDrop={handleDrop} + onDragEnd={handleDragEnd} + onFieldDrop={onFieldDrop} + isDragging={draggedItem?.id === property.name && draggedItem.sourceContainerId === containerId} + isDragOver={dragOverItem === property.name} + dropPosition={ + dragOverItem === property.name && + (dropPosition === "top" || dropPosition === "bottom") + ? dropPosition + : null + } + /> +
  • ))} -
    + ) : (
    {t.objectPropertiesNone} diff --git a/src/lib/schemaEditor.ts b/src/lib/schemaEditor.ts index 28d35ad..442951e 100644 --- a/src/lib/schemaEditor.ts +++ b/src/lib/schemaEditor.ts @@ -271,6 +271,11 @@ export function moveProperty( /** * Moves a property from one schema to another + * + * NOTE: Prefer using higher-level helpers that operate on the full root + * schema and JSON pointer style paths when moving fields across containers + * in the visual editor. This function is kept for potential library + * consumers but is no longer used by the drag-and-drop implementation. */ export function movePropertyBetweenSchemas( sourceSchema: ObjectJSONSchema, @@ -307,7 +312,8 @@ export function movePropertyBetweenSchemas( // Add properties before the target index for (let i = 0; i < targetIndex && i < targetPropertyNames.length; i++) { - newProperties[targetPropertyNames[i]] = targetSchema.properties[targetPropertyNames[i]]; + newProperties[targetPropertyNames[i]] = + targetSchema.properties[targetPropertyNames[i]]; } // Add the moved property @@ -315,7 +321,8 @@ export function movePropertyBetweenSchemas( // Add properties after the target index for (let i = targetIndex; i < targetPropertyNames.length; i++) { - newProperties[targetPropertyNames[i]] = targetSchema.properties[targetPropertyNames[i]]; + newProperties[targetPropertyNames[i]] = + targetSchema.properties[targetPropertyNames[i]]; } const updatedTarget = { ...targetSchema, properties: newProperties }; @@ -327,3 +334,187 @@ export function movePropertyBetweenSchemas( return { updatedSource, updatedTarget }; } + +/** + * Moves a field between (or within) object containers inside a root schema, + * identified by paths to the source and target object schemas. + */ +export interface FieldMoveLocation { + /** Path to the object schema that owns the field. */ + parentPath: string[]; + /** The field name (property key) within that object. */ + name: string; +} + +export interface FieldDropTarget { + /** Path to the object schema that will receive the field. */ + parentPath: string[]; + /** + * The anchor field name the drop is relative to. If null, the field is + * appended to the end of the target container. + */ + anchorName: string | null; + /** + * Drop position relative to the anchor: before ("top") or after + * ("bottom"). If null, the field is appended to the end. + */ + position: "top" | "bottom" | null; +} + +function getAtPath( + schema: JSONSchema, + path: string[], +): T | null { + let current = schema; + for (const segment of path) { + if (current == null) return null; + current = current[segment]; + } + return current as T; +} + +function setAtPath( + schema: JSONSchema, + path: string[], + value: T, +): JSONSchema { + if (path.length === 0) { + return value; + } + + const newSchema = copySchema(schema); + let current = newSchema; + for (let i = 0; i < path.length - 1; i++) { + const key = path[i]; + current[key] = copySchema(current[key] ?? {}); + current = current[key]; + } + current[path[path.length - 1]] = value; + return newSchema; +} + +/** + * Moves a field from one object container to another inside a root schema. + * + * - If source and target parent paths are equal, this performs a reordering + * using `moveProperty`. + * - Otherwise, the field is removed from the source object and inserted into + * the target object at the requested position. + */ +export function moveFieldInSchema( + rootSchema: JSONSchema, + source: FieldMoveLocation, + target: FieldDropTarget, +): JSONSchema { + const sourceObject = getAtPath( + rootSchema, + source.parentPath, + ); + const targetObject = getAtPath( + rootSchema, + target.parentPath, + ); + + if (!sourceObject || !isObjectSchema(sourceObject)) return rootSchema; + if (!targetObject || !isObjectSchema(targetObject)) return rootSchema; + if (!sourceObject.properties || !targetObject.properties) return rootSchema; + + // Same container: use moveProperty and honor the requested anchor + if ( + source.parentPath.length === target.parentPath.length && + source.parentPath.every((seg, idx) => seg === target.parentPath[idx]) + ) { + const propertyNames = Object.keys(sourceObject.properties); + + // If no anchor, move to end of the container + if (!target.anchorName) { + const newIndex = propertyNames.length - 1; + const reordered = reorderProperty(sourceObject, source.name, newIndex); + return setAtPath(rootSchema, source.parentPath, reordered); + } + + if (!propertyNames.includes(target.anchorName)) { + return rootSchema; + } + + const after = target.position !== "top"; + const moved = moveProperty( + sourceObject, + source.name, + target.anchorName, + after, + ); + return setAtPath(rootSchema, source.parentPath, moved); + } + + // Cross-container move + const property = sourceObject.properties[source.name]; + if (!property) return rootSchema; + const isRequired = sourceObject.required?.includes(source.name) ?? false; + + // Remove from source container + const updatedSource = removeObjectProperty(sourceObject, source.name); + const intermediateRoot = setAtPath( + rootSchema, + source.parentPath, + updatedSource, + ); + + // Re-read target object from the updated root in case source and target + // share structural parents. + const targetFromUpdatedRoot = getAtPath( + intermediateRoot, + target.parentPath, + ); + if (!targetFromUpdatedRoot || !isObjectSchema(targetFromUpdatedRoot)) { + return intermediateRoot; + } + + const targetProps = targetFromUpdatedRoot.properties || {}; + const existingNames = Object.keys(targetProps); + + // Determine the base name to use in the new container, avoiding collisions + let newName = source.name; + let counter = 1; + while (existingNames.includes(newName)) { + newName = `${source.name}_${counter}`; + counter++; + } + + // Insert at the desired index relative to the anchor. + const propertyNames = Object.keys(targetProps); + let insertIndex = propertyNames.length; + + if (target.anchorName && propertyNames.includes(target.anchorName)) { + const anchorIndex = propertyNames.indexOf(target.anchorName); + insertIndex = + target.position === "top" || target.position == null + ? anchorIndex + : anchorIndex + 1; + } + + const newTargetProps: Record = {}; + + for (let i = 0; i < insertIndex && i < propertyNames.length; i++) { + const key = propertyNames[i]; + newTargetProps[key] = targetProps[key]; + } + + newTargetProps[newName] = property; + + for (let i = insertIndex; i < propertyNames.length; i++) { + const key = propertyNames[i]; + newTargetProps[key] = targetProps[key]; + } + + const updatedTarget: ObjectJSONSchema = { + ...targetFromUpdatedRoot, + properties: newTargetProps, + }; + + if (isRequired) { + updatedTarget.required = [...(updatedTarget.required || []), newName]; + } + + return setAtPath(intermediateRoot, target.parentPath, updatedTarget); +} diff --git a/test/components/SchemaEditor/SchemaVisualEditor.test.tsx.snapshot b/test/components/SchemaEditor/SchemaVisualEditor.test.tsx.snapshot index c3aaf9c..faa1465 100644 --- a/test/components/SchemaEditor/SchemaVisualEditor.test.tsx.snapshot +++ b/test/components/SchemaEditor/SchemaVisualEditor.test.tsx.snapshot @@ -1,7 +1,7 @@ exports[`SchemaVisualEditor > read-only mode doesn't show constraints 1`] = ` -"
    " +"
    " `; exports[`SchemaVisualEditor > write mode does show constraints 1`] = ` -"
    " +"
    " `; diff --git a/test/components/SchemaEditor/types/ArrayEditor.test.tsx b/test/components/SchemaEditor/types/ArrayEditor.test.tsx index fe0d32f..c34c445 100644 --- a/test/components/SchemaEditor/types/ArrayEditor.test.tsx +++ b/test/components/SchemaEditor/types/ArrayEditor.test.tsx @@ -11,6 +11,8 @@ describe("ArrayEditor", () => { onChange: () => {}, depth: 0, validationNode: undefined, + // Root path for the visual editor schema node + path: [], schema: { type: "array", items: { @@ -26,6 +28,8 @@ describe("ArrayEditor", () => { onChange: () => {}, depth: 0, validationNode: undefined, + // Root path for the visual editor schema node + path: [], schema: { type: "array", items: { diff --git a/test/components/SchemaEditor/types/ObjectEditor.test.tsx b/test/components/SchemaEditor/types/ObjectEditor.test.tsx index 1e538e6..cee016b 100644 --- a/test/components/SchemaEditor/types/ObjectEditor.test.tsx +++ b/test/components/SchemaEditor/types/ObjectEditor.test.tsx @@ -15,6 +15,8 @@ describe("ObjectEditor", () => { onChange: () => {}, depth: 0, validationNode: undefined, + // Root path for the visual editor schema node + path: [], schema: { type: "object", properties: { @@ -36,6 +38,8 @@ describe("ObjectEditor", () => { onChange: () => {}, depth: 0, validationNode: undefined, + // Root path for the visual editor schema node + path: [], schema: { type: "object", properties: { diff --git a/test/components/SchemaEditor/types/ObjectEditor.test.tsx.snapshot b/test/components/SchemaEditor/types/ObjectEditor.test.tsx.snapshot index 14b7ad7..68e9f45 100644 --- a/test/components/SchemaEditor/types/ObjectEditor.test.tsx.snapshot +++ b/test/components/SchemaEditor/types/ObjectEditor.test.tsx.snapshot @@ -1,7 +1,7 @@ exports[`ObjectEditor > read-only mode doesn't show constraints 1`] = ` -"
    " +"
    " `; exports[`ObjectEditor > write mode does show constraints 1`] = ` -"
    " +"
    " `; From 63bc53adff1142bf47a4293b04db05c8c6d4393b Mon Sep 17 00:00:00 2001 From: Cody Swartz Date: Tue, 20 Jan 2026 00:18:32 -0800 Subject: [PATCH 3/7] Run fix This really ought to have happened before I started, but here we are. Maybe I can run it off of main on its own branch, but it'll surely be conflict city. Hopefully not too bad. --- postcss.config.js | 246 +++++++++--------- .../SchemaEditor/JsonSchemaEditor.tsx | 190 +++++++------- .../SchemaEditor/SchemaFieldList.tsx | 4 +- .../SchemaEditor/SchemaPropertyEditor.tsx | 8 +- .../SchemaEditor/SchemaVisualEditor.tsx | 18 +- src/components/SchemaEditor/TypeEditor.tsx | 2 +- .../SchemaEditor/types/ObjectEditor.tsx | 21 +- src/hooks/use-monaco-theme.ts | 4 +- src/i18n/locales/uk.ts | 9 +- .../SchemaEditor/SchemaVisualEditor.test.tsx | 2 +- .../SchemaEditor/types/ObjectEditor.test.tsx | 2 +- test/lib/schemaEditor.test.ts | 2 +- 12 files changed, 256 insertions(+), 252 deletions(-) diff --git a/postcss.config.js b/postcss.config.js index 52d81c1..e9f7890 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -9,120 +9,112 @@ const AllowedAtRules = new Set(["media", "supports", "layer"]); // to add proper scoping to the generated CSS. /** @type {() => import("postcss").Plugin} */ const cssScopingPlugin = () => { - return { - postcssPlugin: "replace-root-with-new_design", - Once(root) { - // Add .jsonjoy class selector to all selectors - root.walkRules((rule) => { - if ( - rule.parent?.type === "atrule" && - !AllowedAtRules.has(rule.parent.name) - ) { - return; - } - const newSelectors = new Set(); - for (const selector of rule.selectors) { - // See https://github.com/tailwindlabs/tailwindcss/discussions/18108 - // Tailwind always uses :root / :host, but we want to scope it to .jsonjoy - // Replace :root and :host with .jsonjoy - if (selector === ":root" || selector === ":host") { - newSelectors.add(".jsonjoy"); - } - // Scope universal selector - else if (selector === "*") { - newSelectors.add(".jsonjoy"); - newSelectors.add(".jsonjoy *"); - } - // Prefix all other selectors with .jsonjoy, if not already prefixed - else if (!selector.startsWith(".jsonjoy")) { - newSelectors.add(`.jsonjoy ${selector}`); - newSelectors.add( - addClassSelectorScope("jsonjoy", selector) - ); - } - // Already prefixed, so do nothing - else { - newSelectors.add(selector); - } - } - rule.selectors = [...newSelectors]; - }); + return { + postcssPlugin: "replace-root-with-new_design", + Once(root) { + // Add .jsonjoy class selector to all selectors + root.walkRules((rule) => { + if ( + rule.parent?.type === "atrule" && + !AllowedAtRules.has(rule.parent.name) + ) { + return; + } + const newSelectors = new Set(); + for (const selector of rule.selectors) { + // See https://github.com/tailwindlabs/tailwindcss/discussions/18108 + // Tailwind always uses :root / :host, but we want to scope it to .jsonjoy + // Replace :root and :host with .jsonjoy + if (selector === ":root" || selector === ":host") { + newSelectors.add(".jsonjoy"); + } + // Scope universal selector + else if (selector === "*") { + newSelectors.add(".jsonjoy"); + newSelectors.add(".jsonjoy *"); + } + // Prefix all other selectors with .jsonjoy, if not already prefixed + else if (!selector.startsWith(".jsonjoy")) { + newSelectors.add(`.jsonjoy ${selector}`); + newSelectors.add(addClassSelectorScope("jsonjoy", selector)); + } + // Already prefixed, so do nothing + else { + newSelectors.add(selector); + } + } + rule.selectors = [...newSelectors]; + }); - // Prefix built-in animation names from tailwind with jsonjoy- - // See https://tailwindcss.com/docs/animation - root.walkDecls((decl) => { - if (decl.variable) { - const animateMatch = /--animate-([a-zA-Z0-9_-]+)/.exec( - decl.prop - ); - if (animateMatch) { - const animationName = animateMatch[1]; - decl.value = decl.value.replace( - new RegExp(`\\b${animationName}\\b`, "g"), - `jsonjoy-${animationName}` - ); - } - } - }); + // Prefix built-in animation names from tailwind with jsonjoy- + // See https://tailwindcss.com/docs/animation + root.walkDecls((decl) => { + if (decl.variable) { + const animateMatch = /--animate-([a-zA-Z0-9_-]+)/.exec(decl.prop); + if (animateMatch) { + const animationName = animateMatch[1]; + decl.value = decl.value.replace( + new RegExp(`\\b${animationName}\\b`, "g"), + `jsonjoy-${animationName}`, + ); + } + } + }); - // Prefix @layer with jsonjoy- - root.walkAtRules((atRule) => { - if ( - atRule.name === "layer" && - !atRule.params.startsWith("jsonjoy-") - ) { - atRule.params = `jsonjoy-${atRule.params}`; - } - }); + // Prefix @layer with jsonjoy- + root.walkAtRules((atRule) => { + if (atRule.name === "layer" && !atRule.params.startsWith("jsonjoy-")) { + atRule.params = `jsonjoy-${atRule.params}`; + } + }); - // Prefix built-in keyframe names from tailwind with jsonjoy- - // See https://tailwindcss.com/docs/animation - root.walkAtRules((atRule) => { - if ( - atRule.name === "keyframes" && - !atRule.params.startsWith("jsonjoy-") - ) { - atRule.params = `jsonjoy-${atRule.params}`; - } - }); + // Prefix built-in keyframe names from tailwind with jsonjoy- + // See https://tailwindcss.com/docs/animation + root.walkAtRules((atRule) => { + if ( + atRule.name === "keyframes" && + !atRule.params.startsWith("jsonjoy-") + ) { + atRule.params = `jsonjoy-${atRule.params}`; + } + }); - // Prefix CSS custom properties with jsonjoy- - // Skip --vscode-* variables (used by Monaco editor) - root.walkDecls((decl) => { - if ( - decl.variable && - !decl.prop.startsWith("--jsonjoy-") && - !decl.prop.startsWith("--vscode-") - ) { - decl.prop = `--jsonjoy-${decl.prop.substring(2)}`; - } - }); + // Prefix CSS custom properties with jsonjoy- + // Skip --vscode-* variables (used by Monaco editor) + root.walkDecls((decl) => { + if ( + decl.variable && + !decl.prop.startsWith("--jsonjoy-") && + !decl.prop.startsWith("--vscode-") + ) { + decl.prop = `--jsonjoy-${decl.prop.substring(2)}`; + } + }); - // Prefix usages of CSS custom properties [var(--name)] with jsonjoy- - // Skip vscode-* variables (used by Monaco editor) - root.walkDecls((decl) => { - decl.value = decl.value.replace( - /var\(--([a-zA-Z0-9_-]+)/g, - (match, name) => { - return name.startsWith("jsonjoy-") || - name.startsWith("vscode-") - ? match - : `var(--jsonjoy-${name}`; - } - ); - }); + // Prefix usages of CSS custom properties [var(--name)] with jsonjoy- + // Skip vscode-* variables (used by Monaco editor) + root.walkDecls((decl) => { + decl.value = decl.value.replace( + /var\(--([a-zA-Z0-9_-]+)/g, + (match, name) => { + return name.startsWith("jsonjoy-") || name.startsWith("vscode-") + ? match + : `var(--jsonjoy-${name}`; + }, + ); + }); - // Prefix custom @property rules with jsonjoy- - root.walkAtRules((atRule) => { - if ( - atRule.name === "property" && - !atRule.params.startsWith("--jsonjoy-") - ) { - atRule.params = `--jsonjoy-${atRule.params.substring(2)}`; - } - }); - }, - }; + // Prefix custom @property rules with jsonjoy- + root.walkAtRules((atRule) => { + if ( + atRule.name === "property" && + !atRule.params.startsWith("--jsonjoy-") + ) { + atRule.params = `--jsonjoy-${atRule.params.substring(2)}`; + } + }); + }, + }; }; /** @@ -137,31 +129,31 @@ const cssScopingPlugin = () => { * @param {string} selector */ function addClassSelectorScope(className, selector) { - // ID selector, class selector, attribute selector or pseudo-class / pseudo-element - if ( - selector.startsWith(".") || - selector.startsWith("#") || - selector.startsWith("[") || - selector.startsWith(":") - ) { - return `.${className}${selector}`; - } + // ID selector, class selector, attribute selector or pseudo-class / pseudo-element + if ( + selector.startsWith(".") || + selector.startsWith("#") || + selector.startsWith("[") || + selector.startsWith(":") + ) { + return `.${className}${selector}`; + } - // Tag name - // Note that for tag names, the class selector must be inserted after the tag name, - // as in `table.jsonjoy` instead of `.jsonjoytable`. - const match = selector.match(/^([a-zA-Z0-9_-]+)/); - if (match) { - const tagName = match[1]; - return `${tagName}.${className}${selector.substring(tagName.length)}`; - } + // Tag name + // Note that for tag names, the class selector must be inserted after the tag name, + // as in `table.jsonjoy` instead of `.jsonjoytable`. + const match = selector.match(/^([a-zA-Z0-9_-]+)/); + if (match) { + const tagName = match[1]; + return `${tagName}.${className}${selector.substring(tagName.length)}`; + } - return selector; + return selector; } /** @type {{plugins:import("postcss").AcceptedPlugin[] }} */ export const config = { - plugins: [tailwindCss(), cssScopingPlugin()], + plugins: [tailwindCss(), cssScopingPlugin()], }; export default config; diff --git a/src/components/SchemaEditor/JsonSchemaEditor.tsx b/src/components/SchemaEditor/JsonSchemaEditor.tsx index 957f28c..95ec573 100644 --- a/src/components/SchemaEditor/JsonSchemaEditor.tsx +++ b/src/components/SchemaEditor/JsonSchemaEditor.tsx @@ -89,109 +89,109 @@ const JsonSchemaEditor: FC = ({ "jsonjoy", )} > - {/* For mobile screens - show as tabs */} -
    - -
    -

    {t.schemaEditorTitle}

    -
    - - - - {t.schemaEditorEditModeVisual} - - - {t.schemaEditorEditModeJson} - - + {/* For mobile screens - show as tabs */} +
    + +
    +

    {t.schemaEditorTitle}

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

    {t.schemaEditorTitle}

    - + + + + + + + +
    -
    -
    - + + {/* 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/SchemaFieldList.tsx b/src/components/SchemaEditor/SchemaFieldList.tsx index 88c5668..2e1e10c 100644 --- a/src/components/SchemaEditor/SchemaFieldList.tsx +++ b/src/components/SchemaEditor/SchemaFieldList.tsx @@ -1,9 +1,9 @@ -import { useMemo, type FC } from "react"; +import { type FC, useMemo } from "react"; import { useTranslation } from "../../hooks/use-translation.ts"; import { - getSchemaProperties, type FieldDropTarget, type FieldMoveLocation, + getSchemaProperties, } from "../../lib/schemaEditor.ts"; import type { JSONSchema as JSONSchemaType, diff --git a/src/components/SchemaEditor/SchemaPropertyEditor.tsx b/src/components/SchemaEditor/SchemaPropertyEditor.tsx index 9064a28..76c3de0 100644 --- a/src/components/SchemaEditor/SchemaPropertyEditor.tsx +++ b/src/components/SchemaEditor/SchemaPropertyEditor.tsx @@ -2,16 +2,16 @@ import { ChevronDown, ChevronRight, GripVertical, X } from "lucide-react"; import { useEffect, 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, ObjectJSONSchema, SchemaType, } from "../../types/jsonSchema.ts"; -import type { - FieldDropTarget, - FieldMoveLocation, -} from "../../lib/schemaEditor.ts"; import { asObjectSchema, getSchemaDescription, diff --git a/src/components/SchemaEditor/SchemaVisualEditor.tsx b/src/components/SchemaEditor/SchemaVisualEditor.tsx index 4ced715..598680d 100644 --- a/src/components/SchemaEditor/SchemaVisualEditor.tsx +++ b/src/components/SchemaEditor/SchemaVisualEditor.tsx @@ -2,12 +2,12 @@ import type { FC } from "react"; import { useTranslation } from "../../hooks/use-translation.ts"; import { createFieldSchema, - updateObjectProperty, - updatePropertyRequired, - renameObjectProperty, - moveFieldInSchema, type FieldDropTarget, type FieldMoveLocation, + moveFieldInSchema, + renameObjectProperty, + updateObjectProperty, + updatePropertyRequired, } from "../../lib/schemaEditor.ts"; import type { JSONSchema, NewField } from "../../types/jsonSchema.ts"; import { asObjectSchema, isBooleanSchema } from "../../types/jsonSchema.ts"; @@ -49,7 +49,7 @@ const SchemaVisualEditor: FC = ({ onChange(newSchema); }; -// Handle editing a top-level field + // Handle editing a top-level field const handleEditField = (name: string, updatedField: NewField) => { // Create a field schema based on the updated field data const fieldSchema = createFieldSchema(updatedField); @@ -60,7 +60,11 @@ const SchemaVisualEditor: FC = ({ if (name !== updatedField.name) { newSchema = renameObjectProperty(newSchema, name, updatedField.name); // Update the field schema after rename - newSchema = updateObjectProperty(newSchema, updatedField.name, fieldSchema); + newSchema = updateObjectProperty( + newSchema, + updatedField.name, + fieldSchema, + ); } else { // Name didn't change, just update the schema newSchema = updateObjectProperty(newSchema, name, fieldSchema); @@ -133,10 +137,8 @@ const SchemaVisualEditor: FC = ({ diff --git a/src/components/SchemaEditor/TypeEditor.tsx b/src/components/SchemaEditor/TypeEditor.tsx index 0d9eb17..5aa3101 100644 --- a/src/components/SchemaEditor/TypeEditor.tsx +++ b/src/components/SchemaEditor/TypeEditor.tsx @@ -1,4 +1,5 @@ import { lazy, Suspense } from "react"; +import { useTranslation } from "../../hooks/use-translation.ts"; import type { JSONSchema, ObjectJSONSchema, @@ -6,7 +7,6 @@ import type { } from "../../types/jsonSchema.ts"; import { withObjectSchema } from "../../types/jsonSchema.ts"; import type { ValidationTreeNode } from "../../types/validation.ts"; -import { useTranslation } from "../../hooks/use-translation.ts"; // Lazy load specific type editors to avoid circular dependencies const StringEditor = lazy(() => import("./types/StringEditor.tsx")); diff --git a/src/components/SchemaEditor/types/ObjectEditor.tsx b/src/components/SchemaEditor/types/ObjectEditor.tsx index e6cc012..1b2816c 100644 --- a/src/components/SchemaEditor/types/ObjectEditor.tsx +++ b/src/components/SchemaEditor/types/ObjectEditor.tsx @@ -1,16 +1,16 @@ import { useMemo } from "react"; import { useTranslation } from "../../../hooks/use-translation.ts"; import { - getSchemaProperties, type FieldDropTarget, type FieldMoveLocation, + getSchemaProperties, } from "../../../lib/schemaEditor.ts"; import type { NewField, ObjectJSONSchema } from "../../../types/jsonSchema.ts"; import { asObjectSchema, isBooleanSchema } from "../../../types/jsonSchema.ts"; import AddFieldButton from "../AddFieldButton.tsx"; +import { useDragContext } from "../DragContext.tsx"; import SchemaPropertyEditor from "../SchemaPropertyEditor.tsx"; import type { TypeEditorProps } from "../TypeEditor.tsx"; -import { useDragContext } from "../DragContext.tsx"; const ObjectEditor: React.FC = ({ schema, @@ -22,8 +22,11 @@ const ObjectEditor: React.FC = ({ onFieldDrop, }) => { const t = useTranslation(); - const containerId = useMemo(() => `object-editor-${depth}-${Math.random().toString(36).substr(2, 9)}`, [depth]); - + const containerId = useMemo( + () => `object-editor-${depth}-${Math.random().toString(36).substr(2, 9)}`, + [depth], + ); + const { draggedItem, setDraggedItem, @@ -140,7 +143,10 @@ const ObjectEditor: React.FC = ({ // Handle drag over for items const handleDragOver = (e: React.DragEvent, name: string) => { e.preventDefault(); - if (!draggedItem || (draggedItem.sourceContainerId === containerId && draggedItem.id === name)) { + if ( + !draggedItem || + (draggedItem.sourceContainerId === containerId && draggedItem.id === name) + ) { setDropPosition(null); return; } @@ -242,7 +248,10 @@ const ObjectEditor: React.FC = ({ onDrop={handleDrop} onDragEnd={handleDragEnd} onFieldDrop={onFieldDrop} - isDragging={draggedItem?.id === property.name && draggedItem.sourceContainerId === containerId} + isDragging={ + draggedItem?.id === property.name && + draggedItem.sourceContainerId === containerId + } isDragOver={dragOverItem === property.name} dropPosition={ dragOverItem === property.name && diff --git a/src/hooks/use-monaco-theme.ts b/src/hooks/use-monaco-theme.ts index 1787ed2..294e6dc 100644 --- a/src/hooks/use-monaco-theme.ts +++ b/src/hooks/use-monaco-theme.ts @@ -195,9 +195,7 @@ export function useMonacoTheme() { ], }; - MonacoModule.json.jsonDefaults.setDiagnosticsOptions( - diagnosticsOptions, - ); + MonacoModule.json.jsonDefaults.setDiagnosticsOptions(diagnosticsOptions); }; return { diff --git a/src/i18n/locales/uk.ts b/src/i18n/locales/uk.ts index 6befba5..2dc763c 100644 --- a/src/i18n/locales/uk.ts +++ b/src/i18n/locales/uk.ts @@ -10,7 +10,8 @@ export const uk: Translation = { fieldDescriptionTooltip: "Додайте контекст про те, що представляє це поле", fieldNameLabel: "Назва поля", fieldNamePlaceholder: "наприклад, ім'я, вік, активне", - fieldNameTooltip: "Використовуйте camelCase для кращої читабельності (наприклад, firstName)", + fieldNameTooltip: + "Використовуйте camelCase для кращої читабельності (наприклад, firstName)", fieldRequiredLabel: "Обов'язкове поле", fieldType: "Тип поля", fieldTypeExample: "Приклад:", @@ -132,7 +133,8 @@ export const uk: Translation = { "Вставте свій JSON-документ нижче, щоб згенерувати з нього схему.", inferrerCancel: "Скасувати", inferrerGenerate: "Згенерувати схему", - inferrerErrorInvalidJson: "Неправильний формат JSON. Перевірте, будь ласка, введені дані.", + inferrerErrorInvalidJson: + "Неправильний формат JSON. Перевірте, будь ласка, введені дані.", validatorTitle: "Перевірити JSON", validatorDescription: @@ -154,7 +156,8 @@ export const uk: Translation = { visualEditorNoFieldsHint1: "Поки що не визначено жодне поле", visualEditorNoFieldsHint2: "Додайте перше поле, щоб почати", - typeValidationErrorNegativeLength: "Значення довжини не можуть бути від'ємними.", + typeValidationErrorNegativeLength: + "Значення довжини не можуть бути від'ємними.", typeValidationErrorIntValue: "Значення має бути цілим числом.", typeValidationErrorPositive: "Значення має бути додатнім.", }; diff --git a/test/components/SchemaEditor/SchemaVisualEditor.test.tsx b/test/components/SchemaEditor/SchemaVisualEditor.test.tsx index 40b07ce..4395a8c 100644 --- a/test/components/SchemaEditor/SchemaVisualEditor.test.tsx +++ b/test/components/SchemaEditor/SchemaVisualEditor.test.tsx @@ -2,8 +2,8 @@ import { render } from "@testing-library/react"; import "global-jsdom/register"; import { describe, test } from "node:test"; import React from "react"; -import SchemaVisualEditor from "../../../src/components/SchemaEditor/SchemaVisualEditor.tsx"; import { DragProvider } from "../../../src/components/SchemaEditor/DragContext.tsx"; +import SchemaVisualEditor from "../../../src/components/SchemaEditor/SchemaVisualEditor.tsx"; describe("SchemaVisualEditor", () => { test("write mode does show constraints", (t) => { diff --git a/test/components/SchemaEditor/types/ObjectEditor.test.tsx b/test/components/SchemaEditor/types/ObjectEditor.test.tsx index cee016b..846cbf0 100644 --- a/test/components/SchemaEditor/types/ObjectEditor.test.tsx +++ b/test/components/SchemaEditor/types/ObjectEditor.test.tsx @@ -2,8 +2,8 @@ import { render } from "@testing-library/react"; import "global-jsdom/register"; import { describe, test } from "node:test"; import React from "react"; -import ObjectEditor from "../../../../src/components/SchemaEditor/types/ObjectEditor.tsx"; import { DragProvider } from "../../../../src/components/SchemaEditor/DragContext.tsx"; +import ObjectEditor from "../../../../src/components/SchemaEditor/types/ObjectEditor.tsx"; describe("ObjectEditor", () => { test("write mode does show constraints", (t) => { diff --git a/test/lib/schemaEditor.test.ts b/test/lib/schemaEditor.test.ts index 38ac0a6..f816c6e 100644 --- a/test/lib/schemaEditor.test.ts +++ b/test/lib/schemaEditor.test.ts @@ -1,5 +1,5 @@ -import { describe, test } from "node:test"; import assert from "node:assert"; +import { describe, test } from "node:test"; import { renameObjectProperty } from "../../src/lib/schemaEditor.ts"; describe("renameObjectProperty", () => { From 5eb511c88e68aafd93b6e0cf0fdbfd9a592cf689 Mon Sep 17 00:00:00 2001 From: Cody Swartz Date: Tue, 20 Jan 2026 01:39:03 -0800 Subject: [PATCH 4/7] Fix missing imports Not sure why this isn't picked up by the check. Ah, the check script must not cover this or do type checking. But there is a typecheck script. --- src/components/SchemaEditor/types/ObjectEditor.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/SchemaEditor/types/ObjectEditor.tsx b/src/components/SchemaEditor/types/ObjectEditor.tsx index 1b2816c..5aa5cd7 100644 --- a/src/components/SchemaEditor/types/ObjectEditor.tsx +++ b/src/components/SchemaEditor/types/ObjectEditor.tsx @@ -4,6 +4,9 @@ import { type FieldDropTarget, type FieldMoveLocation, getSchemaProperties, + removeObjectProperty, + updateObjectProperty, + updatePropertyRequired, } from "../../../lib/schemaEditor.ts"; import type { NewField, ObjectJSONSchema } from "../../../types/jsonSchema.ts"; import { asObjectSchema, isBooleanSchema } from "../../../types/jsonSchema.ts"; From 356f0cdb234dbc9814e11573e64cf942f629dd3f Mon Sep 17 00:00:00 2001 From: Cody Swartz Date: Tue, 3 Mar 2026 19:02:26 -0800 Subject: [PATCH 5/7] Migrate drag-and-drop to Pragmatic Drag and Drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace home-rolled HTML5 DnD with @atlaskit/pragmatic-drag-and-drop: - Rewrite DragContext to simplified { draggingId, overId, overEdge } state - SchemaPropertyEditor: useEffect registers draggable() + dropTargetForElements() via refs (propsRef pattern prevents re-registration during active drags); canDrop guards prevent self-drop and dropping onto descendants - SchemaFieldList: single global monitorForElements drives all visual state (setDragging, setOver) via onDragStart/onDrag/onDrop — avoids false onDragLeave events from child elements; resolveTarget() skips ancestor containers so nested items stay nested during reorder - ObjectEditor: remove all manual drag handlers and drag props - tsconfig.test.json: add paths aliases so tsx (Node ESM) resolves Pragmatic DnD subpath exports to CJS dist files Net: -233 lines. Cross-container drag and nested reorder both work correctly. Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 40 +++- package.json | 2 + src/components/SchemaEditor/DragContext.tsx | 90 ++------ .../SchemaEditor/SchemaFieldList.tsx | 212 +++++------------- .../SchemaEditor/SchemaPropertyEditor.tsx | 153 +++++++------ .../SchemaEditor/types/ObjectEditor.tsx | 141 +----------- .../SchemaVisualEditor.test.tsx.snapshot | 4 +- .../types/ObjectEditor.test.tsx.snapshot | 4 +- tsconfig.test.json | 13 +- 9 files changed, 213 insertions(+), 446 deletions(-) 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 6fca9e7..12c35c3 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 index 04718f7..0901bf6 100644 --- a/src/components/SchemaEditor/DragContext.tsx +++ b/src/components/SchemaEditor/DragContext.tsx @@ -1,86 +1,42 @@ import type { ReactNode } from "react"; import { createContext, useContext, useState } from "react"; -import type { JSONSchema } from "../../types/jsonSchema.ts"; -export interface DraggedItem { - /** - * The property name being dragged. - */ - id: string; - /** - * Path (from the root schema of the visual editor) to the object schema - * that currently owns this property. This is the container whose - * `properties` collection includes `id`. - * - * For example, the root object has path [], and a nested object field - * `address` would live at ["properties", "address"]. - */ - parentPath: string[]; - /** - * Original container identifier, still used for quick equality checks in - * the UI (e.g. highlighting the dragged row). This is purely a view-level - * identifier and not used for schema updates anymore. - */ - sourceContainerId: string; - /** - * The JSON Schema for the dragged property. - */ - propertySchema: JSONSchema; - /** - * Whether the dragged property is required in its source container. - */ - required: boolean; +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; } -interface DragContextType { - draggedItem: DraggedItem | null; - setDraggedItem: (item: DraggedItem | null) => void; - dragOverItem: string | null; - setDragOverItem: (item: string | null) => void; - dropPosition: "top" | "bottom" | null; - setDropPosition: (position: "top" | "bottom" | null) => void; - clearDragState: () => void; -} - -const DragContext = createContext(undefined); +const DragContext = createContext({ + draggingId: null, + overId: null, + overEdge: null, + setDragging: () => {}, + setOver: () => {}, +}); -export const useDragContext = () => { - const context = useContext(DragContext); - if (!context) { - throw new Error("useDragContext must be used within a DragProvider"); - } - return context; -}; +export const useDragContext = () => useContext(DragContext); interface DragProviderProps { children: ReactNode; } export const DragProvider: React.FC = ({ children }) => { - const [draggedItem, setDraggedItem] = useState(null); - const [dragOverItem, setDragOverItem] = useState(null); - const [dropPosition, setDropPosition] = useState<"top" | "bottom" | null>( - null, - ); + 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 clearDragState = () => { - setDraggedItem(null); - setDragOverItem(null); - setDropPosition(null); + const setOver = (id: string | null, edge: "top" | "bottom" | null) => { + setOverId(id); + setOverEdge(edge); }; return ( - + {children} ); diff --git a/src/components/SchemaEditor/SchemaFieldList.tsx b/src/components/SchemaEditor/SchemaFieldList.tsx index 2e1e10c..089732f 100644 --- a/src/components/SchemaEditor/SchemaFieldList.tsx +++ b/src/components/SchemaEditor/SchemaFieldList.tsx @@ -1,4 +1,7 @@ -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 { useDragContext } from "./DragContext.tsx"; import { useTranslation } from "../../hooks/use-translation.ts"; import { type FieldDropTarget, @@ -12,7 +15,6 @@ 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 { @@ -33,38 +35,17 @@ const SchemaFieldList: FC = ({ onFieldDrop, }) => { const t = useTranslation(); - const containerId = useMemo(() => { - const uniqueId = crypto.randomUUID(); - return `schema-field-list-${uniqueId}`; - }, []); - - const { - draggedItem, - setDraggedItem, - dragOverItem, - setDragOverItem, - dropPosition, - setDropPosition, - clearDragState, - } = useDragContext(); + 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; @@ -84,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; @@ -104,7 +84,6 @@ const SchemaFieldList: FC = ({ }); }; - // Handle schema change const handleSchemaChange = ( name: string, updatedSchema: ObjectJSONSchema, @@ -113,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, { @@ -125,110 +103,59 @@ const SchemaFieldList: FC = ({ }); }; - // Handle drag start - const handleDragStart = (_e: React.DragEvent, name: string) => { - const property = properties.find((prop) => prop.name === name); - if (!property) return; - - setDraggedItem({ - id: name, - parentPath, - sourceContainerId: containerId, - propertySchema: property.schema, - required: property.required, - }); - }; - - // Handle drag over for items - const handleDragOver = (e: React.DragEvent, name: string) => { - e.preventDefault(); - if ( - !draggedItem || - (draggedItem.sourceContainerId === containerId && draggedItem.id === name) - ) { - setDropPosition(null); - return; - } - - setDragOverItem(name); - - // Calculate drop position based on mouse Y position relative to element - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - const relativeY = e.clientY - rect.top; - const threshold = rect.height / 2; - - if (relativeY < threshold) { - setDropPosition("top"); - } else { - setDropPosition("bottom"); - } - }; - - // Handle drop – delegate to the centralized handler in the visual editor - const handleDrop = (e: React.DragEvent, targetName: string | null = null) => { - e.preventDefault(); - if (!draggedItem || !onFieldDrop) { - clearDragState(); - return; - } - - const source: FieldMoveLocation = { - parentPath: draggedItem.parentPath, - name: draggedItem.id, + // 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; }; - const target: FieldDropTarget = { - parentPath, - anchorName: targetName ?? dragOverItem, - position: dropPosition, - }; - - onFieldDrop(source, target); - clearDragState(); - }; - - // Handle drag end - const handleDragEnd = () => { - clearDragState(); - }; - - // Handle drag over on the container to allow drops anywhere in the list - const handleContainerDragOver = (e: React.DragEvent) => { - // Only handle drag events that are directly on this container, not - // those bubbled from child elements (like nested object editors). - if (e.target !== e.currentTarget) return; - - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - }; - - // Handle keyboard events for accessibility - const handleKeyDown = (e: React.KeyboardEvent) => { - // Escape key cancels any active drag operation - if (e.key === "Escape" && draggedItem) { - clearDragState(); - e.preventDefault(); - } - }; - - // Handle focus events for accessibility - const handleFocus = () => { - // When container receives focus, announce its purpose - if (draggedItem) { - // Container is a valid drop target - } - }; - - // Handle drop on the container using the existing dragOverItem and dropPosition state - const handleContainerDrop = (e: React.DragEvent) => { - // Ignore drops that originated from child elements; those components - // handle their own drag-and-drop behavior. - if (e.target !== e.currentTarget) return; - - // Use the existing dragOverItem and dropPosition state - // This allows dropping in dead zones while respecting the UI state - handleDrop(e, dragOverItem); - }; + 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), @@ -236,15 +163,7 @@ const SchemaFieldList: FC = ({ ); return ( -
    +
    {properties.map((property) => ( = ({ onSchemaChange={(schema) => handleSchemaChange(property.name, schema)} readOnly={readOnly} parentPath={parentPath} - onDragStart={handleDragStart} - onDragOver={handleDragOver} - onDrop={handleDrop} - onDragEnd={handleDragEnd} onFieldDrop={onFieldDrop} - isDragging={ - draggedItem?.id === property.name && - draggedItem.sourceContainerId === containerId - } - isDragOver={dragOverItem === property.name} - dropPosition={ - dragOverItem === property.name && - (dropPosition === "top" || dropPosition === "bottom") - ? dropPosition - : null - } /> ))}
    diff --git a/src/components/SchemaEditor/SchemaPropertyEditor.tsx b/src/components/SchemaEditor/SchemaPropertyEditor.tsx index f6b833d..e1eaf5e 100644 --- a/src/components/SchemaEditor/SchemaPropertyEditor.tsx +++ b/src/components/SchemaEditor/SchemaPropertyEditor.tsx @@ -1,5 +1,11 @@ +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, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Input } from "../../components/ui/input.tsx"; import { useTranslation } from "../../hooks/use-translation.ts"; import type { @@ -20,32 +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"; -interface DropIndicatorProps { - onDrop: (e: React.DragEvent) => void; - position: "top" | "bottom"; -} - -const DropIndicator: React.FC = ({ onDrop, position }) => ( -