Skip to content
Open
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/main/frontend/app/routes/settings/settings-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const defaultEditorSettings: EditorSettings = {
const defaultStudioSettings: StudioSettings = {
previewOnSave: true,
autoRefresh: true,
gradient: true,
gradient: false,
}

const defaultProjectSettings: ProjectSettings = {
Expand Down
1 change: 1 addition & 0 deletions src/main/frontend/app/routes/studio/canvas/flow.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export const FlowConfig = {
STICKY_NOTE_DEFAULT_WIDTH: 200,
STICKY_NOTE_DEFAULT_HEIGHT: 200,
COPY_PASTE_OFFSET: 40,
MAX_HISTORY: 20, // Adjust this value as needed to limit the number of undo steps
}
80 changes: 68 additions & 12 deletions src/main/frontend/app/routes/studio/canvas/flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
ReactFlow,
ReactFlowProvider,
useReactFlow,
useUpdateNodeInternals,
} from '@xyflow/react'
import Dagre from '@dagrejs/dagre'
import '@xyflow/react/dist/style.css'
Expand All @@ -31,7 +32,7 @@
import CreateNodeModal from '~/components/flow/create-node-modal'
import { useProjectStore } from '~/stores/project-store'
import { saveAdapter } from '~/services/adapter-service'
import { cloneWithRemappedIds } from '~/utils/flow-utils'
import { cloneWithRemappedIds, restoreTemporalForTab, saveTemporalForTab } from '~/utils/flow-utils'

Check warning on line 35 in src/main/frontend/app/routes/studio/canvas/flow.tsx

View workflow job for this annotation

GitHub Actions / Build & Run All Tests

'saveTemporalForTab' is defined but never used
import { showErrorToast, showSuccessToast } from '~/components/toast'

export type FlowNode = FrankNodeType | ExitNode | StickyNote | GroupNode | Node
Expand All @@ -53,10 +54,11 @@

function FlowCanvas({ showNodeContextMenu }: Readonly<{ showNodeContextMenu: (b: boolean) => void }>) {
const [loading, setLoading] = useState(false)
const { isEditing, setIsEditing, setParentId, setDraggedName } = useNodeContextStore(
const { isEditing, setIsEditing, setIsNewNode, setParentId, setDraggedName } = useNodeContextStore(
useShallow((s) => ({
isEditing: s.isEditing,
setIsEditing: s.setIsEditing,
setIsNewNode: s.setIsNewNode,
setParentId: s.setParentId,
setDraggedName: s.setDraggedName,
})),
Expand All @@ -75,6 +77,7 @@
groupNode: GroupNodeComponent,
}
const edgeTypes = { frankEdge: FrankEdgeComponent }
const updateNodeInternals = useUpdateNodeInternals()
const reactFlow = useReactFlow()
const reactFlowRef = useRef(reactFlow)
reactFlowRef.current = reactFlow
Expand Down Expand Up @@ -355,6 +358,24 @@
pasteSelection()
}

if (isCmdOrCtrl && event.key.toLowerCase() === 'z') {
event.preventDefault()
// Redo if Shift is also pressed, otherwise undo
const temporal = useFlowStore.temporal.getState()
if (event.shiftKey) {
temporal.redo()
} else {
temporal.undo()
}
}

// Or redo with Cmd/Ctrl + Y, which is common on Windows
if (isCmdOrCtrl && event.key.toLowerCase() === 'y') {
event.preventDefault()
const temporal = useFlowStore.temporal.getState()
temporal.redo()
}

if (event.key === 'g' || event.key === 'G') {
event.preventDefault()
handleGrouping()
Comment on lines 361 to 381
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the only component looking for key or mouse events?
I'm thinking that maybe having an overarching system to manage key and mouse event handling could be nice since a lot of repetition might happen if this is used in multiple components

I can elaborate further but maybe its better to make an issue and let it be like this for this PR, if you agree with me here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think it would be nice to have something like that that you can apply to several components. As of right now, the flow.tsx file is generally the only component to have this level of listeners, but in the future the editor could have that aswell for instance, or maybe even the datamapper.

Expand Down Expand Up @@ -428,6 +449,7 @@
sourceInfo?: { nodeId: string | null; handleId: string | null; handleType: 'source' | 'target' | null },
) {
showNodeContextMenu(true)
setIsNewNode(true)
setIsEditing(true)

const flowStore = useFlowStore.getState()
Expand Down Expand Up @@ -499,6 +521,13 @@
function restoreFlowFromTab(tabId: string) {
const tabStore = useTabStore.getState()
const flowStore = useFlowStore.getState()
const temporal = useFlowStore.temporal.getState()

// Pause tracking while loading
temporal.pause()

// Clear current history
temporal.clear()

const tabData = tabStore.getTab(tabId)
const flowJson = tabData?.flowJson
Expand All @@ -507,19 +536,22 @@
flowStore.setNodes(Array.isArray(flowJson.nodes) ? flowJson.nodes : [])
flowStore.setEdges(Array.isArray(flowJson.edges) ? flowJson.edges : [])
const viewport = flowJson.viewport as { x: number; y: number; zoom: number } | undefined
flowStore.setViewport(viewport && true ? viewport : { x: 0, y: 0, zoom: 1 })
flowStore.setViewport(viewport ?? { x: 0, y: 0, zoom: 1 })

// Restore undoState safely (no clear/pause)
restoreTemporalForTab(tabId)
} else {
flowStore.setNodes([])
flowStore.setEdges([])
flowStore.setViewport({ x: 0, y: 0, zoom: 1 })
}

temporal.resume()
}

function clearFlow() {
const flowStore = useFlowStore.getState()
flowStore.setNodes([])
flowStore.setEdges([])
flowStore.setViewport({ x: 0, y: 0, zoom: 1 })
useFlowStore.getState().setNodes([])
useFlowStore.getState().setEdges([])
useFlowStore.temporal.getState().clear()
}

async function loadFlowFromTab(tab: TabData) {
Expand Down Expand Up @@ -549,18 +581,19 @@
function saveFlowToTab(tabId: string) {
const tabStore = useTabStore.getState()
const flowStore = useFlowStore.getState()
const temporal = useFlowStore.temporal.getState()

const flowData = reactFlowRef.current.toObject()
const viewport = flowStore.viewport
const tabData = tabStore.getTab(tabId)

if (!tabData) return

tabStore.setTabData(tabId, {
...tabData,
flowJson: {
...flowData,
viewport,
flowJson: { ...flowData, viewport },
undoState: {
past: structuredClone(temporal.pastStates),
future: structuredClone(temporal.futureStates),
},
})
}
Expand Down Expand Up @@ -615,6 +648,29 @@
}
}

// Listen for node data changes to trigger internals update for connected edges and handles
// Added for undo/redo and direct data changes to ensure handles stay positioned properly
useEffect(() => {
const unsub = useFlowStore.subscribe(
(state) => state.nodes, // selector: subscribe only to nodes
(newNodes, oldNodes) => {
if (!reactFlowRef.current || !oldNodes) return

// Compare old vs new node data
for (const node of newNodes) {
const oldNode = oldNodes.find((n) => n.id === node.id)
if (!oldNode) continue

if (oldNode.data !== node.data) {
updateNodeInternals(node.id)
}
}
},
)

return () => unsub()
}, [updateNodeInternals])

return (
<div
className="relative h-full w-full"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ export default function FrankNode(properties: NodeProps<FrankNodeType>) {
const [canDropDraggedElement, setCanDropDraggedElement] = useState(false)
const showNodeContextMenu = useNodeContextMenu()
const { elements, filters } = useFrankDoc()
const { setNodeId, setAttributes, setParentId, setIsEditing, setDraggedName, draggedName } = useNodeContextStore()
const { setNodeId, setIsNewNode, setAttributes, setParentId, setIsEditing, setDraggedName, draggedName } =
useNodeContextStore()
const gradientEnabled = useSettingsStore((state) => state.studio.gradient)
// Store the associated Frank element
const frankElement = useMemo(() => {
Expand Down Expand Up @@ -230,6 +231,7 @@ export default function FrankNode(properties: NodeProps<FrankNodeType>) {
return
}

setIsNewNode(true)
showNodeContextMenu(true)
setIsEditing(true)
setParentId(properties.id)
Expand All @@ -248,6 +250,7 @@ export default function FrankNode(properties: NodeProps<FrankNodeType>) {
[
setDraggedName,
canAcceptChild,
setIsNewNode,
showNodeContextMenu,
setIsEditing,
setParentId,
Expand Down
45 changes: 34 additions & 11 deletions src/main/frontend/app/routes/studio/context/node-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,18 @@ export default function NodeContext({
const [inputValues, setInputValues] = useState<Record<number, string>>({})

const { elements, ffDoc } = useFrankDoc()
const { attributes, setIsEditing, parentId, setParentId, childParentId } = useNodeContextStore(
useShallow((s) => ({
attributes: s.attributes,
setIsEditing: s.setIsEditing,
parentId: s.parentId,
setParentId: s.setParentId,
childParentId: s.childParentId,
})),
)
const { attributes, isNewNode, setIsEditing, setIsNewNode, parentId, setParentId, childParentId } =
useNodeContextStore(
useShallow((s) => ({
attributes: s.attributes,
isNewNode: s.isNewNode,
setIsEditing: s.setIsEditing,
setIsNewNode: s.setIsNewNode,
parentId: s.parentId,
setParentId: s.setParentId,
childParentId: s.childParentId,
})),
)

const validateMandatoryFields = useCallback(
(validations: Record<number, string>) => {
Expand Down Expand Up @@ -192,22 +195,30 @@ export default function NodeContext({
const parentNode = nodes.find((n) => n.id === parentId.toString())
if (!parentNode || !isFrankNode(parentNode)) return

// 🔍 Find the child recursively
// Find the child recursively
const existingChild = findChildRecursive(parentNode.data.children, nodeId.toString())

if (!existingChild) {
console.error('ERROR: Could not find child to update:', nodeId)
return
}

// Build updated child (preserves type, subtype, children, etc.)
// Build updated child (preserves type, subtype, children, etc.)
const updatedChild = {
...existingChild,
...(nameField && { name: nameField.value }),
attributes: newAttributesObject,
}

// Update child recursively in store
if (isNewNode) {
updateChild(parentNode.id, updatedChild, { isNewNode: true })
setIsNewNode(false)
setIsEditing(false)
setShowNodeContext(false)
setParentId(null)
return
}
updateChild(parentNode.id, updatedChild)

// Close context
Expand All @@ -218,6 +229,17 @@ export default function NodeContext({
}

// Else: updating a top-level Frank node
// Set attributes with newNode flag to keep the adding of a node a single action for the undo stack, instead of add node + set attributes being two separate actions
if (isNewNode) {
setAttributes(nodeId.toString(), newAttributesObject, { isNewNode: true })
if (nameField) {
setNodeName(nodeId.toString(), nameField.value, { isNewNode: true })
}
setIsNewNode(false)
setIsEditing(false)
setShowNodeContext(false)
return
}
setAttributes(nodeId.toString(), newAttributesObject)

if (nameField) {
Expand All @@ -238,6 +260,7 @@ export default function NodeContext({
}
deleteNode(nodeId.toString())
setIsEditing(false)
setIsNewNode(false)
setShowNodeContext(false)
}

Expand Down
Loading
Loading