From f8f5bc5d9282807c617b7aa959e33ee50be023b7 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Thu, 3 Sep 2026 14:24:53 +0200 Subject: [PATCH 1/4] fix(sdk): palette drag and drop working on mobile --- packages/sdk/src/features/diagram/diagram.tsx | 2 +- .../palette/components/items/palette-item.tsx | 9 ++--- .../components/items/palette-items.tsx | 24 +++----------- .../hooks/use-palette-drag-and-drop.tsx | 23 ++++--------- .../palette}/hooks/use-palette-drop.spec.tsx | 10 +++--- .../palette}/hooks/use-palette-drop.ts | 33 ++++++++++--------- .../features/palette/palette-container.tsx | 12 +++---- .../palette/stores/use-palette-store.ts | 26 +++++++++++++++ packages/sdk/src/node/common.ts | 4 --- .../src/store/slices/palette/palette-slice.ts | 14 +------- packages/sdk/src/utils/generate-id.ts | 17 +++++++++- 11 files changed, 86 insertions(+), 88 deletions(-) rename packages/sdk/src/{ => features/palette}/hooks/use-palette-drop.spec.tsx (91%) rename packages/sdk/src/{ => features/palette}/hooks/use-palette-drop.ts (69%) create mode 100644 packages/sdk/src/features/palette/stores/use-palette-store.ts diff --git a/packages/sdk/src/features/diagram/diagram.tsx b/packages/sdk/src/features/diagram/diagram.tsx index 466cd50bd..f2ae29bb0 100644 --- a/packages/sdk/src/features/diagram/diagram.tsx +++ b/packages/sdk/src/features/diagram/diagram.tsx @@ -15,7 +15,6 @@ import type { DragEvent } from 'react'; import styles from './diagram.module.css'; import { getReactFlowProps } from '../../data/react-flow-config'; -import { usePaletteDrop } from '../../hooks/use-palette-drop'; import type { WorkflowBuilderOnSelectionChangeParams } from '../../node/common'; import type { WorkflowBuilderEdge, WorkflowBuilderNode } from '../../node/node-data'; import { getStoreNodes } from '../../store/slices/diagram-slice/actions'; @@ -23,6 +22,7 @@ import { useStore } from '../../store/store'; import type { WorkflowBuilderReactFlowProps } from '../../workflow-builder-root/workflow-builder-root.types'; import { trackFutureChange } from '../changes-tracker/stores/use-changes-tracker-store'; import { useDeleteConfirmation } from '../modals/delete-confirmation/use-delete-confirmation'; +import { usePaletteDrop } from '../palette/hooks/use-palette-drop'; import { withOptionalComponentPlugins } from '../plugins-core/adapters/adapter-components'; import { deleteKeyCode } from './const'; import { SNAP_GRID, SNAP_IS_ACTIVE } from './diagram.const'; diff --git a/packages/sdk/src/features/palette/components/items/palette-item.tsx b/packages/sdk/src/features/palette/components/items/palette-item.tsx index ecf337864..18dfd3045 100644 --- a/packages/sdk/src/features/palette/components/items/palette-item.tsx +++ b/packages/sdk/src/features/palette/components/items/palette-item.tsx @@ -1,5 +1,4 @@ import clsx from 'clsx'; -import type { DragEvent } from 'react'; import styles from './palette-item.module.css'; @@ -8,12 +7,11 @@ import { NodePreviewContainer } from '../../node-preview-container'; type PaletteItemProps = { item: PaletteItemType; - onDragStart: (event: DragEvent) => void; - onMouseDown: (type: string) => void; + onPointerDown: (event: React.PointerEvent, item: PaletteItemType) => void; isDisabled?: boolean; }; -export function PaletteItem({ item, onDragStart, onMouseDown, isDisabled = false }: PaletteItemProps) { +export function PaletteItem({ item, onPointerDown, isDisabled = false }: PaletteItemProps) { return (
onMouseDown(item.type)} - onDragStart={onDragStart} + onPointerDown={(event) => onPointerDown(event, item)} >
diff --git a/packages/sdk/src/features/palette/components/items/palette-items.tsx b/packages/sdk/src/features/palette/components/items/palette-items.tsx index 2591eaabf..da78471dc 100644 --- a/packages/sdk/src/features/palette/components/items/palette-items.tsx +++ b/packages/sdk/src/features/palette/components/items/palette-items.tsx @@ -1,5 +1,4 @@ import { Accordion } from '@workflowbuilder/ui'; -import type { DragEvent } from 'react'; import styles from './palette-items.module.css'; @@ -8,13 +7,12 @@ import type { PaletteGroup, PaletteItem as PaletteItemType } from '../../../../n import { PaletteItem } from './palette-item'; type PaletteItemsProps = { - onDragStart: (event: DragEvent) => void; - onMouseDown: (type: string) => void; + onPointerDown: (event: React.PointerEvent, item: PaletteItemType) => void; items: (PaletteItemType | PaletteGroup)[]; isDisabled?: boolean; }; -export function PaletteItems({ items, onDragStart, onMouseDown, isDisabled = false }: PaletteItemsProps) { +export function PaletteItems({ items, onPointerDown, isDisabled = false }: PaletteItemsProps) { const translateIfPossible = useTranslateIfPossible(); return ( @@ -34,13 +32,7 @@ export function PaletteItems({ items, onDragStart, onMouseDown, isDisabled = fal >
{group.groupItems.map((item) => ( - + ))}
@@ -49,15 +41,7 @@ export function PaletteItems({ items, onDragStart, onMouseDown, isDisabled = fal const item = itemOrGroup as PaletteItemType; - return ( - - ); + return ; })} ); diff --git a/packages/sdk/src/features/palette/hooks/use-palette-drag-and-drop.tsx b/packages/sdk/src/features/palette/hooks/use-palette-drag-and-drop.tsx index bc23150a4..a83616b15 100644 --- a/packages/sdk/src/features/palette/hooks/use-palette-drag-and-drop.tsx +++ b/packages/sdk/src/features/palette/hooks/use-palette-drag-and-drop.tsx @@ -1,34 +1,25 @@ -import { type DragEvent, useRef } from 'react'; +import { useRef } from 'react'; +import type { PaletteItem } from '../../../node/common'; import { useStore } from '../../../store/store'; -import { dataFormat } from '../../../utils/consts'; +import { setDraggedItem } from '../stores/use-palette-store'; export function usePaletteDragAndDrop(canDrag: boolean) { - const setDraggedItem = useStore((state) => state.setDraggedItem); - const draggedItem = useStore((state) => state.draggedItem); const zoom = useStore((state) => state.reactFlowInstance?.getZoom() || 1); const ref = useRef(null); - function onMouseDown(type: string) { - if (canDrag) { - setDraggedItem({ type }); - } - } - - function onDragStart(event: DragEvent) { + function onPointerDown(event: React.PointerEvent, item: PaletteItem) { if (!canDrag) { return event.preventDefault(); } - event.dataTransfer.setDragImage(ref.current as Element, 0, 0); - event.dataTransfer.setData(dataFormat, JSON.stringify(draggedItem)); + (event.target as HTMLElement).setPointerCapture(event.pointerId); + setDraggedItem(item); } return { - draggedItem, zoom, ref, - onMouseDown, - onDragStart, + onPointerDown, }; } diff --git a/packages/sdk/src/hooks/use-palette-drop.spec.tsx b/packages/sdk/src/features/palette/hooks/use-palette-drop.spec.tsx similarity index 91% rename from packages/sdk/src/hooks/use-palette-drop.spec.tsx rename to packages/sdk/src/features/palette/hooks/use-palette-drop.spec.tsx index 450b779d9..aabb29010 100644 --- a/packages/sdk/src/hooks/use-palette-drop.spec.tsx +++ b/packages/sdk/src/features/palette/hooks/use-palette-drop.spec.tsx @@ -11,11 +11,11 @@ import { renderHook } from '@testing-library/react'; import type { NodeAddChange } from '@xyflow/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { PaletteItem } from '../node/common'; -import type { WorkflowBuilderNode } from '../node/node-data'; -import { NodeType } from '../node/node-types'; -import { resetWorkflowStore, useStore } from '../store/store'; -import { dataFormat } from '../utils/consts'; +import type { PaletteItem } from '../../../node/common'; +import type { WorkflowBuilderNode } from '../../../node/node-data'; +import { NodeType } from '../../../node/node-types'; +import { resetWorkflowStore, useStore } from '../../../store/store'; +import { dataFormat } from '../../../utils/consts'; import { usePaletteDrop } from './use-palette-drop'; vi.mock('@xyflow/react', () => ({ diff --git a/packages/sdk/src/hooks/use-palette-drop.ts b/packages/sdk/src/features/palette/hooks/use-palette-drop.ts similarity index 69% rename from packages/sdk/src/hooks/use-palette-drop.ts rename to packages/sdk/src/features/palette/hooks/use-palette-drop.ts index f3ea54d7c..28fff4675 100644 --- a/packages/sdk/src/hooks/use-palette-drop.ts +++ b/packages/sdk/src/features/palette/hooks/use-palette-drop.ts @@ -2,16 +2,16 @@ import { type XYPosition, useStoreApi } from '@xyflow/react'; import { type DragEvent, useCallback } from 'react'; import { useShallow } from 'zustand/shallow'; -import { getCustomNodeTemplates } from '../data/node-templates'; -import { trackFutureChange } from '../features/changes-tracker/stores/use-changes-tracker-store'; -import type { DraggingItem } from '../node/common'; -import type { BaseNodeProperties } from '../node/node-schema'; -import { NodeType } from '../node/node-types'; -import { useStore } from '../store/store'; -import { dataFormat } from '../utils/consts'; -import { getNodeAddChange } from '../utils/get-node-add-change'; -import { resolveReactFlowNodeType } from '../utils/resolve-react-flow-node-type'; -import { useTranslateIfPossible } from './use-translate-if-possible'; +import { getCustomNodeTemplates } from '../../../data/node-templates'; +import { useTranslateIfPossible } from '../../../hooks/use-translate-if-possible'; +import type { BaseNodeProperties } from '../../../node/node-schema'; +import { NodeType } from '../../../node/node-types'; +import { useStore } from '../../../store/store'; +import { generateId } from '../../../utils/generate-id'; +import { getNodeAddChange } from '../../../utils/get-node-add-change'; +import { resolveReactFlowNodeType } from '../../../utils/resolve-react-flow-node-type'; +import { trackFutureChange } from '../../changes-tracker/stores/use-changes-tracker-store'; +import { getDraggedItemAction, setDraggedItem } from '../stores/use-palette-store'; export function usePaletteDrop() { const resetSelectedElements = useStoreApi().getState().resetSelectedElements; @@ -48,7 +48,7 @@ export function usePaletteDrop() { const reactFlowNodeType = resolveReactFlowNodeType(type, templateType, getCustomNodeTemplates()); - const newNodeId = crypto.randomUUID(); + const newNodeId = generateId(); trackFutureChange('addNode', { nodeType: type }); resetSelectedElements(); onNodesChange(getNodeAddChange(reactFlowNodeType, position, data, newNodeId)); @@ -65,13 +65,16 @@ export function usePaletteDrop() { y: event.clientY, }); - const json = event.dataTransfer?.getData(dataFormat); - if (!json) return; + const draggedItem = getDraggedItemAction(); - const draggingItem = JSON.parse(json) as DraggingItem; - const { type } = draggingItem; + if (!draggedItem) { + return; + } + + const { type } = draggedItem; dropNode(position, type); + setDraggedItem(null); }, [reactFlowInstance, dropNode], ); diff --git a/packages/sdk/src/features/palette/palette-container.tsx b/packages/sdk/src/features/palette/palette-container.tsx index 78544ac4c..d3166b77b 100644 --- a/packages/sdk/src/features/palette/palette-container.tsx +++ b/packages/sdk/src/features/palette/palette-container.tsx @@ -12,6 +12,7 @@ import { PaletteHeader } from './components/header/palette-header'; import { PaletteItems } from './components/items/palette-items'; import { usePaletteDragAndDrop } from './hooks/use-palette-drag-and-drop'; import { NodePreviewContainer } from './node-preview-container'; +import { usePaletteStore } from './stores/use-palette-store'; /** * Left-side palette listing draggable node types and the template selector. @@ -29,7 +30,9 @@ export function PaletteContainer() { const paletteItems = useStore((state) => state.data); const isReadOnlyMode = useStore((state) => state.isReadOnlyMode); - const { draggedItem, zoom, ref, onMouseDown, onDragStart } = usePaletteDragAndDrop(!isReadOnlyMode); + const draggedItem = usePaletteStore((state) => state.draggedItem); + + const { zoom, ref, onPointerDown } = usePaletteDragAndDrop(!isReadOnlyMode); useEffect(() => { fetchData(); @@ -42,12 +45,7 @@ export function PaletteContainer() { header={ toggleSidebar()} isSidebarExpanded={isSidebarExpanded} />} footer={} > - + {draggedItem && ( diff --git a/packages/sdk/src/features/palette/stores/use-palette-store.ts b/packages/sdk/src/features/palette/stores/use-palette-store.ts new file mode 100644 index 000000000..d800802cc --- /dev/null +++ b/packages/sdk/src/features/palette/stores/use-palette-store.ts @@ -0,0 +1,26 @@ +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; + +import type { PaletteItem } from '../../../node/common'; + +type PaletteStore = { + draggedItem: PaletteItem | null; +}; + +export const usePaletteStore = create()( + devtools( + () => + ({ + draggedItem: null, + }) satisfies PaletteStore, + { name: 'paletteStore' }, + ), +); + +export function setDraggedItem(item: PaletteItem | null) { + return usePaletteStore.setState({ draggedItem: item }); +} + +export function getDraggedItemAction() { + return usePaletteStore.getState().draggedItem; +} diff --git a/packages/sdk/src/node/common.ts b/packages/sdk/src/node/common.ts index 9797924db..e297dbdaa 100644 --- a/packages/sdk/src/node/common.ts +++ b/packages/sdk/src/node/common.ts @@ -96,10 +96,6 @@ export type ChildrenProps = { export type ZoomLevelFormatterFn = (zoomLevel: number) => number | string; -export type DraggingItem = { - type: string; -}; - export function isNodeType(type: ItemType): type is NodeType { return Object.values(NodeType).includes(type as NodeType); } diff --git a/packages/sdk/src/store/slices/palette/palette-slice.ts b/packages/sdk/src/store/slices/palette/palette-slice.ts index 08d9cec9a..f367cd809 100644 --- a/packages/sdk/src/store/slices/palette/palette-slice.ts +++ b/packages/sdk/src/store/slices/palette/palette-slice.ts @@ -1,11 +1,5 @@ import { getPaletteData } from '../../../data/palette'; -import { - type DraggingItem, - type PaletteGroup, - type PaletteItem, - type PaletteItemOrGroup, - StatusType, -} from '../../../node/common'; +import { type PaletteGroup, type PaletteItem, type PaletteItemOrGroup, StatusType } from '../../../node/common'; import type { GetDiagramState, SetDiagramState } from '../../store'; import { refreshNodesErrorsIfNeeded } from '../diagram-slice/actions'; @@ -13,10 +7,8 @@ export type PaletteState = { isSidebarExpanded: boolean; data: PaletteItemOrGroup[]; fetchDataStatus: StatusType; - draggedItem: DraggingItem | null; toggleSidebar: (value?: boolean) => void; fetchData: () => void; - setDraggedItem: (item: DraggingItem | null) => void; getNodeDefinition: (nodeType: string) => PaletteItem | undefined; }; @@ -25,10 +17,6 @@ export function usePaletteSlice(set: SetDiagramState, get: GetDiagramState): Pal isSidebarExpanded: false, data: [], fetchDataStatus: StatusType.Idle, - draggedItem: null, - setDraggedItem: (item) => { - set({ draggedItem: item }); - }, toggleSidebar: (value) => { set({ isSidebarExpanded: value ?? !get().isSidebarExpanded, diff --git a/packages/sdk/src/utils/generate-id.ts b/packages/sdk/src/utils/generate-id.ts index f0eb608e1..f3b2175b7 100644 --- a/packages/sdk/src/utils/generate-id.ts +++ b/packages/sdk/src/utils/generate-id.ts @@ -1,3 +1,18 @@ +function fallbackGenerateId(): `${string}-${string}-${string}-${string}-${string}` { + const array = new Uint8Array(16); + crypto.getRandomValues(array); + array[6] = (array[6] & 0x0F) | 0x40; // Set version to 4 + array[8] = (array[8] & 0x3F) | 0x80; // Set variant to RFC4122 + + const hexArray = Array.from(array, (byte) => byte.toString(16).padStart(2, '0')); + return `${hexArray.slice(0, 4).join('')}-${hexArray.slice(4, 6).join('')}-${hexArray.slice(6, 8).join('')}-${hexArray.slice(8, 10).join('')}-${hexArray.slice(10, 16).join('')}`; +} + export function generateId() { - return crypto.randomUUID(); + try { + return crypto?.randomUUID(); + } catch { + console.warn('crypto.randomUUID() is not available in this environment. Falling back to a custom UUID generator.'); + return fallbackGenerateId(); + } } From 4b126aa8d7f9b89badc6306e8b4d1088377ae772 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Thu, 3 Sep 2026 14:47:50 +0200 Subject: [PATCH 2/4] chore(sdk): documenting the changes --- .../sdk-palette-mobile-drag-and-drop.md | 5 ++ .../sdk/src/features/diagram/diagram.spec.tsx | 2 +- .../ai-tools-control/create-ai-tool.ts | 3 +- .../hooks/use-palette-drag-and-drop.tsx | 1 - .../palette/hooks/use-palette-drop.spec.tsx | 77 +++++++++++++++---- .../palette/hooks/use-palette-drop.ts | 2 +- .../utils/get-empty-variable-definition.ts | 3 +- packages/sdk/src/utils/generate-id.ts | 6 ++ 8 files changed, 79 insertions(+), 20 deletions(-) create mode 100644 .changeset/sdk-palette-mobile-drag-and-drop.md diff --git a/.changeset/sdk-palette-mobile-drag-and-drop.md b/.changeset/sdk-palette-mobile-drag-and-drop.md new file mode 100644 index 000000000..2c32537dd --- /dev/null +++ b/.changeset/sdk-palette-mobile-drag-and-drop.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/sdk': patch +--- + +Dragging a node from the palette onto the canvas now works on touch devices; the palette hands the dragged item over through pointer events instead of the HTML5 drag `dataTransfer`, which mobile browsers never deliver. Ids the editor generates for nodes, AI agent tools and variables also fall back to a `crypto.getRandomValues()`-based UUID when `crypto.randomUUID()` is unavailable, e.g. when the editor is opened over plain HTTP from a LAN address. The `draggedItem` and `setDraggedItem` fields are gone from the editor store returned by `useStore`; the in-flight palette item is internal to the palette now. diff --git a/packages/sdk/src/features/diagram/diagram.spec.tsx b/packages/sdk/src/features/diagram/diagram.spec.tsx index b69eaa1f8..5fc6dc69c 100644 --- a/packages/sdk/src/features/diagram/diagram.spec.tsx +++ b/packages/sdk/src/features/diagram/diagram.spec.tsx @@ -31,7 +31,7 @@ vi.mock('./hooks/use-on-connect', () => ({ useConnect: () => ({ onConnect: vi.fn(), onConnectStart: vi.fn(), onConnectEnd: vi.fn() }), })); vi.mock('./edges/temporary-edge/temporary-edge', () => ({ TemporaryEdge: () => null })); -vi.mock('../../hooks/use-palette-drop', () => ({ usePaletteDrop: () => ({ onDropFromPalette: vi.fn() }) })); +vi.mock('../palette/hooks/use-palette-drop', () => ({ usePaletteDrop: () => ({ onDropFromPalette: vi.fn() }) })); vi.mock('../modals/delete-confirmation/use-delete-confirmation', () => ({ useDeleteConfirmation: () => ({ openDeleteConfirmationModal: vi.fn() }), })); diff --git a/packages/sdk/src/features/json-form/controls/ai-tools-control/create-ai-tool.ts b/packages/sdk/src/features/json-form/controls/ai-tools-control/create-ai-tool.ts index 49fff0cc8..9037c4923 100644 --- a/packages/sdk/src/features/json-form/controls/ai-tools-control/create-ai-tool.ts +++ b/packages/sdk/src/features/json-form/controls/ai-tools-control/create-ai-tool.ts @@ -1,3 +1,4 @@ +import { generateId } from '../../../../utils/generate-id'; import { getHandleId } from '../../../diagram/handles/get-handle-id'; import type { AiAgentTool } from '../../types/controls'; @@ -6,7 +7,7 @@ export function hasAnyValue(data: AiAgentTool): boolean { } export function createAiTool(toolData: AiAgentTool): AiAgentTool { - const id = crypto.randomUUID(); + const id = generateId(); const sourceHandle = getHandleId({ innerId: id, handleType: 'source' }); return { ...toolData, id, sourceHandle }; diff --git a/packages/sdk/src/features/palette/hooks/use-palette-drag-and-drop.tsx b/packages/sdk/src/features/palette/hooks/use-palette-drag-and-drop.tsx index a83616b15..0cde7a3ff 100644 --- a/packages/sdk/src/features/palette/hooks/use-palette-drag-and-drop.tsx +++ b/packages/sdk/src/features/palette/hooks/use-palette-drag-and-drop.tsx @@ -6,7 +6,6 @@ import { setDraggedItem } from '../stores/use-palette-store'; export function usePaletteDragAndDrop(canDrag: boolean) { const zoom = useStore((state) => state.reactFlowInstance?.getZoom() || 1); - const ref = useRef(null); function onPointerDown(event: React.PointerEvent, item: PaletteItem) { diff --git a/packages/sdk/src/features/palette/hooks/use-palette-drop.spec.tsx b/packages/sdk/src/features/palette/hooks/use-palette-drop.spec.tsx index aabb29010..8f9ffda5c 100644 --- a/packages/sdk/src/features/palette/hooks/use-palette-drop.spec.tsx +++ b/packages/sdk/src/features/palette/hooks/use-palette-drop.spec.tsx @@ -1,21 +1,23 @@ -// Pins what a palette drop writes into the new node's `data` (WB-430). The -// `isStartNode` flag is what execution integrations read to find a workflow's -// entry point, so it has to survive the palette-item -> node-data copy — and -// stay absent on every node that did not declare it. +// Pins what a palette drop does with the item the user is dragging. The drop +// no longer reads `dataTransfer` (which never fires on touch devices): the +// palette item is parked in the palette store on pointer-down and picked up +// here on drop. The `isStartNode` flag is what execution integrations read to +// find a workflow's entry point, so it has to survive the palette-item -> +// node-data copy — and stay absent on every node that did not declare it. // // `@xyflow/react` is mocked for `useStoreApi`: the hook reads // `resetSelectedElements` off the ReactFlow store, which only exists inside a // ``. `use-translate-if-possible` is mocked to keep the // i18next singleton out of the test. import { renderHook } from '@testing-library/react'; -import type { NodeAddChange } from '@xyflow/react'; +import type { NodeAddChange, XYPosition } from '@xyflow/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { PaletteItem } from '../../../node/common'; import type { WorkflowBuilderNode } from '../../../node/node-data'; import { NodeType } from '../../../node/node-types'; import { resetWorkflowStore, useStore } from '../../../store/store'; -import { dataFormat } from '../../../utils/consts'; +import { setDraggedItem, usePaletteStore } from '../stores/use-palette-store'; import { usePaletteDrop } from './use-palette-drop'; vi.mock('@xyflow/react', () => ({ @@ -23,11 +25,11 @@ vi.mock('@xyflow/react', () => ({ })); const { noTranslation } = vi.hoisted(() => ({ noTranslation: () => '' })); -vi.mock('./use-translate-if-possible', () => ({ +vi.mock('../../../hooks/use-translate-if-possible', () => ({ useTranslateIfPossible: () => noTranslation, })); -vi.mock('../features/changes-tracker/stores/use-changes-tracker-store', () => ({ +vi.mock('../../changes-tracker/stores/use-changes-tracker-store', () => ({ trackFutureChange: vi.fn(), })); @@ -45,32 +47,77 @@ function paletteItem(overrides: Partial = {}): PaletteItem { } as PaletteItem; } -function dropFromPalette(definition: PaletteItem) { +type DropOptions = { + draggedItem?: PaletteItem | null; + clientPosition?: XYPosition; + screenToFlowPosition?: (position: XYPosition) => XYPosition; +}; + +function drop(definition: PaletteItem, options: DropOptions = {}) { + const { + draggedItem = definition, + clientPosition = { x: 0, y: 0 }, + screenToFlowPosition = () => ({ x: 0, y: 0 }), + } = options; + const onNodesChange = vi.fn(); useStore.setState({ getNodeDefinition: () => definition, onNodesChange, // eslint-disable-next-line @typescript-eslint/no-explicit-any - reactFlowInstance: { screenToFlowPosition: () => ({ x: 0, y: 0 }) } as any, + reactFlowInstance: { screenToFlowPosition } as any, }); + setDraggedItem(draggedItem); const { result } = renderHook(() => usePaletteDrop()); result.current.onDropFromPalette({ preventDefault: vi.fn(), - clientX: 0, - clientY: 0, - dataTransfer: { - getData: (format: string) => (format === dataFormat ? JSON.stringify({ type: definition.type }) : ''), - }, + clientX: clientPosition.x, + clientY: clientPosition.y, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); + return onNodesChange; +} + +function dropFromPalette(definition: PaletteItem, options: DropOptions = {}) { + const onNodesChange = drop(definition, options); const [changes] = onNodesChange.mock.calls[0] as [NodeAddChange[]]; return changes[0]!.item; } beforeEach(() => { resetWorkflowStore(); + setDraggedItem(null); +}); + +describe('usePaletteDrop — dragged item handoff', () => { + it('adds the node the palette store says is being dragged', () => { + const node = dropFromPalette(paletteItem()); + + expect(node.data.type).toBe(TRIGGER_TYPE); + }); + + it('does nothing when no palette item is being dragged', () => { + const onNodesChange = drop(paletteItem(), { draggedItem: null }); + + expect(onNodesChange).not.toHaveBeenCalled(); + }); + + it('clears the dragged item once the drop is handled', () => { + dropFromPalette(paletteItem()); + + expect(usePaletteStore.getState().draggedItem).toBeNull(); + }); + + it('places the node at the flow position of the pointer', () => { + const node = dropFromPalette(paletteItem(), { + clientPosition: { x: 10, y: 20 }, + screenToFlowPosition: ({ x, y }) => ({ x: x + 100, y: y + 200 }), + }); + + expect(node.position).toEqual({ x: 110, y: 220 }); + }); }); describe('usePaletteDrop — start-node flag', () => { diff --git a/packages/sdk/src/features/palette/hooks/use-palette-drop.ts b/packages/sdk/src/features/palette/hooks/use-palette-drop.ts index 28fff4675..bce82c63c 100644 --- a/packages/sdk/src/features/palette/hooks/use-palette-drop.ts +++ b/packages/sdk/src/features/palette/hooks/use-palette-drop.ts @@ -73,8 +73,8 @@ export function usePaletteDrop() { const { type } = draggedItem; - dropNode(position, type); setDraggedItem(null); + dropNode(position, type); }, [reactFlowInstance, dropNode], ); diff --git a/packages/sdk/src/features/variables/utils/get-empty-variable-definition.ts b/packages/sdk/src/features/variables/utils/get-empty-variable-definition.ts index dd17a0373..37228ec91 100644 --- a/packages/sdk/src/features/variables/utils/get-empty-variable-definition.ts +++ b/packages/sdk/src/features/variables/utils/get-empty-variable-definition.ts @@ -1,8 +1,9 @@ +import { generateId } from '../../../utils/generate-id'; import type { VariableDefinition } from '../types'; export function getEmptyVariableDefinition(): VariableDefinition { return { - id: crypto.randomUUID(), + id: generateId(), name: '', description: '', type: 'string', diff --git a/packages/sdk/src/utils/generate-id.ts b/packages/sdk/src/utils/generate-id.ts index f3b2175b7..2582ed77f 100644 --- a/packages/sdk/src/utils/generate-id.ts +++ b/packages/sdk/src/utils/generate-id.ts @@ -8,6 +8,12 @@ function fallbackGenerateId(): `${string}-${string}-${string}-${string}-${string return `${hexArray.slice(0, 4).join('')}-${hexArray.slice(4, 6).join('')}-${hexArray.slice(6, 8).join('')}-${hexArray.slice(8, 10).join('')}-${hexArray.slice(10, 16).join('')}`; } +// `crypto.randomUUID()` exists only in secure contexts (HTTPS or localhost). +// Opening the dev server from another device over the LAN IP, e.g. +// http://192.168.1.10:4200 on a phone, is an insecure context: `crypto` is +// there but `randomUUID` is undefined and the call throws. The fallback builds +// the same v4 UUID from `crypto.getRandomValues()`, which insecure contexts +// still provide. export function generateId() { try { return crypto?.randomUUID(); From 6c9523fc56cc26b385334277395aa6d16dc6f398 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Thu, 3 Sep 2026 14:49:08 +0200 Subject: [PATCH 3/4] chore(sdk): documenting the changes --- packages/sdk/src/utils/generate-id.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/utils/generate-id.ts b/packages/sdk/src/utils/generate-id.ts index 2582ed77f..ebe40a99a 100644 --- a/packages/sdk/src/utils/generate-id.ts +++ b/packages/sdk/src/utils/generate-id.ts @@ -1,8 +1,10 @@ function fallbackGenerateId(): `${string}-${string}-${string}-${string}-${string}` { const array = new Uint8Array(16); crypto.getRandomValues(array); - array[6] = (array[6] & 0x0F) | 0x40; // Set version to 4 - array[8] = (array[8] & 0x3F) | 0x80; // Set variant to RFC4122 + // eslint-disable-next-line unicorn/number-literal-case + array[6] = (array[6] & 0x0f) | 0x40; // Set version to 4 + // eslint-disable-next-line unicorn/number-literal-case + array[8] = (array[8] & 0x3f) | 0x80; // Set variant to RFC4122 const hexArray = Array.from(array, (byte) => byte.toString(16).padStart(2, '0')); return `${hexArray.slice(0, 4).join('')}-${hexArray.slice(4, 6).join('')}-${hexArray.slice(6, 8).join('')}-${hexArray.slice(8, 10).join('')}-${hexArray.slice(10, 16).join('')}`; From eed812be95c10aef865a2f3b0812da6d11700657 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Thu, 3 Sep 2026 15:13:58 +0200 Subject: [PATCH 4/4] chore(sdk): unused dataFormat was removed --- packages/sdk/src/utils/consts.ts | 1 - 1 file changed, 1 deletion(-) delete mode 100644 packages/sdk/src/utils/consts.ts diff --git a/packages/sdk/src/utils/consts.ts b/packages/sdk/src/utils/consts.ts deleted file mode 100644 index b99e59867..000000000 --- a/packages/sdk/src/utils/consts.ts +++ /dev/null @@ -1 +0,0 @@ -export const dataFormat = 'application/reactflow';