From e64f3e881e0cb7c9b80adb78fad858bb322dcb4a Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Sun, 19 Oct 2025 21:12:41 +1100 Subject: [PATCH] Initial commit moved from private version control --- .gitignore | 2 + BlueprintWorkspace.outline.txt | 128 + electron-main.js | 104 + index.html | 147 + package-lock.json | 871 +++ package.json | 12 + preload.js | 22 + scripts/core/BlueprintNode.js | 74 + scripts/core/Connection.js | 46 + scripts/core/LuaGenerator.js | 1181 +++ scripts/core/NodeGraph.js | 355 + scripts/main.js | 469 ++ scripts/nodes/NodeRegistry.js | 296 + scripts/nodes/library/addNumber.js | 41 + scripts/nodes/library/audio/index.js | 16 + scripts/nodes/library/audio/music.js | 64 + scripts/nodes/library/audio/sfx.js | 76 + scripts/nodes/library/booleanLiteral.js | 26 + scripts/nodes/library/callCustomEvent.js | 32 + scripts/nodes/library/compare.js | 47 + scripts/nodes/library/customEvent.js | 44 + scripts/nodes/library/data/cartdata.js | 33 + scripts/nodes/library/data/dget.js | 33 + scripts/nodes/library/data/dset.js | 43 + scripts/nodes/library/data/index.js | 18 + scripts/nodes/library/devkit/devkitConfig.js | 35 + .../nodes/library/devkit/devkitInputRead.js | 33 + .../nodes/library/devkit/devkitInputWrite.js | 43 + scripts/nodes/library/devkit/index.js | 22 + scripts/nodes/library/eventDraw.js | 26 + scripts/nodes/library/eventStart.js | 26 + scripts/nodes/library/eventUpdate.js | 25 + scripts/nodes/library/forLoop.js | 92 + scripts/nodes/library/getLocalVariable.js | 0 scripts/nodes/library/getVariable.js | 30 + scripts/nodes/library/gpio/gpioRead.js | 33 + scripts/nodes/library/gpio/gpioWrite.js | 43 + scripts/nodes/library/gpio/index.js | 16 + scripts/nodes/library/graphics/camera.js | 37 + scripts/nodes/library/graphics/circ.js | 57 + scripts/nodes/library/graphics/circfill.js | 57 + scripts/nodes/library/graphics/clip.js | 63 + scripts/nodes/library/graphics/cls.js | 32 + scripts/nodes/library/graphics/color.js | 32 + scripts/nodes/library/graphics/cursor.js | 46 + scripts/nodes/library/graphics/fget.js | 39 + scripts/nodes/library/graphics/fillp.js | 31 + scripts/nodes/library/graphics/fset.js | 52 + scripts/nodes/library/graphics/index.js | 88 + scripts/nodes/library/graphics/line.js | 60 + scripts/nodes/library/graphics/oval.js | 65 + scripts/nodes/library/graphics/ovalfill.js | 65 + scripts/nodes/library/graphics/pal.js | 51 + scripts/nodes/library/graphics/palt.js | 47 + scripts/nodes/library/graphics/pget.js | 41 + scripts/nodes/library/graphics/pset.js | 50 + scripts/nodes/library/graphics/rect.js | 65 + scripts/nodes/library/graphics/rectfill.js | 65 + scripts/nodes/library/graphics/rrect.js | 73 + scripts/nodes/library/graphics/rrectfill.js | 73 + scripts/nodes/library/graphics/sget.js | 42 + scripts/nodes/library/graphics/spr.js | 76 + scripts/nodes/library/graphics/sset.js | 51 + scripts/nodes/library/graphics/sspr.js | 100 + scripts/nodes/library/ifNode.js | 67 + scripts/nodes/library/index.js | 87 + scripts/nodes/library/input/btn.js | 55 + scripts/nodes/library/input/btnp.js | 56 + .../nodes/library/input/controllerButtons.js | 175 + scripts/nodes/library/input/index.js | 17 + scripts/nodes/library/localVariable.js | 0 scripts/nodes/library/localVariables.js | 169 + scripts/nodes/library/lua/coroutineCreate.js | 38 + scripts/nodes/library/lua/coroutineResume.js | 91 + scripts/nodes/library/lua/coroutineRunning.js | 27 + scripts/nodes/library/lua/coroutineStatus.js | 32 + scripts/nodes/library/lua/coroutineYield.js | 53 + scripts/nodes/library/lua/getMetatable.js | 27 + scripts/nodes/library/lua/index.js | 63 + scripts/nodes/library/lua/rawEqual.js | 29 + scripts/nodes/library/lua/rawGet.js | 29 + scripts/nodes/library/lua/rawSet.js | 33 + scripts/nodes/library/lua/setMetatable.js | 35 + scripts/nodes/library/map/index.js | 20 + scripts/nodes/library/map/map.js | 108 + scripts/nodes/library/map/mget.js | 41 + scripts/nodes/library/map/mset.js | 51 + scripts/nodes/library/map/tline.js | 157 + scripts/nodes/library/math/abs.js | 25 + scripts/nodes/library/math/atan2.js | 30 + scripts/nodes/library/math/band.js | 29 + scripts/nodes/library/math/bnot.js | 25 + scripts/nodes/library/math/bor.js | 29 + scripts/nodes/library/math/bxor.js | 29 + scripts/nodes/library/math/ceil.js | 25 + scripts/nodes/library/math/cos.js | 26 + scripts/nodes/library/math/div.js | 29 + scripts/nodes/library/math/flr.js | 25 + scripts/nodes/library/math/index.js | 79 + scripts/nodes/library/math/lshr.js | 29 + scripts/nodes/library/math/max.js | 29 + scripts/nodes/library/math/mid.js | 31 + scripts/nodes/library/math/min.js | 29 + scripts/nodes/library/math/rnd.js | 25 + scripts/nodes/library/math/rotl.js | 29 + scripts/nodes/library/math/rotr.js | 29 + scripts/nodes/library/math/shl.js | 29 + scripts/nodes/library/math/shr.js | 29 + scripts/nodes/library/math/sin.js | 26 + scripts/nodes/library/math/sqrt.js | 25 + scripts/nodes/library/math/srand.js | 29 + scripts/nodes/library/memory/cstore.js | 67 + scripts/nodes/library/memory/index.js | 43 + scripts/nodes/library/memory/memcpy.js | 51 + scripts/nodes/library/memory/memset.js | 51 + scripts/nodes/library/memory/peek.js | 45 + scripts/nodes/library/memory/peek2.js | 33 + scripts/nodes/library/memory/peek4.js | 33 + scripts/nodes/library/memory/poke.js | 67 + scripts/nodes/library/memory/poke2.js | 43 + scripts/nodes/library/memory/poke4.js | 43 + scripts/nodes/library/memory/reload.js | 67 + scripts/nodes/library/menu/index.js | 14 + scripts/nodes/library/menu/menuItem.js | 63 + scripts/nodes/library/multiplyNumber.js | 41 + scripts/nodes/library/numberLiteral.js | 26 + scripts/nodes/library/print.js | 59 + scripts/nodes/library/sequence.js | 74 + scripts/nodes/library/serial/index.js | 16 + scripts/nodes/library/serial/serialReceive.js | 33 + scripts/nodes/library/serial/serialSend.js | 43 + scripts/nodes/library/setLocalVariable.js | 0 scripts/nodes/library/setVariable.js | 44 + scripts/nodes/library/stringLiteral.js | 26 + scripts/nodes/library/strings/chr.js | 43 + scripts/nodes/library/strings/index.js | 34 + scripts/nodes/library/strings/ord.js | 55 + scripts/nodes/library/strings/split.js | 54 + scripts/nodes/library/strings/sub.js | 48 + scripts/nodes/library/strings/tonum.js | 39 + scripts/nodes/library/strings/tostr.js | 39 + scripts/nodes/library/strings/typeOf.js | 25 + scripts/nodes/library/system/assert.js | 41 + scripts/nodes/library/system/extcmd.js | 93 + scripts/nodes/library/system/flip.js | 27 + scripts/nodes/library/system/folder.js | 26 + scripts/nodes/library/system/index.js | 61 + scripts/nodes/library/system/info.js | 27 + scripts/nodes/library/system/load.js | 57 + scripts/nodes/library/system/ls.js | 33 + scripts/nodes/library/system/printh.js | 70 + scripts/nodes/library/system/reboot.js | 26 + scripts/nodes/library/system/reset.js | 27 + scripts/nodes/library/system/resume.js | 26 + scripts/nodes/library/system/run.js | 33 + scripts/nodes/library/system/save.js | 36 + scripts/nodes/library/system/stat.js | 31 + scripts/nodes/library/system/stop.js | 32 + scripts/nodes/library/system/time.js | 23 + scripts/nodes/library/table/add.js | 46 + scripts/nodes/library/table/all.js | 25 + scripts/nodes/library/table/count.js | 40 + scripts/nodes/library/table/del.js | 31 + scripts/nodes/library/table/deli.js | 42 + scripts/nodes/library/table/foreach.js | 116 + scripts/nodes/library/table/index.js | 34 + scripts/nodes/library/table/pairs.js | 25 + scripts/nodes/nodeTypes.js | 91 + scripts/tools/generateOutline.js | 234 + scripts/ui/BlueprintWorkspace.js | 6562 +++++++++++++++++ scripts/ui/BlueprintWorkspaceConstants.js | 5 + scripts/ui/WorkspaceSpawnShortcuts.js | 72 + .../ui/workspace/WorkspaceClipboardManager.js | 289 + .../workspace/WorkspaceContextMenuManager.js | 620 ++ scripts/ui/workspace/WorkspaceDragManager.js | 652 ++ scripts/ui/workspace/WorkspaceDropManager.js | 326 + scripts/ui/workspace/WorkspaceGeometry.js | 85 + .../ui/workspace/WorkspaceHistoryManager.js | 310 + .../workspace/WorkspaceInlineEditorManager.js | 941 +++ .../ui/workspace/WorkspaceMarqueeManager.js | 228 + .../workspace/WorkspaceNavigationManager.js | 597 ++ scripts/ui/workspace/WorkspaceNodeRenderer.js | 420 ++ .../ui/workspace/WorkspacePaletteManager.js | 295 + .../workspace/WorkspacePersistenceManager.js | 213 + scripts/ui/workspace/renderEventList.js | 108 + scripts/ui/workspace/renderVariableList.js | 442 ++ styles/main.css | 2323 ++++++ 187 files changed, 25372 insertions(+) create mode 100644 .gitignore create mode 100644 BlueprintWorkspace.outline.txt create mode 100644 electron-main.js create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 preload.js create mode 100644 scripts/core/BlueprintNode.js create mode 100644 scripts/core/Connection.js create mode 100644 scripts/core/LuaGenerator.js create mode 100644 scripts/core/NodeGraph.js create mode 100644 scripts/main.js create mode 100644 scripts/nodes/NodeRegistry.js create mode 100644 scripts/nodes/library/addNumber.js create mode 100644 scripts/nodes/library/audio/index.js create mode 100644 scripts/nodes/library/audio/music.js create mode 100644 scripts/nodes/library/audio/sfx.js create mode 100644 scripts/nodes/library/booleanLiteral.js create mode 100644 scripts/nodes/library/callCustomEvent.js create mode 100644 scripts/nodes/library/compare.js create mode 100644 scripts/nodes/library/customEvent.js create mode 100644 scripts/nodes/library/data/cartdata.js create mode 100644 scripts/nodes/library/data/dget.js create mode 100644 scripts/nodes/library/data/dset.js create mode 100644 scripts/nodes/library/data/index.js create mode 100644 scripts/nodes/library/devkit/devkitConfig.js create mode 100644 scripts/nodes/library/devkit/devkitInputRead.js create mode 100644 scripts/nodes/library/devkit/devkitInputWrite.js create mode 100644 scripts/nodes/library/devkit/index.js create mode 100644 scripts/nodes/library/eventDraw.js create mode 100644 scripts/nodes/library/eventStart.js create mode 100644 scripts/nodes/library/eventUpdate.js create mode 100644 scripts/nodes/library/forLoop.js create mode 100644 scripts/nodes/library/getLocalVariable.js create mode 100644 scripts/nodes/library/getVariable.js create mode 100644 scripts/nodes/library/gpio/gpioRead.js create mode 100644 scripts/nodes/library/gpio/gpioWrite.js create mode 100644 scripts/nodes/library/gpio/index.js create mode 100644 scripts/nodes/library/graphics/camera.js create mode 100644 scripts/nodes/library/graphics/circ.js create mode 100644 scripts/nodes/library/graphics/circfill.js create mode 100644 scripts/nodes/library/graphics/clip.js create mode 100644 scripts/nodes/library/graphics/cls.js create mode 100644 scripts/nodes/library/graphics/color.js create mode 100644 scripts/nodes/library/graphics/cursor.js create mode 100644 scripts/nodes/library/graphics/fget.js create mode 100644 scripts/nodes/library/graphics/fillp.js create mode 100644 scripts/nodes/library/graphics/fset.js create mode 100644 scripts/nodes/library/graphics/index.js create mode 100644 scripts/nodes/library/graphics/line.js create mode 100644 scripts/nodes/library/graphics/oval.js create mode 100644 scripts/nodes/library/graphics/ovalfill.js create mode 100644 scripts/nodes/library/graphics/pal.js create mode 100644 scripts/nodes/library/graphics/palt.js create mode 100644 scripts/nodes/library/graphics/pget.js create mode 100644 scripts/nodes/library/graphics/pset.js create mode 100644 scripts/nodes/library/graphics/rect.js create mode 100644 scripts/nodes/library/graphics/rectfill.js create mode 100644 scripts/nodes/library/graphics/rrect.js create mode 100644 scripts/nodes/library/graphics/rrectfill.js create mode 100644 scripts/nodes/library/graphics/sget.js create mode 100644 scripts/nodes/library/graphics/spr.js create mode 100644 scripts/nodes/library/graphics/sset.js create mode 100644 scripts/nodes/library/graphics/sspr.js create mode 100644 scripts/nodes/library/ifNode.js create mode 100644 scripts/nodes/library/index.js create mode 100644 scripts/nodes/library/input/btn.js create mode 100644 scripts/nodes/library/input/btnp.js create mode 100644 scripts/nodes/library/input/controllerButtons.js create mode 100644 scripts/nodes/library/input/index.js create mode 100644 scripts/nodes/library/localVariable.js create mode 100644 scripts/nodes/library/localVariables.js create mode 100644 scripts/nodes/library/lua/coroutineCreate.js create mode 100644 scripts/nodes/library/lua/coroutineResume.js create mode 100644 scripts/nodes/library/lua/coroutineRunning.js create mode 100644 scripts/nodes/library/lua/coroutineStatus.js create mode 100644 scripts/nodes/library/lua/coroutineYield.js create mode 100644 scripts/nodes/library/lua/getMetatable.js create mode 100644 scripts/nodes/library/lua/index.js create mode 100644 scripts/nodes/library/lua/rawEqual.js create mode 100644 scripts/nodes/library/lua/rawGet.js create mode 100644 scripts/nodes/library/lua/rawSet.js create mode 100644 scripts/nodes/library/lua/setMetatable.js create mode 100644 scripts/nodes/library/map/index.js create mode 100644 scripts/nodes/library/map/map.js create mode 100644 scripts/nodes/library/map/mget.js create mode 100644 scripts/nodes/library/map/mset.js create mode 100644 scripts/nodes/library/map/tline.js create mode 100644 scripts/nodes/library/math/abs.js create mode 100644 scripts/nodes/library/math/atan2.js create mode 100644 scripts/nodes/library/math/band.js create mode 100644 scripts/nodes/library/math/bnot.js create mode 100644 scripts/nodes/library/math/bor.js create mode 100644 scripts/nodes/library/math/bxor.js create mode 100644 scripts/nodes/library/math/ceil.js create mode 100644 scripts/nodes/library/math/cos.js create mode 100644 scripts/nodes/library/math/div.js create mode 100644 scripts/nodes/library/math/flr.js create mode 100644 scripts/nodes/library/math/index.js create mode 100644 scripts/nodes/library/math/lshr.js create mode 100644 scripts/nodes/library/math/max.js create mode 100644 scripts/nodes/library/math/mid.js create mode 100644 scripts/nodes/library/math/min.js create mode 100644 scripts/nodes/library/math/rnd.js create mode 100644 scripts/nodes/library/math/rotl.js create mode 100644 scripts/nodes/library/math/rotr.js create mode 100644 scripts/nodes/library/math/shl.js create mode 100644 scripts/nodes/library/math/shr.js create mode 100644 scripts/nodes/library/math/sin.js create mode 100644 scripts/nodes/library/math/sqrt.js create mode 100644 scripts/nodes/library/math/srand.js create mode 100644 scripts/nodes/library/memory/cstore.js create mode 100644 scripts/nodes/library/memory/index.js create mode 100644 scripts/nodes/library/memory/memcpy.js create mode 100644 scripts/nodes/library/memory/memset.js create mode 100644 scripts/nodes/library/memory/peek.js create mode 100644 scripts/nodes/library/memory/peek2.js create mode 100644 scripts/nodes/library/memory/peek4.js create mode 100644 scripts/nodes/library/memory/poke.js create mode 100644 scripts/nodes/library/memory/poke2.js create mode 100644 scripts/nodes/library/memory/poke4.js create mode 100644 scripts/nodes/library/memory/reload.js create mode 100644 scripts/nodes/library/menu/index.js create mode 100644 scripts/nodes/library/menu/menuItem.js create mode 100644 scripts/nodes/library/multiplyNumber.js create mode 100644 scripts/nodes/library/numberLiteral.js create mode 100644 scripts/nodes/library/print.js create mode 100644 scripts/nodes/library/sequence.js create mode 100644 scripts/nodes/library/serial/index.js create mode 100644 scripts/nodes/library/serial/serialReceive.js create mode 100644 scripts/nodes/library/serial/serialSend.js create mode 100644 scripts/nodes/library/setLocalVariable.js create mode 100644 scripts/nodes/library/setVariable.js create mode 100644 scripts/nodes/library/stringLiteral.js create mode 100644 scripts/nodes/library/strings/chr.js create mode 100644 scripts/nodes/library/strings/index.js create mode 100644 scripts/nodes/library/strings/ord.js create mode 100644 scripts/nodes/library/strings/split.js create mode 100644 scripts/nodes/library/strings/sub.js create mode 100644 scripts/nodes/library/strings/tonum.js create mode 100644 scripts/nodes/library/strings/tostr.js create mode 100644 scripts/nodes/library/strings/typeOf.js create mode 100644 scripts/nodes/library/system/assert.js create mode 100644 scripts/nodes/library/system/extcmd.js create mode 100644 scripts/nodes/library/system/flip.js create mode 100644 scripts/nodes/library/system/folder.js create mode 100644 scripts/nodes/library/system/index.js create mode 100644 scripts/nodes/library/system/info.js create mode 100644 scripts/nodes/library/system/load.js create mode 100644 scripts/nodes/library/system/ls.js create mode 100644 scripts/nodes/library/system/printh.js create mode 100644 scripts/nodes/library/system/reboot.js create mode 100644 scripts/nodes/library/system/reset.js create mode 100644 scripts/nodes/library/system/resume.js create mode 100644 scripts/nodes/library/system/run.js create mode 100644 scripts/nodes/library/system/save.js create mode 100644 scripts/nodes/library/system/stat.js create mode 100644 scripts/nodes/library/system/stop.js create mode 100644 scripts/nodes/library/system/time.js create mode 100644 scripts/nodes/library/table/add.js create mode 100644 scripts/nodes/library/table/all.js create mode 100644 scripts/nodes/library/table/count.js create mode 100644 scripts/nodes/library/table/del.js create mode 100644 scripts/nodes/library/table/deli.js create mode 100644 scripts/nodes/library/table/foreach.js create mode 100644 scripts/nodes/library/table/index.js create mode 100644 scripts/nodes/library/table/pairs.js create mode 100644 scripts/nodes/nodeTypes.js create mode 100644 scripts/tools/generateOutline.js create mode 100644 scripts/ui/BlueprintWorkspace.js create mode 100644 scripts/ui/BlueprintWorkspaceConstants.js create mode 100644 scripts/ui/WorkspaceSpawnShortcuts.js create mode 100644 scripts/ui/workspace/WorkspaceClipboardManager.js create mode 100644 scripts/ui/workspace/WorkspaceContextMenuManager.js create mode 100644 scripts/ui/workspace/WorkspaceDragManager.js create mode 100644 scripts/ui/workspace/WorkspaceDropManager.js create mode 100644 scripts/ui/workspace/WorkspaceGeometry.js create mode 100644 scripts/ui/workspace/WorkspaceHistoryManager.js create mode 100644 scripts/ui/workspace/WorkspaceInlineEditorManager.js create mode 100644 scripts/ui/workspace/WorkspaceMarqueeManager.js create mode 100644 scripts/ui/workspace/WorkspaceNavigationManager.js create mode 100644 scripts/ui/workspace/WorkspaceNodeRenderer.js create mode 100644 scripts/ui/workspace/WorkspacePaletteManager.js create mode 100644 scripts/ui/workspace/WorkspacePersistenceManager.js create mode 100644 scripts/ui/workspace/renderEventList.js create mode 100644 scripts/ui/workspace/renderVariableList.js create mode 100644 styles/main.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fac4bbf --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/node_modules +/exports diff --git a/BlueprintWorkspace.outline.txt b/BlueprintWorkspace.outline.txt new file mode 100644 index 0000000..0c88312 --- /dev/null +++ b/BlueprintWorkspace.outline.txt @@ -0,0 +1,128 @@ +Outline for scripts\ui\BlueprintWorkspace.js + + 77 : class BlueprintWorkspace + 81 : method BlueprintWorkspace.constructor + 242 : method BlueprintWorkspace.static #requireTemplate + 253 : method BlueprintWorkspace.initialize + 329 : method BlueprintWorkspace.redo + 336 : method BlueprintWorkspace.exportLua + 343 : method BlueprintWorkspace.#bindGraphEvents + 536 : method BlueprintWorkspace.#bindUiEvents + 779 : method BlueprintWorkspace.removeNode + 811 : method BlueprintWorkspace.clearSelection + 834 : method BlueprintWorkspace.selectNode + 859 : method BlueprintWorkspace.setSelectionState + 866 : method BlueprintWorkspace.#frameSelection + 892 : method BlueprintWorkspace.#togglePaletteVisibility + 901 : method BlueprintWorkspace.#setPaletteVisibility + 919 : method BlueprintWorkspace.#applyPaletteVisibility + 956 : method BlueprintWorkspace.#selectVariable + 977 : method BlueprintWorkspace.#renderInspectorState + 1025 : method BlueprintWorkspace.#enhanceInspectorTooltip + 1041 : arrow clearRaf + 1048 : arrow schedulePosition + 1055 : arrow handleScroll + 1063 : arrow handleResize + 1070 : arrow handleDocumentPointerDown + 1078 : arrow close + 1097 : arrow position + 1168 : arrow open + 1181 : arrow handlePointerLeave + 1185 : arrow handleFocusOut + 1193 : arrow handleKeyDown + 1226 : method BlueprintWorkspace.#renderProjectSettingsInspector + 1319 : method BlueprintWorkspace.#setInspectorView + 1330 : method BlueprintWorkspace.#updateProjectSettingsToggle + 1345 : method BlueprintWorkspace.#handleProjectSettingsChanged + 1356 : method BlueprintWorkspace.#highlightVariableSelection + 1386 : method BlueprintWorkspace.#renderVariableInspector + 1452 : method BlueprintWorkspace.#setVariableName + 1508 : method BlueprintWorkspace.#setVariableType + 1542 : method BlueprintWorkspace.#openVariableTypeDropdown + 1631 : method BlueprintWorkspace.#closeVariableTypeDropdown + 1657 : method BlueprintWorkspace.#startVariableRename + 1689 : arrow finish + 1735 : method BlueprintWorkspace.#cancelVariableRename + 1766 : method BlueprintWorkspace.#setSelection + 1794 : method BlueprintWorkspace.#applySelectionState + 1828 : method BlueprintWorkspace.#isWorkspaceBackgroundTarget + 1852 : method BlueprintWorkspace.#renderOverview + 1863 : method BlueprintWorkspace.#createGlobalVariable + 1886 : method BlueprintWorkspace.#registerVariable + 1905 : method BlueprintWorkspace.#suggestVariableName + 1947 : method BlueprintWorkspace.#normalizeVariableName + 1957 : method BlueprintWorkspace.#formatVariableType + 1976 : method BlueprintWorkspace.#findVariableByName + 2003 : method BlueprintWorkspace.#nodeReferencesVariable + 2035 : method BlueprintWorkspace.#resolveVariableBinding + 2066 : method BlueprintWorkspace.#refreshVariableNode + 2089 : method BlueprintWorkspace.#applyVariableNodeTitle + 2131 : method BlueprintWorkspace.#applyVariableNodePins + 2137 : arrow updatePin + 2222 : method BlueprintWorkspace.#getCustomEventNodes + 2235 : method BlueprintWorkspace.#generateUniqueCustomEventName + 2274 : method BlueprintWorkspace.#resolveCustomEventLabel + 2288 : method BlueprintWorkspace.#customEventOutputPinId + 2298 : method BlueprintWorkspace.#callCustomEventInputPinId + 2308 : method BlueprintWorkspace.#allocateCustomEventParameterId + 2323 : method BlueprintWorkspace.#sanitizeCustomEventParameters + 2431 : method BlueprintWorkspace.#applyCustomEventNodePins + 2506 : method BlueprintWorkspace.#applyCallCustomEventNodePins + 2581 : method BlueprintWorkspace.#ensureConnectionsReflectPinKinds + 2620 : method BlueprintWorkspace.#syncCallCustomEventArguments + 2651 : method BlueprintWorkspace.#addCustomEventParameter + 2679 : method BlueprintWorkspace.#removeCustomEventParameter + 2702 : method BlueprintWorkspace.#renameCustomEventParameter + 2724 : method BlueprintWorkspace.#setCustomEventParameterType + 2746 : method BlueprintWorkspace.#ensureCustomEventStructure + 2764 : method BlueprintWorkspace.#refreshCustomEventNode + 2802 : method BlueprintWorkspace.#syncCallNodesForEvent + 2833 : method BlueprintWorkspace.#refreshCustomEventCallNode + 2899 : method BlueprintWorkspace.#refreshInspectorIfActive + 2917 : method BlueprintWorkspace.#refreshCallNodesForEvent + 2931 : method BlueprintWorkspace.#handleCustomEventRemoval + 2950 : method BlueprintWorkspace.#renderEventList + 2970 : method BlueprintWorkspace.#renderVariableList + 3005 : method BlueprintWorkspace.#renderOverviewEmpty + 3021 : method BlueprintWorkspace.#isEventNode + 3049 : method BlueprintWorkspace.#handleConnectionPointerDown + 3076 : method BlueprintWorkspace.createNodeFromDefinition + 3142 : method BlueprintWorkspace.spawnNodeFromContextMenu + 3187 : method BlueprintWorkspace.#computeSpawnPosition + 3211 : method BlueprintWorkspace.#startDraggingNode + 3223 : method BlueprintWorkspace.#tryAutoConnectSelectedNode + 3271 : method BlueprintWorkspace.#isAutoConnectStartPinAvailable + 3283 : method BlueprintWorkspace.#findAutoConnectTargets + 3398 : method BlueprintWorkspace.#beginConnection + 3421 : arrow handlePointerMove + 3431 : arrow handlePointerUp + 3458 : method BlueprintWorkspace.#updatePendingConnectionPath + 3497 : method BlueprintWorkspace.#finalizeConnection + 3515 : method BlueprintWorkspace.#cancelPendingConnection + 3529 : method BlueprintWorkspace.#tryOpenPendingConnectionMenu + 3590 : method BlueprintWorkspace.#getCompatibleDefinitionsForPending + 3610 : method BlueprintWorkspace.#connectSpawnedNodeToPending + 3657 : method BlueprintWorkspace.#createConnectionPath + 3668 : method BlueprintWorkspace.scheduleConnectionRefresh + 3675 : method BlueprintWorkspace.cancelConnectionRefresh + 3682 : method BlueprintWorkspace.flushConnectionRefresh + 3689 : method BlueprintWorkspace.#scheduleConnectionRefresh + 3703 : method BlueprintWorkspace.#cancelConnectionRefresh + 3713 : method BlueprintWorkspace.#flushConnectionRefresh + 3724 : method BlueprintWorkspace.#renderConnections + 3769 : method BlueprintWorkspace.#computeConnectionGeometry + 3806 : method BlueprintWorkspace.#getPinHandle + 3825 : method BlueprintWorkspace.#getPinHandleCenter + 3853 : method BlueprintWorkspace.#getPinElement + 3871 : method BlueprintWorkspace.#highlightPinsForPending + 3932 : method BlueprintWorkspace.#shouldHighlightHover + 3944 : method BlueprintWorkspace.#isPinCompatibleWithPending + 3983 : method BlueprintWorkspace.#clearPinHighlights + 4040 : method BlueprintWorkspace.#arePinKindsCompatible + 4052 : method BlueprintWorkspace.#renderInspector + 4178 : method BlueprintWorkspace.#renderCustomEventInspector + 4331 : method BlueprintWorkspace.#renderCallCustomEventInspector + 4415 : method BlueprintWorkspace.#refreshLuaOutput + 4430 : method BlueprintWorkspace.serializeWorkspace + 4439 : method BlueprintWorkspace.applySerializedWorkspace + 4559 : method BlueprintWorkspace.#rebuildNodeCounters \ No newline at end of file diff --git a/electron-main.js b/electron-main.js new file mode 100644 index 0000000..c8d3f24 --- /dev/null +++ b/electron-main.js @@ -0,0 +1,104 @@ +const { app, BrowserWindow, ipcMain } = require("electron"); +const fs = require("node:fs/promises"); +const path = require("node:path"); + +const OUTPUT_FILENAME = "compiled.lua"; + +/** + * Resolves the absolute directory where compiled Lua output is stored. + * + * @returns {string} + */ +function getOutputDirectory() { + const applicationData = app.getPath("appData"); + return path.join(applicationData, "pico-8", "carts", "picograph"); +} + +/** + * Resolves the absolute path to the generated Lua file. + * + * @returns {string} + */ +function getOutputPath() { + return path.join(getOutputDirectory(), OUTPUT_FILENAME); +} + +/** @type {import("electron").BrowserWindow | null} */ +let mainWindow = null; + +/** + * Creates the primary application window and loads the web UI. + * + * @returns {void} + */ +function createMainWindow() { + const window = new BrowserWindow({ + width: 1440, + height: 900, + minWidth: 960, + minHeight: 640, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + preload: path.join(__dirname, "preload.js"), + }, + show: false, + }); + + window.once("ready-to-show", () => { + window.show(); + }); + + window.loadFile(path.join(__dirname, "index.html")); + + window.on("closed", () => { + mainWindow = null; + }); + + mainWindow = window; +} + +/** + * Registers IPC handlers used by renderer processes. + * + * @returns {void} + */ +function registerIpcHandlers() { + ipcMain.handle("lua:compile", async (_event, source) => { + if (typeof source !== "string") { + throw new TypeError("Lua source must be a string"); + } + + const outputPath = getOutputPath(); + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, source, "utf8"); + + return { filePath: outputPath }; + }); +} + +/** + * Wires core Electron lifecycle events for the application. + * + * @returns {void} + */ +function registerAppLifecycle() { + app.whenReady().then(() => { + createMainWindow(); + registerIpcHandlers(); + + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + createMainWindow(); + } + }); + }); + + app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } + }); +} + +registerAppLifecycle(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..6d8cfee --- /dev/null +++ b/index.html @@ -0,0 +1,147 @@ + + + + + + picoGraph + + + +
+
+ +
+
picoGraph
+
+ + + + +
+
+
+ +
+
+ +
+
+
+ + +
+ + + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..20f907c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,871 @@ +{ + "name": "picograph", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "picograph", + "version": "0.1.0", + "devDependencies": { + "electron": "^30.0.0" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.22", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.22.tgz", + "integrity": "sha512-hRnu+5qggKDSyWHlnmThnUqg62l29Aj/6vcYgUaSFL9oc7DVjeWEQN3PRgdSc6F8d9QRMWkf36CLMch1Do/+RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/electron": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-30.5.1.tgz", + "integrity": "sha512-AhL7+mZ8Lg14iaNfoYTkXQ2qee8mmsQyllKdqxlpv/zrKgfxz6jNVtcRRbQtLxtF8yzcImWdfTQROpYiPumdbw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^20.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3f51339 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "picograph", + "version": "0.1.0", + "description": "PicoGraph desktop wrapper", + "main": "electron-main.js", + "scripts": { + "start": "electron ." + }, + "devDependencies": { + "electron": "^30.0.0" + } +} diff --git a/preload.js b/preload.js new file mode 100644 index 0000000..98e4c78 --- /dev/null +++ b/preload.js @@ -0,0 +1,22 @@ +const { contextBridge, ipcRenderer } = require("electron"); + +/** + * Provides renderer processes with limited access to Electron IPC bridges. + */ +const api = { + /** + * Requests that the main process compile the supplied Lua source to disk. + * + * @param {string} source Lua program text emitted from the workspace. + * @returns {Promise<{ filePath: string }>} + */ + compileLua(source) { + if (typeof source !== "string") { + return Promise.reject(new TypeError("Lua source must be a string")); + } + + return ipcRenderer.invoke("lua:compile", source); + }, +}; + +contextBridge.exposeInMainWorld("electronAPI", api); diff --git a/scripts/core/BlueprintNode.js b/scripts/core/BlueprintNode.js new file mode 100644 index 0000000..6c750d9 --- /dev/null +++ b/scripts/core/BlueprintNode.js @@ -0,0 +1,74 @@ +/** + * @typedef {Object} PinDescriptor + * @property {string} id Unique pin identifier scoped to the node. + * @property {string} name Public display name. + * @property {('input'|'output')} direction Pin direction relative to the node. + * @property {('exec'|'number'|'boolean'|'string'|'table'|'any')} kind Pin kind describing blueprint behavior. + * @property {string} [description] Optional tooltip/description. + * @property {unknown} [defaultValue] Default value for data pins. + */ + +/** + * @typedef {Object} BlueprintNodeInit + * @property {string} id Node identifier. + * @property {string} type Node definition identifier. + * @property {string} title Display title for the node. + * @property {{x:number,y:number}} position Node position within the workspace. + * @property {Array} inputs Input pin descriptors. + * @property {Array} outputs Output pin descriptors. + * @property {Record} properties Arbitrary node properties. + */ + +/** + * Represents a blueprint node instance within the graph. + */ +export class BlueprintNode { + /** + * @param {BlueprintNodeInit} init Initialization payload. + */ + constructor(init) { + this.id = init.id; + this.type = init.type; + this.title = init.title; + this.position = { ...init.position }; + this.inputs = init.inputs.map((pin) => ({ ...pin })); + this.outputs = init.outputs.map((pin) => ({ ...pin })); + this.properties = { ...init.properties }; + } + + /** + * Updates the node position immutably. + * + * @param {{x:number,y:number}} position New position coordinates. + */ + setPosition(position) { + this.position = { x: position.x, y: position.y }; + } + + /** + * Provides a shallow clone suitable for serialization. + * + * @returns {BlueprintNodeInit} + */ + toJSON() { + return { + id: this.id, + type: this.type, + title: this.title, + position: { ...this.position }, + inputs: this.inputs.map((pin) => ({ ...pin })), + outputs: this.outputs.map((pin) => ({ ...pin })), + properties: { ...this.properties }, + }; + } + + /** + * Retrieves a pin descriptor matching the supplied identifier. + * + * @param {string} pinId Pin identifier. + * @returns {PinDescriptor | undefined} + */ + getPin(pinId) { + return [...this.inputs, ...this.outputs].find((pin) => pin.id === pinId); + } +} diff --git a/scripts/core/Connection.js b/scripts/core/Connection.js new file mode 100644 index 0000000..71d3053 --- /dev/null +++ b/scripts/core/Connection.js @@ -0,0 +1,46 @@ +/** + * Generates a lightweight unique identifier. + * + * @returns {string} + */ +const createConnectionId = () => + globalThis.crypto?.randomUUID?.() ?? + `conn_${Math.random().toString(36).slice(2, 10)}`; + +/** + * @typedef {Object} PinReference + * @property {string} nodeId Linked node identifier. + * @property {string} pinId Pin identifier within the node. + */ + +/** + * Represents a directional connection between two node pins. + */ +export class Connection { + /** + * @param {PinReference} from Origin pin (must be output). + * @param {PinReference} to Target pin (must be input). + * @param {('exec'|'number'|'boolean'|'string'|'table'|'any')} kind Pin kind carried by the connection. + * @param {string} [id] Optional connection identifier. + */ + constructor(from, to, kind, id) { + this.id = id ?? createConnectionId(); + this.from = { ...from }; + this.to = { ...to }; + this.kind = kind; + } + + /** + * Serializes the connection for persistence. + * + * @returns {{from: PinReference, to: PinReference, kind: Connection['kind']}} + */ + toJSON() { + return { + id: this.id, + from: { ...this.from }, + to: { ...this.to }, + kind: this.kind, + }; + } +} diff --git a/scripts/core/LuaGenerator.js b/scripts/core/LuaGenerator.js new file mode 100644 index 0000000..b0c0bd5 --- /dev/null +++ b/scripts/core/LuaGenerator.js @@ -0,0 +1,1181 @@ +/** + * @typedef {import('./NodeGraph.js').NodeGraph} NodeGraph + */ + +/** + * @typedef {import('./BlueprintNode.js').BlueprintNode} BlueprintNode + */ + +/** + * @typedef {import('../nodes/NodeRegistry.js').NodeRegistry} NodeRegistry + */ + +/** + * @typedef {Object} ProjectSettings + * @property {boolean} use60Fps Enables the 60fps update loop via _UPDATE60. + */ + +/** + * Converts a node graph into executable PICO-8 Lua code. + */ +export class LuaGenerator { + /** + * @param {NodeRegistry} registry Node registry for metadata lookups. + */ + constructor(registry) { + this.registry = registry; + this.indentUnit = " "; + this.valueCache = new Map(); + this.valueStack = new Set(); + /** @type {Map} */ + this.customEventFunctionNames = new Map(); + /** + * @type {Map>} + */ + this.customEventParameters = new Map(); + /** @type {Map} */ + this.customEventParameterLookup = new Map(); + /** @type {Array<{ id: string, name: string, type: string, defaultValue: unknown }>} */ + this.variables = []; + /** @type {Map} */ + this.globalVariableNames = new Map(); + /** @type {ProjectSettings} */ + this.projectSettings = { + use60Fps: false, + }; + } + + /** + * Applies project-level configuration. + * + * @param {ProjectSettings} settings Project configuration flags. + */ + setProjectSettings(settings) { + this.projectSettings = { + use60Fps: Boolean(settings.use60Fps), + }; + } + + /** + * Updates the workspace variable metadata used for Lua generation. + * + * @param {Array<{ id: string, name: string, type: string, defaultValue: unknown }>} variables Variable definitions. + */ + setVariables(variables) { + if (!Array.isArray(variables)) { + this.variables = []; + return; + } + + this.variables = variables.map((entry, index) => ({ + id: typeof entry?.id === "string" ? entry.id : `variable_${index}`, + name: typeof entry?.name === "string" ? entry.name : "", + type: this.#normalizeVariableType(entry?.type), + defaultValue: Object.prototype.hasOwnProperty.call(entry ?? {}, "defaultValue") + ? entry?.defaultValue + : null, + })); + } + + /** + * Generates Lua code for the provided graph instance. + * + * @param {NodeGraph} graph Blueprint graph to compile. + * @returns {string} + */ + generate(graph, options = {}) { + this.graph = graph; + this.connections = graph.getConnections(); + this.nodes = new Map(graph.getNodes().map((node) => [node.id, node])); + this.valueCache.clear(); + this.valueStack.clear(); + this.globalVariableNames.clear(); + + const variableEntries = Array.isArray(options) + ? options + : Array.isArray(options?.variables) + ? options.variables + : this.variables; + + this.setVariables(variableEntries); + const globalDeclarations = this.#prepareGlobalVariableDeclarations( + this.variables + ); + + const use60Fps = Boolean(this.projectSettings.use60Fps); + const entryTypes = new Set(this.registry.getEntryNodeTypes()); + const entryEventMap = this.registry.getEntryPointEvents(); + const entryCandidates = [...this.nodes.values()].filter((node) => + entryTypes.has(node.type) + ); + + /** @type {Array} */ + const customEvents = []; + /** @type {Array} */ + const entryNodes = []; + + entryCandidates.forEach((node) => { + if (node.type === "custom_event") { + customEvents.push(node); + } else { + entryNodes.push(node); + } + }); + + if (!entryNodes.length && !customEvents.length) { + return ["-- Generated with picoGraph", "-- No entry node present."].join( + "\n" + ); + } + + this.customEventFunctionNames = + this.#buildCustomEventFunctionMap(customEvents); + const { parameterMap, lookupMap } = + this.#buildCustomEventParameterMap(customEvents); + this.customEventParameters = parameterMap; + this.customEventParameterLookup = lookupMap; + + /** @type {Map>} */ + const grouped = new Map(); + entryNodes.forEach((node) => { + let eventName = entryEventMap.get(node.type) ?? "_init"; + if (eventName === "_update" && use60Fps) { + eventName = "_update60"; + } + const collection = grouped.get(eventName) ?? []; + collection.push(node); + grouped.set(eventName, collection); + }); + + const output = ["-- Generated with picoGraph"]; + if (globalDeclarations.length) { + output.push(""); + output.push(...globalDeclarations); + } + const appendEvent = (eventName, node) => { + const lines = this.#emitExecChain(node.id, 1, new Set()); + output.push(""); + output.push(`function ${eventName}()`); + if (lines.length) { + output.push(...lines); + } + output.push("end"); + }; + + const preferredOrder = ["_init", "_update", "_update60", "_draw"]; + preferredOrder.forEach((eventName) => { + const nodes = grouped.get(eventName); + if (!nodes || !nodes.length) { + return; + } + + appendEvent(eventName, nodes[0]); + grouped.delete(eventName); + }); + + const remainingEvents = [...grouped.entries()].sort((a, b) => + a[0].localeCompare(b[0]) + ); + + remainingEvents.forEach(([eventName, nodes]) => { + if (!nodes.length) { + return; + } + appendEvent(eventName, nodes[0]); + }); + + if (customEvents.length) { + const orderedCustomEvents = customEvents + .slice() + .sort((a, b) => + this.#getCustomEventDisplayName(a).localeCompare( + this.#getCustomEventDisplayName(b) + ) + ); + + orderedCustomEvents.forEach((node) => { + const functionName = this.customEventFunctionNames.get(node.id); + if (!functionName) { + return; + } + const signature = this.customEventParameters.get(node.id) ?? []; + const parameterList = signature + .map((entry) => entry.luaName) + .join(", "); + const lines = this.#emitExecChain(node.id, 1, new Set()); + output.push(""); + if (parameterList.length) { + output.push(`function ${functionName}(${parameterList})`); + } else { + output.push(`function ${functionName}()`); + } + if (lines.length) { + output.push(...lines); + } + output.push("end"); + }); + } + + return output.join("\n"); + } + + /** + * Recursively walks the exec chain starting at a node. + * + * @param {string} nodeId Starting node identifier. + * @param {number} indentLevel Indentation level. + * @param {Set} path Guard set preventing infinite recursion. + * @returns {Array} + */ + #emitExecChain(nodeId, indentLevel, path) { + if (path.has(nodeId)) { + return [ + `${this.#indent( + indentLevel + )}-- cyclic exec connection involving ${nodeId}`, + ]; + } + path.add(nodeId); + + const node = this.nodes.get(nodeId); + if (!node) { + return [`${this.#indent(indentLevel)}-- missing node ${nodeId}`]; + } + + const lines = this.#emitFlowNode(node, indentLevel, path); + path.delete(nodeId); + return lines; + } + + /** + * Builds the execution context shared with node behaviors. + * + * @param {BlueprintNode} node Active node reference. + * @param {number} indentLevel Current indentation level. + * @param {Set} path Cycle detection guard. + * @returns {import('../nodes/nodeTypes.js').ExecContext} + */ + #createExecContext(node, indentLevel, path) { + return { + node, + indentLevel, + path, + indent: (level = indentLevel) => this.#indent(level), + resolveValueInput: (pinId, fallback) => + this.#resolveValueInput(node, pinId, fallback), + emitNextExec: (pinId, overrides = {}) => { + const nextIndentLevel = overrides.indentLevel ?? indentLevel; + const nextPath = overrides.path ?? path; + return this.#emitNextExec(node.id, pinId, nextIndentLevel, nextPath); + }, + emitBranch: (pinId, overrides = {}) => { + const branchIndent = overrides.indentLevel ?? indentLevel; + const branchPath = overrides.path ?? path; + return this.#emitBranch(node.id, pinId, branchIndent, branchPath); + }, + emitExecChain: (targetNodeId, targetIndentLevel, targetPath) => + this.#emitExecChain(targetNodeId, targetIndentLevel, targetPath), + findExecTargets: (pinId) => this.#findExecTargets(node.id, pinId), + sanitizeIdentifier: (value) => this.#sanitizeIdentifier(String(value)), + sanitizeOperator: (value) => this.#sanitizeOperator(String(value)), + formatLiteral: (kind, value) => this.#formatLiteral(kind, value), + }; + } + + /** + * Dispatches code generation for a specific flow node. + * + * @param {BlueprintNode} node Target node. + * @param {number} indentLevel Indentation level. + * @param {Set} path Cycle guard collection. + * @returns {Array} + */ + #emitFlowNode(node, indentLevel, path) { + const behavior = this.registry.getBehavior(node.type); + if (behavior?.emitExec) { + return behavior.emitExec( + this.#createExecContext(node, indentLevel, path) + ); + } + + switch (node.type) { + case "event_start": + case "event_init": + case "event_update": + case "event_draw": + return this.#emitNextExec(node.id, "exec_out", indentLevel, path); + case "print": + return this.#emitPrint(node, indentLevel, path); + case "set_var": + return this.#emitSetVariable(node, indentLevel, path); + case "if": + return this.#emitIf(node, indentLevel, path); + case "for_loop": + return this.#emitForLoop(node, indentLevel, path); + case "sequence": + return this.#emitSequence(node, indentLevel, path); + case "call_custom_event": + return this.#emitCallCustomEvent(node, indentLevel, path); + default: + return [ + `${this.#indent(indentLevel)}-- unsupported flow node: ${node.title}`, + ]; + } + } + + /** + * Builds the value context shared with node behaviors. + * + * @param {BlueprintNode} node Active node reference. + * @param {string} pinId Value output pin. + * @returns {import('../nodes/nodeTypes.js').ValueContext & { pinId: string }} + */ + #createValueContext(node, pinId) { + return { + node, + pinId, + resolveValueInput: (inputPinId, fallback) => + this.#resolveValueInput(node, inputPinId, fallback), + sanitizeIdentifier: (value) => this.#sanitizeIdentifier(String(value)), + sanitizeOperator: (value) => this.#sanitizeOperator(String(value)), + formatLiteral: (kind, value) => this.#formatLiteral(kind, value), + }; + } + + /** + * Emits Lua for a print node. + * + * @param {BlueprintNode} node Print node. + * @param {number} indentLevel Indent level. + * @param {Set} path Cycle guard. + * @returns {Array} + */ + #emitPrint(node, indentLevel, path) { + const text = this.#resolveValueInput(node, "msg", '""'); + const x = this.#resolveValueInput(node, "x", "0"); + const y = this.#resolveValueInput(node, "y", "0"); + const color = this.#resolveValueInput(node, "color", "7"); + const line = `${this.#indent( + indentLevel + )}print(${text}, ${x}, ${y}, ${color})`; + return [ + line, + ...this.#emitNextExec(node.id, "exec_out", indentLevel, path), + ]; + } + + /** + * Emits Lua for a variable assignment node. + * + * @param {BlueprintNode} node Set variable node. + * @param {number} indentLevel Indent level. + * @param {Set} path Cycle guard. + * @returns {Array} + */ + #emitSetVariable(node, indentLevel, path) { + const name = this.#resolveWorkspaceVariableName(node); + const value = this.#resolveValueInput(node, "value", "nil"); + const line = `${this.#indent(indentLevel)}${name} = ${value}`; + return [ + line, + ...this.#emitNextExec(node.id, "exec_out", indentLevel, path), + ]; + } + + /** + * Emits Lua for a branch node. + * + * @param {BlueprintNode} node Branch node. + * @param {number} indentLevel Indent level. + * @param {Set} path Cycle guard. + * @returns {Array} + */ + #emitIf(node, indentLevel, path) { + const condition = this.#resolveValueInput(node, "condition", "false"); + const lines = [`${this.#indent(indentLevel)}if ${condition} then`]; + + const thenLines = this.#emitBranch( + node.id, + "then", + indentLevel + 1, + new Set(path) + ); + if (!thenLines.length) { + lines.push(`${this.#indent(indentLevel + 1)}-- then branch`); + } else { + lines.push(...thenLines); + } + + const elseTargets = this.#findExecTargets(node.id, "else"); + if (elseTargets.length) { + lines.push(`${this.#indent(indentLevel)}else`); + const elseLines = this.#emitBranch( + node.id, + "else", + indentLevel + 1, + new Set(path) + ); + if (!elseLines.length) { + lines.push(`${this.#indent(indentLevel + 1)}-- else branch`); + } else { + lines.push(...elseLines); + } + } + + lines.push(`${this.#indent(indentLevel)}end`); + return lines; + } + + /** + * Emits Lua for a for-loop node. + * + * @param {BlueprintNode} node Loop node. + * @param {number} indentLevel Indent level. + * @param {Set} path Cycle guard. + * @returns {Array} + */ + #emitForLoop(node, indentLevel, path) { + const indexName = this.#sanitizeIdentifier( + String(node.properties.index ?? "i") + ); + const startValue = this.#resolveValueInput(node, "start", "0"); + const endValue = this.#resolveValueInput(node, "end", "0"); + const stepValue = this.#resolveValueInput(node, "step", "1"); + + const lines = [ + `${this.#indent( + indentLevel + )}for ${indexName} = ${startValue}, ${endValue}, ${stepValue} do`, + ]; + + const loopLines = this.#emitBranch( + node.id, + "loop", + indentLevel + 1, + new Set(path) + ); + if (!loopLines.length) { + lines.push(`${this.#indent(indentLevel + 1)}-- loop body`); + } else { + lines.push(...loopLines); + } + + lines.push(`${this.#indent(indentLevel)}end`); + lines.push(...this.#emitBranch(node.id, "completed", indentLevel, path)); + return lines; + } + + /** + * Emits Lua for a sequence node, executing each branch in order. + * + * @param {BlueprintNode} node Sequence node. + * @param {number} indentLevel Indentation level. + * @param {Set} path Cycle guard collection. + * @returns {Array} + */ + #emitSequence(node, indentLevel, path) { + const lines = []; + node.outputs.forEach((pin) => { + const label = (pin.name ?? pin.id).toLowerCase(); + lines.push(`${this.#indent(indentLevel)}-- sequence ${label}`); + + const targets = this.#findExecTargets(node.id, pin.id); + if (!targets.length) { + return; + } + + targets.forEach((target) => { + const branchPath = new Set(path); + lines.push( + ...this.#emitExecChain(target.nodeId, indentLevel, branchPath) + ); + }); + }); + return lines; + } + + /** + * Emits Lua for a call custom event node. + * + * @param {BlueprintNode} node Call node reference. + * @param {number} indentLevel Indentation level. + * @param {Set} path Cycle detection guard. + * @returns {Array} + */ + #emitCallCustomEvent(node, indentLevel, path) { + const eventId = + typeof node.properties.eventId === "string" + ? node.properties.eventId + : ""; + const functionName = eventId + ? this.customEventFunctionNames.get(eventId) + : null; + const lines = []; + if (functionName) { + const signature = this.customEventParameters.get(eventId) ?? []; + const argEntries = signature.map((entry) => { + const fallback = entry.optional + ? "nil" + : this.#defaultLiteralForKind(entry.kind); + const value = this.#resolveValueInput( + node, + entry.inputPinId, + fallback + ); + return { value, optional: Boolean(entry.optional) }; + }); + while ( + argEntries.length && + argEntries[argEntries.length - 1].optional && + argEntries[argEntries.length - 1].value === "nil" + ) { + argEntries.pop(); + } + const call = argEntries.length + ? `${functionName}(${argEntries + .map((entry) => entry.value) + .join(", ")})` + : `${functionName}()`; + lines.push(`${this.#indent(indentLevel)}${call}`); + } else { + lines.push(`${this.#indent(indentLevel)}-- missing custom event target`); + } + lines.push(...this.#emitNextExec(node.id, "exec_out", indentLevel, path)); + return lines; + } + + /** + * Emits a branch for a given exec output pin. + * + * @param {string} nodeId Node identifier. + * @param {string} pinId Output pin identifier. + * @param {number} indentLevel Indent level. + * @param {Set} path Cycle guard. + * @returns {Array} + */ + #emitBranch(nodeId, pinId, indentLevel, path) { + const targets = this.#findExecTargets(nodeId, pinId); + if (!targets.length) { + return []; + } + + const lines = []; + targets.forEach((target) => { + lines.push(...this.#emitExecChain(target.nodeId, indentLevel, path)); + }); + return lines; + } + + /** + * Advances to the next node for sequential exec outputs. + * + * @param {string} nodeId Node identifier. + * @param {string} pinId Exec output pin identifier. + * @param {number} indentLevel Indent level. + * @param {Set} path Cycle guard. + * @returns {Array} + */ + #emitNextExec(nodeId, pinId, indentLevel, path) { + const targets = this.#findExecTargets(nodeId, pinId); + if (!targets.length) { + return []; + } + + return this.#emitExecChain(targets[0].nodeId, indentLevel, path); + } + + /** + * Retrieves execution targets for a given output pin. + * + * @param {string} nodeId Node identifier. + * @param {string} pinId Output pin identifier. + * @returns {Array<{nodeId:string,pinId:string}>} + */ + #findExecTargets(nodeId, pinId) { + return this.connections + .filter( + (connection) => + connection.kind === "exec" && + connection.from.nodeId === nodeId && + connection.from.pinId === pinId + ) + .map((connection) => connection.to); + } + + /** + * Resolves the expression bound to an input pin. + * + * @param {BlueprintNode} node Parent node. + * @param {string} pinId Input pin identifier. + * @param {string} fallback Fallback literal when unconnected. + * @returns {string} + */ + #resolveValueInput(node, pinId, fallback) { + const connection = this.#getConnectionTo(node.id, pinId); + if (connection) { + return this.#evaluateValue(connection.from.nodeId, connection.from.pinId); + } + + const pin = this.#findPin(node, pinId); + const inlineKey = this.#inlinePropertyKey(pinId); + if (Object.prototype.hasOwnProperty.call(node.properties, inlineKey)) { + const inlineValue = node.properties[inlineKey]; + if (inlineValue !== undefined && inlineValue !== null) { + return this.#formatLiteral(pin?.kind ?? "any", inlineValue); + } + } + + if (pin && pin.defaultValue !== undefined && pin.defaultValue !== null) { + return this.#formatLiteral(pin.kind, pin.defaultValue); + } + + return fallback; + } + + /** + * Retrieves the pin descriptor for a node. + * + * @param {BlueprintNode} node Parent node. + * @param {string} pinId Pin identifier. + * @returns {import('../core/BlueprintNode.js').PinDescriptor | undefined} + */ + #findPin(node, pinId) { + return [...node.inputs, ...node.outputs].find((pin) => pin.id === pinId); + } + + /** + * Locates the incoming connection for a specific pin. + * + * @param {string} nodeId Node identifier. + * @param {string} pinId Pin identifier. + * @returns {import('./Connection.js').Connection | undefined} + */ + #getConnectionTo(nodeId, pinId) { + return this.connections.find( + (connection) => + connection.to.nodeId === nodeId && connection.to.pinId === pinId + ); + } + + /** + * Evaluates an expression produced by a value pin. + * + * @param {string} nodeId Node identifier. + * @param {string} pinId Output pin identifier. + * @returns {string} + */ + #evaluateValue(nodeId, pinId) { + const cacheKey = `${nodeId}:${pinId}`; + if (this.valueCache.has(cacheKey)) { + return this.valueCache.get(cacheKey); + } + + if (this.valueStack.has(cacheKey)) { + return "nil"; + } + + this.valueStack.add(cacheKey); + const node = this.nodes.get(nodeId); + if (!node) { + this.valueStack.delete(cacheKey); + return "nil"; + } + + let expression; + const behavior = this.registry.getBehavior(node.type); + if (behavior?.evaluateValue) { + expression = behavior.evaluateValue( + this.#createValueContext(node, pinId) + ); + } + + if (expression === undefined) { + switch (node.type) { + case "number_literal": + expression = this.#formatLiteral( + "number", + node.properties.value ?? 0 + ); + break; + case "string_literal": + expression = this.#formatLiteral( + "string", + node.properties.value ?? "" + ); + break; + case "boolean_literal": + expression = this.#formatLiteral( + "boolean", + node.properties.value ?? "true" + ); + break; + case "get_var": { + const name = this.#resolveWorkspaceVariableName(node); + expression = name; + break; + } + case "custom_event": { + const parameterName = this.customEventParameterLookup.get( + `${node.id}:${pinId}` + ); + expression = parameterName ?? "nil"; + break; + } + case "add_number": { + const a = this.#resolveValueInput(node, "a", "0"); + const b = this.#resolveValueInput(node, "b", "0"); + expression = `(${a}) + (${b})`; + break; + } + case "multiply_number": { + const a = this.#resolveValueInput(node, "a", "1"); + const b = this.#resolveValueInput(node, "b", "1"); + expression = `(${a}) * (${b})`; + break; + } + case "compare": { + const a = this.#resolveValueInput(node, "a", "0"); + const b = this.#resolveValueInput(node, "b", "0"); + const operator = this.#sanitizeOperator( + String(node.properties.operator ?? "==") + ); + expression = `(${a}) ${operator} (${b})`; + break; + } + default: { + expression = "nil"; + break; + } + } + } + + this.valueStack.delete(cacheKey); + this.valueCache.set(cacheKey, expression); + return expression; + } + + /** + * Sanitizes an identifier for Lua output. + * + * @param {string} name Input name. + * @returns {string} + */ + #sanitizeIdentifier(name) { + const trimmed = name.trim(); + let sanitized = trimmed.replace(/[^a-zA-Z0-9_]/g, "_"); + if (!sanitized.length) { + sanitized = "var"; + } + + if (!/^[a-zA-Z_]/.test(sanitized)) { + sanitized = `v_${sanitized}`; + } + + return sanitized.toLowerCase(); + } + + /** + * Ensures comparison operators are valid for Lua. + * + * @param {string} operator Operator candidate. + * @returns {string} + */ + #sanitizeOperator(operator) { + const allowed = ["==", "!=", ">", "<", ">=", "<="]; + if (allowed.includes(operator)) { + return operator === "!=" ? "~=" : operator; + } + return "=="; + } + + /** + * Formats a literal based on pin kind. + * + * @param {string} kind Pin kind. + * @param {unknown} value Raw value. + * @returns {string} + */ + #formatLiteral(kind, value) { + switch (kind) { + case "number": { + const numeric = Number(value); + return Number.isFinite(numeric) ? String(numeric) : "0"; + } + case "boolean": { + if (typeof value === "string") { + return value === "false" ? "false" : "true"; + } + return value ? "true" : "false"; + } + case "table": { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length ? trimmed : "{}"; + } + return "{}"; + } + case "string": + return JSON.stringify(String(value ?? "")); + default: + return JSON.stringify(String(value ?? "")); + } + } + + /** + * Provides a default literal for the supplied pin kind. + * + * @param {string} kind Pin kind reference. + * @returns {string} + */ + #defaultLiteralForKind(kind) { + switch (kind) { + case "number": + return "0"; + case "boolean": + return "false"; + case "string": + return '""'; + case "table": + return "{}"; + default: + return "nil"; + } + } + + /** + * Determines the display name for a custom event node. + * + * @param {BlueprintNode} node Custom event node reference. + * @returns {string} + */ + #getCustomEventDisplayName(node) { + const raw = + typeof node.properties.name === "string" + ? node.properties.name.trim() + : ""; + return raw || "CustomEvent"; + } + + /** + * Builds a map of custom event node identifiers to Lua function names. + * + * @param {Array} customEvents Custom event nodes to process. + * @returns {Map} + */ + #buildCustomEventFunctionMap(customEvents) { + const map = new Map(); + const usedNames = new Set(); + + customEvents.forEach((node) => { + const displayName = this.#getCustomEventDisplayName(node); + let baseIdentifier = this.#sanitizeIdentifier(displayName); + if (!baseIdentifier.startsWith("custom_")) { + baseIdentifier = `custom_${baseIdentifier}`; + } + + let candidate = baseIdentifier; + let suffix = 1; + while (usedNames.has(candidate)) { + candidate = `${baseIdentifier}_${suffix}`; + suffix += 1; + } + + usedNames.add(candidate); + map.set(node.id, candidate); + }); + + return map; + } + + /** + * Builds metadata describing custom event parameter layouts. + * + * @param {Array} customEvents Custom event nodes to process. + * @returns {{ parameterMap: Map>, lookupMap: Map }} + */ + #buildCustomEventParameterMap(customEvents) { + /** @type {Map>} */ + const parameterMap = new Map(); + /** @type {Map} */ + const lookupMap = new Map(); + + customEvents.forEach((node) => { + const rawParameters = Array.isArray(node.properties.parameters) + ? node.properties.parameters + : []; + const allowedKinds = new Set(["any", "number", "string", "boolean"]); + const usedNames = new Set(); + /** @type {Array<{ id: string, name: string, luaName: string, outputPinId: string, inputPinId: string, kind: string }>} */ + const entries = []; + + rawParameters.forEach((parameter, index) => { + const rawName = + typeof parameter?.name === "string" ? parameter.name.trim() : ""; + const displayName = rawName || `param${index + 1}`; + let luaName = this.#sanitizeIdentifier(displayName); + if (!luaName.length) { + luaName = `param${index + 1}`; + } + let candidate = luaName; + let suffix = 2; + while (usedNames.has(candidate)) { + candidate = `${luaName}_${suffix}`; + suffix += 1; + } + usedNames.add(candidate); + + const rawId = + typeof parameter?.id === "string" ? parameter.id.trim() : ""; + const parameterId = + rawId || `param_${String(index + 1).padStart(2, "0")}`; + const outputPinId = `param_${parameterId}`; + const inputPinId = `arg_${parameterId}`; + const rawKind = + typeof parameter?.type === "string" ? parameter.type : "any"; + const kind = allowedKinds.has(rawKind) ? rawKind : "any"; + const optional = Boolean(parameter?.optional); + + entries.push({ + id: parameterId, + name: displayName, + luaName: candidate, + outputPinId, + inputPinId, + kind, + optional, + }); + lookupMap.set(`${node.id}:${outputPinId}`, candidate); + }); + + parameterMap.set(node.id, entries); + }); + + return { parameterMap, lookupMap }; + } + + /** + * Prepares global variable declarations for Lua output. + * + * @param {Array<{ id: string, name: string, type: string, defaultValue: unknown }>} variables Workspace variables. + * @returns {Array} + */ + #prepareGlobalVariableDeclarations(variables) { + const declarations = []; + if (!Array.isArray(variables) || !variables.length) { + return declarations; + } + + const usedNames = new Set(); + variables.forEach((entry, index) => { + if (!entry) { + return; + } + + const rawName = + typeof entry.name === "string" && entry.name.trim().length + ? entry.name.trim() + : typeof entry.id === "string" && entry.id + ? entry.id + : `var${index + 1}`; + let baseName = this.#sanitizeIdentifier(rawName); + if (!baseName.length) { + baseName = `var${index + 1}`; + } + + let candidate = baseName; + let suffix = 2; + while (usedNames.has(candidate)) { + candidate = `${baseName}_${suffix}`; + suffix += 1; + } + usedNames.add(candidate); + + if (typeof entry.id === "string" && entry.id.length) { + this.globalVariableNames.set(entry.id, candidate); + } + + const literal = this.#formatVariableDefaultLiteral(entry); + declarations.push(`${candidate} = ${literal}`); + }); + + return declarations; + } + + /** + * Formats a workspace variable default for Lua initialization. + * + * @param {{ type: string, defaultValue: unknown }} variable Variable descriptor. + * @returns {string} + */ + #formatVariableDefaultLiteral(variable) { + const type = this.#normalizeVariableType(variable?.type); + const value = variable?.defaultValue; + + switch (type) { + case "number": { + const numeric = Number(value); + return Number.isFinite(numeric) ? String(numeric) : "0"; + } + case "boolean": + return value ? "true" : "false"; + case "string": + return JSON.stringify(String(value ?? "")); + case "table": { + if (Array.isArray(value)) { + const fragments = value + .map((entry) => this.#formatTableDefaultEntry(entry)) + .filter((fragment) => fragment.length); + + if (!fragments.length) { + return "{}"; + } + + const rawCandidate = + value.length === 1 && typeof value[0]?.value === "string" + ? value[0].value.trim() + : ""; + if ( + value.length === 1 && + !value[0]?.key && + rawCandidate.startsWith("{") && + rawCandidate.endsWith("}") + ) { + return rawCandidate; + } + + const singleLine = fragments.join(", "); + if (singleLine.length <= 60 && !singleLine.includes("\n")) { + return `{ ${singleLine} }`; + } + return `{\n ${fragments.join(",\n ")}\n}`; + } + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length ? trimmed : "{}"; + } + return "{}"; + } + default: { + if (value === null || value === undefined) { + return "nil"; + } + if (typeof value === "number") { + return Number.isFinite(value) ? String(value) : "nil"; + } + if (typeof value === "boolean") { + return value ? "true" : "false"; + } + return JSON.stringify(String(value)); + } + } + } + + /** + * Formats a table default entry into a Lua fragment. + * + * @param {{ key?: string, value?: string }} entry Table entry descriptor. + * @returns {string} + */ + #formatTableDefaultEntry(entry) { + if (!entry || typeof entry !== "object") { + return ""; + } + + const key = + typeof entry.key === "string" ? entry.key.trim() : ""; + const value = + typeof entry.value === "string" ? entry.value.trim() : ""; + + if (!key.length) { + return value; + } + + const formattedKey = this.#formatTableEntryKey(key); + const targetValue = value.length ? value : "nil"; + return `${formattedKey} = ${targetValue}`; + } + + /** + * Normalizes a table entry key for Lua output. + * + * @param {string} key Raw key value. + * @returns {string} + */ + #formatTableEntryKey(key) { + const trimmed = key.trim(); + if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) { + return trimmed; + } + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + return trimmed; + } + return `[${trimmed}]`; + } + + /** + * Resolves the Lua identifier for a workspace variable node, honoring generator mappings. + * + * @param {BlueprintNode} node Blueprint node referencing a variable. + * @returns {string} + */ + #resolveWorkspaceVariableName(node) { + const rawId = + typeof node?.properties?.variableId === "string" + ? node.properties.variableId + : ""; + if (rawId) { + const mapped = this.globalVariableNames.get(rawId); + if (mapped) { + return mapped; + } + } + + const fallback = + typeof node?.properties?.name === "string" + ? node.properties.name + : "var"; + return this.#sanitizeIdentifier(fallback); + } + + /** + * Normalizes a variable type descriptor. + * + * @param {unknown} value Candidate type. + * @returns {string} + */ + #normalizeVariableType(value) { + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + switch (normalized) { + case "number": + case "string": + case "boolean": + case "table": + return normalized; + default: + return "any"; + } + } + return "any"; + } + + /** + * Returns indentation whitespace for the given level. + * + * @param {number} level Indent level. + * @returns {string} + */ + #indent(level) { + return this.indentUnit.repeat(level); + } + + /** + * Generates the property key used for inline pin overrides. + * + * @param {string} pinId Pin identifier. + * @returns {string} + */ + #inlinePropertyKey(pinId) { + return `pin:${pinId}`; + } +} diff --git a/scripts/core/NodeGraph.js b/scripts/core/NodeGraph.js new file mode 100644 index 0000000..77cdee5 --- /dev/null +++ b/scripts/core/NodeGraph.js @@ -0,0 +1,355 @@ +import { BlueprintNode } from "./BlueprintNode.js"; +import { Connection } from "./Connection.js"; + +/** @typedef {import('./BlueprintNode.js').BlueprintNodeInit} BlueprintNodeInit */ +/** @typedef {import('./BlueprintNode.js').PinDescriptor} PinDescriptor */ +/** @typedef {import('./Connection.js').PinReference} PinReference */ + +/** + * Maintains the canonical blueprint data model: nodes, pins, and their connections. + */ +export class NodeGraph extends EventTarget { + constructor() { + super(); + /** @type {Map} */ + this.nodes = new Map(); + /** @type {Map} */ + this.connections = new Map(); + this.nodeCounters = new Map(); + } + + /** + * Emits a custom event notifying listeners about a change. + * + * @param {string} type Event name. + * @param {unknown} detail Event payload. + */ + emit(type, detail) { + this.dispatchEvent(new CustomEvent(type, { detail })); + } + + /** + * Generates a unique node identifier scoped by node type. + * + * @param {string} type Node definition identifier. + * @returns {string} + */ + createNodeId(type) { + const current = this.nodeCounters.get(type) ?? 0; + const next = current + 1; + this.nodeCounters.set(type, next); + return `${type}_${String(next).padStart(2, "0")}`; + } + + /** + * Registers a node instance within the graph. + * + * @param {BlueprintNode} node Node to add. + */ + addNode(node) { + this.nodes.set(node.id, node); + this.emit("nodeschanged", { type: "add", node }); + } + + /** + * Removes a node and any associated connections. + * + * @param {string} nodeId Target node identifier. + */ + removeNode(nodeId) { + const node = this.nodes.get(nodeId); + if (!node) { + return; + } + + [...this.connections.values()].forEach((connection) => { + if ( + connection.from.nodeId === nodeId || + connection.to.nodeId === nodeId + ) { + this.connections.delete(connection.id); + } + }); + + this.nodes.delete(nodeId); + this.emit("nodeschanged", { type: "remove", nodeId }); + this.emit("connectionschanged", { type: "prune", nodeId }); + } + + /** + * Updates the node position within the graph. + * + * @param {string} nodeId Node identifier. + * @param {{x:number,y:number}} position New coordinates to store. + */ + setNodePosition(nodeId, position) { + const node = this.nodes.get(nodeId); + if (!node) { + return; + } + + node.setPosition(position); + this.emit("nodepositionchanged", { nodeId, position: { ...position } }); + } + + /** + * Updates a node property bag value. + * + * @param {string} nodeId Node identifier. + * @param {string} key Property key. + * @param {unknown} value Property value. + */ + setNodeProperty(nodeId, key, value) { + const node = this.nodes.get(nodeId); + if (!node) { + return; + } + + node.properties[key] = value; + this.emit("nodepropertychanged", { nodeId, key, value }); + } + + /** + * Removes an existing connection. + * + * @param {string} connectionId Identifier for the connection. + */ + removeConnection(connectionId) { + if (!this.connections.has(connectionId)) { + return; + } + + this.connections.delete(connectionId); + this.emit("connectionschanged", { type: "remove", connectionId }); + } + + /** + * Removes connections linked to a specific pin. + * + * @param {PinReference} ref Pin reference to clear. + */ + removeConnectionsForPin(ref) { + let mutated = false; + const affectedNodes = new Set(); + [...this.connections.values()].forEach((connection) => { + if ( + (connection.from.nodeId === ref.nodeId && + connection.from.pinId === ref.pinId) || + (connection.to.nodeId === ref.nodeId && + connection.to.pinId === ref.pinId) + ) { + this.connections.delete(connection.id); + mutated = true; + affectedNodes.add(connection.from.nodeId); + affectedNodes.add(connection.to.nodeId); + } + }); + + if (mutated) { + this.emit("connectionschanged", { + type: "prune", + ref, + nodes: [...affectedNodes], + }); + } + } + + /** + * Attempts to connect two pins if the contract is valid. + * + * @param {PinReference} from Output pin reference. + * @param {PinReference} to Input pin reference. + * @returns {boolean} Whether the connection was created. + */ + connect(from, to) { + const fromNode = this.nodes.get(from.nodeId); + const toNode = this.nodes.get(to.nodeId); + if (!fromNode || !toNode) { + return false; + } + + const fromPin = fromNode.getPin(from.pinId); + const toPin = toNode.getPin(to.pinId); + if (!fromPin || !toPin) { + return false; + } + + if (!this.#isConnectionValid(fromNode, fromPin, toNode, toPin)) { + return false; + } + + if (fromPin.kind === "exec") { + this.removeConnectionsForPin(from); + } + + // Deduplicate single-input connections except for exec inputs which may stack. + if (toPin.kind !== "exec") { + this.removeConnectionsForPin(to); + } + + const connection = new Connection( + from, + to, + toPin.kind === "any" ? fromPin.kind : toPin.kind + ); + this.connections.set(connection.id, connection); + this.emit("connectionschanged", { type: "add", connection }); + return true; + } + + /** + * Retrieves an immutable list of nodes. + * + * @returns {Array} + */ + getNodes() { + return [...this.nodes.values()].map((node) => node); + } + + /** + * Retrieves all active connections. + * + * @returns {Array} + */ + getConnections() { + return [...this.connections.values()].map((connection) => connection); + } + + /** + * Returns connections targeting a particular node and optional pin. + * + * @param {string} nodeId Node identifier. + * @param {string} [pinId] Optional pin identifier. + * @returns {Array} + */ + getConnectionsForNode(nodeId, pinId) { + return this.getConnections().filter((connection) => { + const matchesNode = + connection.from.nodeId === nodeId || connection.to.nodeId === nodeId; + const matchesPin = !pinId + ? true + : connection.from.pinId === pinId || connection.to.pinId === pinId; + return matchesNode && matchesPin; + }); + } + + /** + * Produces a serializable payload for the graph. + * + * @returns {{nodes: Array>, connections: Array>}} + */ + toJSON() { + return { + nodes: this.getNodes().map((node) => node.toJSON()), + connections: this.getConnections().map((connection) => + connection.toJSON() + ), + }; + } + + /** + * Restores a graph from serialized payload. + * + * @param {{nodes:Array, connections:Array>}} payload Serialized graph data. + * @returns {NodeGraph} + */ + static fromJSON(payload) { + const graph = new NodeGraph(); + payload.nodes.forEach((nodeData) => { + const node = new BlueprintNode(nodeData); + graph.nodes.set(node.id, node); + }); + payload.connections.forEach((connectionData) => { + const connection = new Connection( + connectionData.from, + connectionData.to, + connectionData.kind, + connectionData.id + ); + graph.connections.set(connection.id, connection); + }); + return graph; + } + + /** + * Replaces the current graph contents using the provided serialized payload. + * + * @param {{nodes:Array, connections:Array>}} payload Serialized graph data. + */ + replaceState(payload) { + this.nodes.clear(); + this.connections.clear(); + this.nodeCounters.clear(); + + payload.nodes.forEach((nodeData) => { + const node = new BlueprintNode(nodeData); + this.nodes.set(node.id, node); + this.#trackNodeCounter(node); + }); + + payload.connections.forEach((connectionData) => { + const connection = new Connection( + connectionData.from, + connectionData.to, + connectionData.kind, + connectionData.id + ); + this.connections.set(connection.id, connection); + }); + + this.emit("graphrestored", { + nodes: this.getNodes(), + connections: this.getConnections(), + }); + } + + /** + * Determines whether a connection between the provided pins is permitted. + * + * @param {BlueprintNode} fromNode Source node. + * @param {PinDescriptor} fromPin Source pin descriptor. + * @param {BlueprintNode} toNode Target node. + * @param {PinDescriptor} toPin Target pin descriptor. + * @returns {boolean} + */ + #isConnectionValid(fromNode, fromPin, toNode, toPin) { + if (fromNode.id === toNode.id) { + return false; + } + + if (fromPin.direction !== "output" || toPin.direction !== "input") { + return false; + } + + if ( + toPin.kind !== "any" && + fromPin.kind !== "any" && + fromPin.kind !== toPin.kind + ) { + return false; + } + + // Avoid duplicate identical connections. + const duplicated = [...this.connections.values()].some( + (existing) => + existing.from.nodeId === fromNode.id && + existing.from.pinId === fromPin.id && + existing.to.nodeId === toNode.id && + existing.to.pinId === toPin.id + ); + + return !duplicated; + } + + /** + * Updates the node counter registry for the supplied node instance. + * + * @param {BlueprintNode} node Node reference used to update counters. + */ + #trackNodeCounter(node) { + const match = /_(\d+)$/.exec(node.id); + const numeric = match ? Number.parseInt(match[1], 10) : 0; + const current = this.nodeCounters.get(node.type) ?? 0; + this.nodeCounters.set(node.type, Math.max(current, numeric)); + } +} diff --git a/scripts/main.js b/scripts/main.js new file mode 100644 index 0000000..98b7380 --- /dev/null +++ b/scripts/main.js @@ -0,0 +1,469 @@ +import { BlueprintWorkspace } from "./ui/BlueprintWorkspace.js"; +import { NodeRegistry } from "./nodes/NodeRegistry.js"; +import { LuaGenerator } from "./core/LuaGenerator.js"; + +/** + * @typedef {{ compileLua(source: string): Promise<{ filePath: string }> }} ElectronAPI + */ + +/** + * @template T + * @param {T | null} element DOM element reference. + * @param {string} id Identifier used for error reporting. + * @returns {T} + */ +const requireElement = (element, id) => { + if (!element) { + throw new Error(`Missing required element: ${id}`); + } + return element; +}; + +const appBodyElement = /** @type {HTMLElement} */ ( + requireElement(document.getElementById("appBody"), "appBody") +); +const workspaceElement = /** @type {HTMLElement} */ ( + requireElement(document.getElementById("workspaceCanvas"), "workspaceCanvas") +); +const nodeLayer = /** @type {HTMLElement} */ ( + requireElement(document.getElementById("nodeLayer"), "nodeLayer") +); +const connectionLayer = /** @type {SVGElement} */ ( + requireElement(document.getElementById("connectionLayer"), "connectionLayer") +); +const eventList = /** @type {HTMLUListElement} */ ( + requireElement(document.getElementById("eventList"), "eventList") +); +const variableList = /** @type {HTMLUListElement} */ ( + requireElement(document.getElementById("variableList"), "variableList") +); +const addVariableButton = /** @type {HTMLButtonElement} */ ( + requireElement( + document.getElementById("addVariableButton"), + "addVariableButton" + ) +); +const paletteList = /** @type {HTMLElement} */ ( + requireElement(document.getElementById("paletteList"), "paletteList") +); +const paletteSearch = /** @type {HTMLInputElement} */ ( + requireElement(document.getElementById("paletteSearch"), "paletteSearch") +); +const paletteElement = /** @type {HTMLElement} */ ( + requireElement(document.getElementById("palettePanel"), "palettePanel") +); +const inspectorContent = /** @type {HTMLElement} */ ( + requireElement( + document.getElementById("inspectorContent"), + "inspectorContent" + ) +); +const duplicateNodeButton = /** @type {HTMLButtonElement} */ ( + requireElement( + document.getElementById("duplicateNodeButton"), + "duplicateNodeButton" + ) +); +const deleteNodeButton = /** @type {HTMLButtonElement} */ ( + requireElement( + document.getElementById("deleteNodeButton"), + "deleteNodeButton" + ) +); +const compileButton = /** @type {HTMLButtonElement} */ ( + requireElement(document.getElementById("compileButton"), "compileButton") +); +const exportLuaButton = /** @type {HTMLButtonElement} */ ( + requireElement(document.getElementById("exportLuaButton"), "exportLuaButton") +); +const projectSettingsButton = /** @type {HTMLButtonElement} */ ( + requireElement( + document.getElementById("projectSettingsButton"), + "projectSettingsButton" + ) +); +const paletteToggleButton = /** @type {HTMLButtonElement} */ ( + requireElement( + document.getElementById("paletteToggleButton"), + "paletteToggleButton" + ) +); +const frameSelectionButton = /** @type {HTMLButtonElement} */ ( + requireElement( + document.getElementById("frameSelectionButton"), + "frameSelectionButton" + ) +); +const luaModal = /** @type {HTMLElement} */ ( + requireElement(document.getElementById("luaModal"), "luaModal") +); +const luaModalCode = /** @type {HTMLElement} */ ( + requireElement(document.getElementById("luaModalCode"), "luaModalCode") +); +const luaModalCodeContainer = /** @type {HTMLElement} */ ( + requireElement( + luaModal.querySelector(".lua-modal__code"), + "luaModalCodeContainer" + ) +); +const copyLuaButton = /** @type {HTMLButtonElement} */ ( + requireElement(document.getElementById("copyLuaButton"), "copyLuaButton") +); +const closeLuaModalButton = /** @type {HTMLButtonElement} */ ( + requireElement( + document.getElementById("closeLuaModalButton"), + "closeLuaModalButton" + ) +); + +const registry = new NodeRegistry(); +const generator = new LuaGenerator(registry); +const workspace = new BlueprintWorkspace({ + workspaceElement, + nodeLayer, + connectionLayer, + paletteList, + paletteSearch, + paletteElement, + inspectorContent, + duplicateNodeButton, + deleteNodeButton, + generator, + registry, + eventList, + variableList, + addVariableButton, + projectSettingsButton, + paletteToggleButton, + appBodyElement, + frameSelectionButton, +}); + +workspace.initialize(); + +/** @type {ElectronAPI | undefined} */ +const electronAPI = + typeof window !== "undefined" && "electronAPI" in window + ? /** @type {ElectronAPI} */ (window.electronAPI) + : undefined; + +document.addEventListener("contextmenu", (event) => { + event.preventDefault(); +}); + +const escapeHtml = (value) => + value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + +const luaTokenPattern = + /(--.*$)|("(?:\\.|[^"\\])*")|('(?!\[)(?:\\.|[^'\\])*')|(\b\d+(?:\.\d+)?\b)|\b(and|break|do|else|elseif|end|false|for|function|if|in|local|nil|not|or|repeat|return|then|true|until|while)\b/gm; + +const highlightLua = (source) => { + if (!source) { + return ""; + } + + let result = ""; + let lastIndex = 0; + + source.replace( + luaTokenPattern, + ( + match, + comment, + doubleQuoted, + singleQuoted, + numberLiteral, + keyword, + offset + ) => { + result += escapeHtml(source.slice(lastIndex, offset)); + + let tokenType = ""; + if (comment) { + tokenType = "comment"; + } else if (doubleQuoted || singleQuoted) { + tokenType = "string"; + } else if (numberLiteral) { + tokenType = "number"; + } else { + tokenType = "keyword"; + } + + result += `${escapeHtml( + match + )}`; + lastIndex = offset + match.length; + return match; + } + ); + + result += escapeHtml(source.slice(lastIndex)); + return result; +}; + +const focusableSelector = + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; +let isLuaModalOpen = false; +let lastFocusedElement = /** @type {HTMLElement | null} */ (null); +let copyFeedbackTimeout = /** @type {number | null} */ (null); +let currentLuaSource = ""; +let compileFeedbackTimeout = /** @type {number | null} */ (null); + +const scheduleMicrotask = (callback) => { + if (typeof queueMicrotask === "function") { + queueMicrotask(callback); + return; + } + Promise.resolve().then(callback); +}; + +const getModalFocusableElements = () => { + return /** @type {Array} */ ( + Array.from(luaModal.querySelectorAll(focusableSelector)).filter( + (element) => + element instanceof HTMLElement && !element.hasAttribute("disabled") + ) + ); +}; + +const handleModalKeydown = (event) => { + if (!isLuaModalOpen) { + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + closeLuaModal(); + return; + } + + if (event.key !== "Tab") { + return; + } + + const focusable = getModalFocusableElements(); + if (!focusable.length) { + event.preventDefault(); + return; + } + + const currentIndex = focusable.indexOf(document.activeElement); + let nextIndex = currentIndex; + + if (event.shiftKey) { + nextIndex = currentIndex <= 0 ? focusable.length - 1 : currentIndex - 1; + } else { + nextIndex = + currentIndex === -1 || currentIndex === focusable.length - 1 + ? 0 + : currentIndex + 1; + } + + event.preventDefault(); + focusable[nextIndex].focus(); +}; + +const writeClipboard = async (text) => { + if ( + navigator.clipboard && + typeof navigator.clipboard.writeText === "function" + ) { + await navigator.clipboard.writeText(text); + return; + } + + const selection = document.getSelection(); + const previousRange = + selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + const fallback = document.createElement("textarea"); + fallback.value = text; + fallback.setAttribute("readonly", "true"); + fallback.style.position = "fixed"; + fallback.style.opacity = "0"; + document.body.appendChild(fallback); + fallback.select(); + document.execCommand("copy"); + document.body.removeChild(fallback); + + if (previousRange && selection) { + selection.removeAllRanges(); + selection.addRange(previousRange); + } +}; + +const resetCopyFeedback = () => { + copyLuaButton.classList.remove("is-success"); + copyLuaButton.setAttribute("aria-label", "Copy to clipboard"); + copyLuaButton.setAttribute("title", "Copy to clipboard"); +}; + +const openLuaModal = () => { + currentLuaSource = workspace.exportLua(); + luaModalCode.innerHTML = highlightLua(currentLuaSource); + luaModalCodeContainer.scrollTop = 0; + if (copyFeedbackTimeout !== null) { + window.clearTimeout(copyFeedbackTimeout); + copyFeedbackTimeout = null; + } + resetCopyFeedback(); + + if (isLuaModalOpen) { + return; + } + + lastFocusedElement = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + luaModal.removeAttribute("hidden"); + luaModal.setAttribute("aria-hidden", "false"); + document.body.classList.add("modal-open"); + isLuaModalOpen = true; + document.addEventListener("keydown", handleModalKeydown); + + scheduleMicrotask(() => { + const focusTarget = luaModalCodeContainer; + focusTarget.focus(); + }); +}; + +const closeLuaModal = () => { + if (!isLuaModalOpen) { + return; + } + + isLuaModalOpen = false; + luaModal.setAttribute("aria-hidden", "true"); + luaModal.setAttribute("hidden", ""); + document.body.classList.remove("modal-open"); + document.removeEventListener("keydown", handleModalKeydown); + + if (copyFeedbackTimeout !== null) { + window.clearTimeout(copyFeedbackTimeout); + copyFeedbackTimeout = null; + } + resetCopyFeedback(); + + if (lastFocusedElement && document.contains(lastFocusedElement)) { + lastFocusedElement.focus(); + } +}; + +/** + * Restores the compile button to its idle appearance. + * + * @returns {void} + */ +const resetCompileButton = () => { + if (compileFeedbackTimeout !== null) { + window.clearTimeout(compileFeedbackTimeout); + compileFeedbackTimeout = null; + } + + compileButton.classList.remove("is-success", "is-error", "is-busy"); + compileButton.disabled = false; + compileButton.textContent = "Compile"; +}; + +/** + * Handles compile button clicks by requesting a Lua export from Electron. + * + * @returns {Promise} + */ +const handleCompileClick = async () => { + if (!electronAPI) { + return; + } + + if (compileFeedbackTimeout !== null) { + window.clearTimeout(compileFeedbackTimeout); + compileFeedbackTimeout = null; + } + + compileButton.classList.remove("is-success", "is-error"); + compileButton.classList.add("is-busy"); + compileButton.disabled = true; + compileButton.textContent = "Compiling..."; + + const source = workspace.exportLua(); + + try { + const result = await electronAPI.compileLua(source); + console.info("Lua compiled to", result.filePath); + compileButton.classList.remove("is-busy"); + compileButton.classList.add("is-success"); + compileButton.disabled = false; + compileButton.textContent = "Compiled!"; + compileFeedbackTimeout = window.setTimeout(() => { + resetCompileButton(); + }, 1800); + } catch (error) { + console.error("Failed to compile Lua", error); + compileButton.classList.remove("is-busy"); + compileButton.classList.add("is-error"); + compileButton.disabled = false; + compileButton.textContent = "Failed"; + compileFeedbackTimeout = window.setTimeout(() => { + resetCompileButton(); + }, 2200); + } +}; + +if (electronAPI && typeof electronAPI.compileLua === "function") { + compileButton.disabled = false; + compileButton.addEventListener("click", () => { + handleCompileClick().catch((error) => { + console.error("Unhandled compile error", error); + resetCompileButton(); + }); + }); +} else { + compileButton.disabled = true; + compileButton.title = "Compile is available in the desktop app"; +} + +exportLuaButton.addEventListener("click", () => { + openLuaModal(); +}); + +luaModal.addEventListener("click", (event) => { + const target = + event.target instanceof HTMLElement + ? event.target.closest("[data-modal-close]") + : null; + if (target) { + event.preventDefault(); + closeLuaModal(); + } +}); + +copyLuaButton.addEventListener("click", async () => { + if (!currentLuaSource) { + return; + } + + try { + await writeClipboard(currentLuaSource); + copyLuaButton.classList.add("is-success"); + copyLuaButton.setAttribute("aria-label", "Copied!"); + copyLuaButton.setAttribute("title", "Copied!"); + if (copyFeedbackTimeout !== null) { + window.clearTimeout(copyFeedbackTimeout); + } + copyFeedbackTimeout = window.setTimeout(() => { + resetCopyFeedback(); + copyFeedbackTimeout = null; + }, 1500); + } catch (error) { + console.error("Copy failed", error); + } +}); + +closeLuaModalButton.addEventListener("click", () => { + closeLuaModal(); +}); diff --git a/scripts/nodes/NodeRegistry.js b/scripts/nodes/NodeRegistry.js new file mode 100644 index 0000000..2a0f38f --- /dev/null +++ b/scripts/nodes/NodeRegistry.js @@ -0,0 +1,296 @@ +import { BlueprintNode } from "../core/BlueprintNode.js"; +import { nodeModules } from "./library/index.js"; + +/** @typedef {import('./nodeTypes.js').NodeDefinition} NodeDefinition */ +/** @typedef {import('./nodeTypes.js').NodeBehavior} NodeBehavior */ +/** @typedef {import('./nodeTypes.js').NodeModule} NodeModule */ +/** @typedef {{ value: string, weight: number }} SearchField */ + +/** + * Central registry providing node definitions, behavior hooks, and factory helpers. + */ +export class NodeRegistry { + constructor() { + /** @type {Map} */ + this.definitions = new Map(); + /** @type {Map} */ + this.behaviors = new Map(); + /** @type {Set} */ + this.entryNodeTypes = new Set(); + /** @type {Map} */ + this.entryPointEvents = new Map(); + + nodeModules.forEach((module) => this.registerModule(module)); + } + + /** + * Normalizes search inputs for case-insensitive comparisons. + * + * @param {string} query Raw query string. + * @returns {string} + */ + #normalizeQuery(query) { + return (query ?? "").trim().toLowerCase(); + } + + /** + * Determines whether a node definition matches the provided query. + * + * @param {NodeDefinition} definition Definition to inspect. + * @param {string} query Search query. + * @returns {boolean} + */ + matchesDefinition(definition, query) { + const normalized = this.#normalizeQuery(query); + if (!normalized) { + return true; + } + + const score = this.#scoreDefinition(definition, normalized); + return Number.isFinite(score); + } + + /** + * Enumerates every registered definition. + * + * @returns {Array} + */ + list() { + return [...this.definitions.values()]; + } + + /** + * Retrieves a single node definition. + * + * @param {string} id Definition identifier. + * @returns {NodeDefinition | undefined} + */ + get(id) { + return this.definitions.get(id); + } + + /** + * Performs a fuzzy search across node metadata and returns results sorted by distance. + * + * @param {string} query Search input. + * @returns {Array} + */ + search(query) { + const normalized = this.#normalizeQuery(query); + if (!normalized) { + return this.list().sort((a, b) => a.title.localeCompare(b.title)); + } + + /** @type {Array<{ definition: NodeDefinition, score: number }>} */ + const ranked = []; + this.definitions.forEach((definition) => { + const score = this.#scoreDefinition(definition, normalized); + if (Number.isFinite(score)) { + ranked.push({ definition, score }); + } + }); + + ranked.sort((a, b) => { + if (a.score === b.score) { + return a.definition.title.localeCompare(b.definition.title); + } + return a.score - b.score; + }); + + return ranked.map((entry) => entry.definition); + } + + /** + * Aggregates searchable fields for a node definition and assigns weighting. + * + * @param {NodeDefinition} definition Definition to inspect. + * @returns {Array} Normalized field collection with weights. + */ + #collectSearchFields(definition) { + const fields = /** @type {Array} */ ([ + { value: definition.title, weight: 0.8 }, + { value: definition.category, weight: 1 }, + ]); + + if (definition.description) { + fields.push({ value: definition.description, weight: 1 }); + } + + if (Array.isArray(definition.searchTags)) { + definition.searchTags.forEach((tag) => { + fields.push({ value: tag, weight: 1 }); + }); + } + + return fields + .filter((field) => typeof field.value === "string" && field.value.trim().length) + .map((field) => ({ + value: field.value.toLowerCase(), + weight: field.weight, + })); + } + + /** + * Computes a fuzzy score representing how closely a definition matches a query. + * + * @param {NodeDefinition} definition Target definition. + * @param {string} normalizedQuery Lowercase trimmed query string. + * @returns {number} Matching score where lower values are closer; Infinity means no match. + */ + #scoreDefinition(definition, normalizedQuery) { + const fields = this.#collectSearchFields(definition); + if (!fields.length) { + return Number.POSITIVE_INFINITY; + } + + let best = Number.POSITIVE_INFINITY; + for (const field of fields) { + const score = this.#computeFuzzyScore(normalizedQuery, field.value); + if (!Number.isFinite(score)) { + continue; + } + + const weighted = score * field.weight; + if (weighted < best) { + best = weighted; + } + if (best === 0) { + break; + } + } + return best; + } + + /** + * Produces a fuzzy matching distance between the query and a candidate string. + * + * @param {string} query Normalized query string. + * @param {string} candidate Normalized candidate string. + * @returns {number} Distance score; Infinity indicates the query is not a subsequence of the candidate. + */ + #computeFuzzyScore(query, candidate) { + if (!candidate) { + return Number.POSITIVE_INFINITY; + } + + const qLength = query.length; + const cLength = candidate.length; + if (!qLength) { + return 0; + } + + let qIndex = 0; + let score = 0; + let lastMatch = -1; + + for (let cIndex = 0; cIndex < cLength; cIndex += 1) { + if (candidate[cIndex] !== query[qIndex]) { + continue; + } + + if (lastMatch === -1) { + score += cIndex; + } else { + score += cIndex - lastMatch - 1; + } + + lastMatch = cIndex; + qIndex += 1; + + if (qIndex === qLength) { + break; + } + } + + if (qIndex !== qLength) { + return Number.POSITIVE_INFINITY; + } + + score += cLength - lastMatch - 1; + return score; + } + + /** + * Instantiates a blueprint node from a definition. + * + * @param {string} definitionId Definition identifier. + * @param {{id: string, position: {x:number,y:number}}} options Initialization options. + * @returns {BlueprintNode} + */ + createNode(definitionId, options) { + const definition = this.get(definitionId); + if (!definition) { + throw new Error(`Unknown node definition: ${definitionId}`); + } + + const properties = {}; + definition.properties.forEach((schema) => { + const value = schema.defaultValue ?? null; + properties[schema.key] = value; + }); + + definition.initializeProperties?.(properties); + + return new BlueprintNode({ + id: options.id, + type: definition.id, + title: definition.title, + position: { ...options.position }, + inputs: definition.inputs.map((pin) => ({ + ...pin, + })), + outputs: definition.outputs.map((pin) => ({ + ...pin, + })), + properties, + }); + } + + /** + * Registers a node module exposing editor metadata and Lua behavior. + * + * @param {NodeModule} module Node module to register. + */ + registerModule(module) { + const { definition, behavior } = module; + this.definitions.set(definition.id, definition); + + if (behavior) { + this.behaviors.set(definition.id, behavior); + if (behavior.isEntryPoint) { + this.entryNodeTypes.add(definition.id); + if (behavior.eventName) { + this.entryPointEvents.set(definition.id, behavior.eventName); + } + } + } + } + + /** + * Retrieves behavior metadata for a node type. + * + * @param {string} id Node identifier. + * @returns {NodeBehavior | undefined} + */ + getBehavior(id) { + return this.behaviors.get(id); + } + + /** + * Enumerates node types flagged as entry points. + * + * @returns {Array} + */ + getEntryNodeTypes() { + return [...this.entryNodeTypes]; + } + + /** + * Exposes lifecycle event bindings for entry nodes. + * + * @returns {Map} + */ + getEntryPointEvents() { + return new Map(this.entryPointEvents); + } +} diff --git a/scripts/nodes/library/addNumber.js b/scripts/nodes/library/addNumber.js new file mode 100644 index 0000000..658a5bb --- /dev/null +++ b/scripts/nodes/library/addNumber.js @@ -0,0 +1,41 @@ +import { createNodeModule } from "../nodeTypes.js"; + +/** + * Adds two numeric values. + */ +export const addNumberNode = createNodeModule( + { + id: "add_number", + title: "Add", + category: "Math", + description: "Add two numbers.", + searchTags: ["add", "math", "sum", "number"], + inputs: [ + { + id: "a", + name: "A", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "b", + name: "B", + direction: "input", + kind: "number", + defaultValue: 0, + }, + ], + outputs: [ + { id: "res", name: "Result", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const a = resolveValueInput("a", "0"); + const b = resolveValueInput("b", "0"); + return `(${a}) + (${b})`; + }, + } +); diff --git a/scripts/nodes/library/audio/index.js b/scripts/nodes/library/audio/index.js new file mode 100644 index 0000000..3bef2ec --- /dev/null +++ b/scripts/nodes/library/audio/index.js @@ -0,0 +1,16 @@ +import { sfxNode } from "./sfx.js"; +import { musicNode } from "./music.js"; + +/** + * All audio-related node modules backed by PICO-8 helpers. + */ +export const audioNodes = [sfxNode, musicNode]; + +/** + * Checklist tracking coverage of documented audio helpers. + * @type {Array<{ function: string, nodeId: string, implemented: boolean }>} + */ +export const audioFunctionChecklist = [ + { function: "SFX", nodeId: "audio_sfx", implemented: true }, + { function: "MUSIC", nodeId: "audio_music", implemented: true }, +]; diff --git a/scripts/nodes/library/audio/music.js b/scripts/nodes/library/audio/music.js new file mode 100644 index 0000000..f0f563e --- /dev/null +++ b/scripts/nodes/library/audio/music.js @@ -0,0 +1,64 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Plays or stops music using the PICO-8 music helper. + */ +export const musicNode = createNodeModule( + { + id: "audio_music", + title: "Play Music", + category: "Audio", + description: + "Start, fade, or stop music playback using MUSIC().", + searchTags: ["music", "audio", "song", "pattern"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "pattern", + name: "Pattern", + direction: "input", + kind: "number", + description: "Pattern index or command", + defaultValue: 0, + }, + { + id: "fade", + name: "Fade (ms)", + direction: "input", + kind: "number", + description: "Fade duration in milliseconds", + }, + { + id: "mask", + name: "Channel Mask", + direction: "input", + kind: "number", + description: "Bitmask reserving channels", + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const OMIT = "__pg_omit__"; + const pattern = resolveValueInput("pattern", "0"); + const fade = resolveValueInput("fade", OMIT); + const mask = resolveValueInput("mask", OMIT); + + const args = [pattern]; + if (fade !== OMIT || mask !== OMIT) { + const fadeArg = fade === OMIT ? "nil" : fade; + args.push(fadeArg); + if (mask !== OMIT) { + args.push(mask); + } + } + + const line = `${indent(indentLevel)}music(${args.join(", ")})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/audio/sfx.js b/scripts/nodes/library/audio/sfx.js new file mode 100644 index 0000000..cfced1d --- /dev/null +++ b/scripts/nodes/library/audio/sfx.js @@ -0,0 +1,76 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Plays or controls a sound effect using the PICO-8 sfx helper. + */ +export const sfxNode = createNodeModule( + { + id: "audio_sfx", + title: "Play SFX", + category: "Audio", + description: + "Trigger a sound effect, control looping, or stop playback via SFX().", + searchTags: ["sfx", "audio", "sound", "effect"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "id", + name: "SFX", + direction: "input", + kind: "number", + description: "Sound effect slot or command", + defaultValue: 0, + }, + { + id: "channel", + name: "Channel", + direction: "input", + kind: "number", + description: "Playback channel (-1 auto)", + }, + { + id: "offset", + name: "Offset", + direction: "input", + kind: "number", + description: "Note offset", + }, + { + id: "length", + name: "Length", + direction: "input", + kind: "number", + description: "Number of notes to play", + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const OMIT = "__pg_omit__"; + const id = resolveValueInput("id", "0"); + const channel = resolveValueInput("channel", OMIT); + const offset = resolveValueInput("offset", OMIT); + const length = resolveValueInput("length", OMIT); + + const args = [id]; + if (channel !== OMIT || offset !== OMIT || length !== OMIT) { + const channelArg = channel === OMIT ? "nil" : channel; + args.push(channelArg); + if (offset !== OMIT || length !== OMIT) { + const offsetArg = offset === OMIT ? "nil" : offset; + args.push(offsetArg); + if (length !== OMIT) { + args.push(length); + } + } + } + + const line = `${indent(indentLevel)}sfx(${args.join(", ")})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/booleanLiteral.js b/scripts/nodes/library/booleanLiteral.js new file mode 100644 index 0000000..8e5d111 --- /dev/null +++ b/scripts/nodes/library/booleanLiteral.js @@ -0,0 +1,26 @@ +import { createNodeModule } from "../nodeTypes.js"; + +/** + * Constant boolean literal. + */ +export const booleanLiteralNode = createNodeModule( + { + id: "boolean_literal", + title: "Boolean", + category: "Values", + description: "Constant boolean literal.", + searchTags: ["boolean", "literal", "true", "false"], + inputs: [], + outputs: [ + { id: "value", name: "Value", direction: "output", kind: "boolean" }, + ], + properties: [ + { key: "value", label: "Value", type: "boolean", defaultValue: true }, + ], + }, + { + evaluateValue: ({ node, formatLiteral }) => { + return formatLiteral("boolean", node.properties.value ?? true); + }, + } +); diff --git a/scripts/nodes/library/callCustomEvent.js b/scripts/nodes/library/callCustomEvent.js new file mode 100644 index 0000000..f137727 --- /dev/null +++ b/scripts/nodes/library/callCustomEvent.js @@ -0,0 +1,32 @@ +import { createNodeModule } from "../nodeTypes.js"; + +/** + * Invokes a custom event by reference. + * @type {import('../nodeTypes.js').NodeModule} + */ +export const callCustomEventNode = createNodeModule( + { + id: "call_custom_event", + title: "Call Custom Event", + category: "Events", + description: "Invokes a custom event defined elsewhere in the graph.", + searchTags: ["event", "call", "trigger", "custom"], + inputs: [{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" }], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + initializeProperties: (properties) => { + properties.eventId = + typeof properties.eventId === "string" ? properties.eventId : ""; + if ( + !properties.arguments || + typeof properties.arguments !== "object" || + Array.isArray(properties.arguments) + ) { + properties.arguments = {}; + } + }, + }, + null +); diff --git a/scripts/nodes/library/compare.js b/scripts/nodes/library/compare.js new file mode 100644 index 0000000..5fe3a53 --- /dev/null +++ b/scripts/nodes/library/compare.js @@ -0,0 +1,47 @@ +import { createNodeModule } from "../nodeTypes.js"; + +/** + * Compares two values using a selected operator. + */ +export const compareNode = createNodeModule( + { + id: "compare", + title: "Compare", + category: "Logic", + description: "Compare two values with a selected operator.", + searchTags: ["compare", "logic", "condition", "branch"], + inputs: [ + { id: "a", name: "A", direction: "input", kind: "any" }, + { id: "b", name: "B", direction: "input", kind: "any" }, + ], + outputs: [ + { id: "res", name: "Result", direction: "output", kind: "boolean" }, + ], + properties: [ + { + key: "operator", + label: "Operator", + type: "enum", + defaultValue: "==", + options: [ + { label: "Equal", value: "==" }, + { label: "Not Equal", value: "!=" }, + { label: "Greater", value: ">" }, + { label: "Less", value: "<" }, + { label: "Greater Or Equal", value: ">=" }, + { label: "Less Or Equal", value: "<=" }, + ], + }, + ], + }, + { + evaluateValue: ({ node, resolveValueInput, sanitizeOperator }) => { + const a = resolveValueInput("a", "0"); + const b = resolveValueInput("b", "0"); + const operator = sanitizeOperator( + String(node.properties.operator ?? "==") + ); + return `(${a}) ${operator} (${b})`; + }, + } +); diff --git a/scripts/nodes/library/customEvent.js b/scripts/nodes/library/customEvent.js new file mode 100644 index 0000000..82fdc81 --- /dev/null +++ b/scripts/nodes/library/customEvent.js @@ -0,0 +1,44 @@ +import { createNodeModule } from "../nodeTypes.js"; + +/** + * Defines a reusable custom event entry point. + * @type {import('../nodeTypes.js').NodeModule} + */ +export const customEventNode = createNodeModule( + { + id: "custom_event", + title: "Custom Event", + category: "Events", + description: "Defines a custom event that can be triggered elsewhere.", + searchTags: ["event", "custom", "broadcast", "trigger"], + inputs: [], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [ + { + key: "name", + label: "Event Name", + type: "string", + defaultValue: "CustomEvent", + placeholder: "Enter event name", + }, + ], + initializeProperties: (properties) => { + if (typeof properties.name !== "string" || !properties.name.trim()) { + properties.name = "CustomEvent"; + } + if (!Array.isArray(properties.parameters)) { + properties.parameters = []; + } + const counter = Number.isFinite(properties.parameterCounter) + ? Number(properties.parameterCounter) + : 0; + properties.parameterCounter = counter; + }, + }, + { + isEntryPoint: true, + emitExec: ({ emitNextExec }) => emitNextExec("exec_out"), + } +); diff --git a/scripts/nodes/library/data/cartdata.js b/scripts/nodes/library/data/cartdata.js new file mode 100644 index 0000000..a8ad116 --- /dev/null +++ b/scripts/nodes/library/data/cartdata.js @@ -0,0 +1,33 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Opens persistent cartridge storage using cartdata. + */ +export const cartdataNode = createNodeModule( + { + id: "data_cartdata", + title: "Cart Data Init", + category: "Data", + description: "Setup persistent storage for the cartridge using CARTDATA().", + searchTags: ["cartdata", "save", "persistent", "data"], + inputs: [ + { + id: "id", + name: "Identifier", + direction: "input", + kind: "string", + defaultValue: "\"my_cart\"", + }, + ], + outputs: [ + { id: "loaded", name: "Loaded", direction: "output", kind: "boolean" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const id = resolveValueInput("id", '"pico_cart"'); + return `cartdata(${id})`; + }, + } +); diff --git a/scripts/nodes/library/data/dget.js b/scripts/nodes/library/data/dget.js new file mode 100644 index 0000000..632c433 --- /dev/null +++ b/scripts/nodes/library/data/dget.js @@ -0,0 +1,33 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Reads a persistent value using dget. + */ +export const dgetNode = createNodeModule( + { + id: "data_dget", + title: "Data Get", + category: "Data", + description: "Retrieve a persistent number using DGET().", + searchTags: ["dget", "load", "data", "persistent"], + inputs: [ + { + id: "index", + name: "Index", + direction: "input", + kind: "number", + defaultValue: 0, + }, + ], + outputs: [ + { id: "value", name: "Value", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const index = resolveValueInput("index", "0"); + return `dget(${index})`; + }, + } +); diff --git a/scripts/nodes/library/data/dset.js b/scripts/nodes/library/data/dset.js new file mode 100644 index 0000000..4299785 --- /dev/null +++ b/scripts/nodes/library/data/dset.js @@ -0,0 +1,43 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Writes a persistent value using dset. + */ +export const dsetNode = createNodeModule( + { + id: "data_dset", + title: "Data Set", + category: "Data", + description: "Store a persistent number using DSET().", + searchTags: ["dset", "save", "data", "persistent"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "index", + name: "Index", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "value", + name: "Value", + direction: "input", + kind: "number", + defaultValue: 0, + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const index = resolveValueInput("index", "0"); + const value = resolveValueInput("value", "0"); + const line = `${indent(indentLevel)}dset(${index}, ${value})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/data/index.js b/scripts/nodes/library/data/index.js new file mode 100644 index 0000000..b844d78 --- /dev/null +++ b/scripts/nodes/library/data/index.js @@ -0,0 +1,18 @@ +import { cartdataNode } from "./cartdata.js"; +import { dsetNode } from "./dset.js"; +import { dgetNode } from "./dget.js"; + +/** + * All data persistence node modules backed by PICO-8 helpers. + */ +export const dataNodes = [cartdataNode, dsetNode, dgetNode]; + +/** + * Checklist tracking coverage of documented cart data helpers. + * @type {Array<{ function: string, nodeId: string, implemented: boolean }>} + */ +export const dataFunctionChecklist = [ + { function: "CARTDATA", nodeId: "data_cartdata", implemented: true }, + { function: "DSET", nodeId: "data_dset", implemented: true }, + { function: "DGET", nodeId: "data_dget", implemented: true }, +]; diff --git a/scripts/nodes/library/devkit/devkitConfig.js b/scripts/nodes/library/devkit/devkitConfig.js new file mode 100644 index 0000000..7a4e85f --- /dev/null +++ b/scripts/nodes/library/devkit/devkitConfig.js @@ -0,0 +1,35 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Applies host configuration flags via devkit_config(). + */ +export const devkitConfigNode = createNodeModule( + { + id: "devkit_config", + title: "Devkit Config", + category: "Devkit", + description: "Send a configuration string to devkit_config().", + searchTags: ["devkit", "config", "host"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "config", + name: "Config", + direction: "input", + kind: "string", + defaultValue: "", + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const config = resolveValueInput("config", '""'); + const line = `${indent(indentLevel)}devkit_config(${config})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/devkit/devkitInputRead.js b/scripts/nodes/library/devkit/devkitInputRead.js new file mode 100644 index 0000000..1b9e8d3 --- /dev/null +++ b/scripts/nodes/library/devkit/devkitInputRead.js @@ -0,0 +1,33 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Reads values from devkit-provided inputs via devkit_input(). + */ +export const devkitInputReadNode = createNodeModule( + { + id: "devkit_input_read", + title: "Devkit Input Read", + category: "Devkit", + description: "Fetch the value of a devkit input index.", + searchTags: ["devkit", "input", "read"], + inputs: [ + { + id: "index", + name: "Index", + direction: "input", + kind: "number", + defaultValue: 0, + }, + ], + outputs: [ + { id: "value", name: "Value", direction: "output", kind: "any" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const index = resolveValueInput("index", "0"); + return `devkit_input(${index})`; + }, + } +); diff --git a/scripts/nodes/library/devkit/devkitInputWrite.js b/scripts/nodes/library/devkit/devkitInputWrite.js new file mode 100644 index 0000000..35cfac1 --- /dev/null +++ b/scripts/nodes/library/devkit/devkitInputWrite.js @@ -0,0 +1,43 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Sends values to devkit inputs via devkit_input(). + */ +export const devkitInputWriteNode = createNodeModule( + { + id: "devkit_input_write", + title: "Devkit Input Write", + category: "Devkit", + description: "Push a value to a devkit input index.", + searchTags: ["devkit", "input", "write"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "index", + name: "Index", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "value", + name: "Value", + direction: "input", + kind: "number", + defaultValue: 0, + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const index = resolveValueInput("index", "0"); + const value = resolveValueInput("value", "0"); + const line = `${indent(indentLevel)}devkit_input(${index}, ${value})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/devkit/index.js b/scripts/nodes/library/devkit/index.js new file mode 100644 index 0000000..6f65b38 --- /dev/null +++ b/scripts/nodes/library/devkit/index.js @@ -0,0 +1,22 @@ +import { devkitConfigNode } from "./devkitConfig.js"; +import { devkitInputReadNode } from "./devkitInputRead.js"; +import { devkitInputWriteNode } from "./devkitInputWrite.js"; + +/** + * Node modules covering devkit helpers. + */ +export const devkitNodes = [ + devkitConfigNode, + devkitInputReadNode, + devkitInputWriteNode, +]; + +/** + * Checklist tracking coverage of devkit helpers. + * @type {Array<{ function: string, nodeId: string, implemented: boolean }>} + */ +export const devkitFunctionChecklist = [ + { function: "DEVKIT_CONFIG", nodeId: "devkit_config", implemented: true }, + { function: "DEVKIT_INPUT (read)", nodeId: "devkit_input_read", implemented: true }, + { function: "DEVKIT_INPUT (write)", nodeId: "devkit_input_write", implemented: true }, +]; diff --git a/scripts/nodes/library/eventDraw.js b/scripts/nodes/library/eventDraw.js new file mode 100644 index 0000000..161b1ba --- /dev/null +++ b/scripts/nodes/library/eventDraw.js @@ -0,0 +1,26 @@ +import { createNodeModule } from "../nodeTypes.js"; + +/** + * Lifecycle event triggered once per visible frame. + * @type {import('../nodeTypes.js').NodeModule} + */ +export const eventDrawNode = createNodeModule( + { + id: "event_draw", + title: "Event Draw", + category: "Events", + description: "Called once per visible frame.", + searchTags: ["draw", "render", "frame", "loop"], + unique: true, + inputs: [], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + isEntryPoint: true, + eventName: "_draw", + emitExec: ({ emitNextExec }) => emitNextExec("exec_out"), + } +); diff --git a/scripts/nodes/library/eventStart.js b/scripts/nodes/library/eventStart.js new file mode 100644 index 0000000..7cbeebd --- /dev/null +++ b/scripts/nodes/library/eventStart.js @@ -0,0 +1,26 @@ +import { createNodeModule } from "../nodeTypes.js"; + +/** + * Lifecycle entry triggered once when the cart boots. + * @type {import('../nodeTypes.js').NodeModule} + */ +export const eventInitNode = createNodeModule( + { + id: "event_start", + title: "Event Init", + category: "Events", + description: "Called once on cart startup.", + searchTags: ["start", "init", "boot", "lifecycle"], + unique: true, + inputs: [], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + isEntryPoint: true, + eventName: "_init", + emitExec: ({ emitNextExec }) => emitNextExec("exec_out"), + } +); diff --git a/scripts/nodes/library/eventUpdate.js b/scripts/nodes/library/eventUpdate.js new file mode 100644 index 0000000..7c4b8d0 --- /dev/null +++ b/scripts/nodes/library/eventUpdate.js @@ -0,0 +1,25 @@ +import { createNodeModule } from "../nodeTypes.js"; + +/** + * @type {import('../nodeTypes.js').NodeModule} + */ +export const eventUpdateNode = createNodeModule( + { + id: "event_update", + title: "Event Update", + category: "Events", + description: "Called once per update", + searchTags: ["update", "loop", "frame", "tick"], + unique: true, + inputs: [], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + isEntryPoint: true, + eventName: "_update", + emitExec: ({ emitNextExec }) => emitNextExec("exec_out"), + } +); diff --git a/scripts/nodes/library/forLoop.js b/scripts/nodes/library/forLoop.js new file mode 100644 index 0000000..9aacb03 --- /dev/null +++ b/scripts/nodes/library/forLoop.js @@ -0,0 +1,92 @@ +import { createNodeModule } from "../nodeTypes.js"; + +/** + * Iterates from start to end inclusive using PICO-8 for semantics. + */ +export const forLoopNode = createNodeModule( + { + id: "for_loop", + title: "For Loop", + category: "Logic", + description: + "Iterate from start to end inclusive using PICO-8 for semantics.", + searchTags: ["loop", "for", "iterate", "counter"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "start", + name: "Start", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "end", + name: "End", + direction: "input", + kind: "number", + defaultValue: 10, + }, + { + id: "step", + name: "Step", + direction: "input", + kind: "number", + defaultValue: 1, + }, + ], + outputs: [ + { id: "loop", name: "Loop", direction: "output", kind: "exec" }, + { id: "completed", name: "Completed", direction: "output", kind: "exec" }, + ], + properties: [ + { + key: "index", + label: "Index Variable", + type: "string", + defaultValue: "i", + }, + ], + }, + { + emitExec: ({ + node, + indent, + indentLevel, + resolveValueInput, + sanitizeIdentifier, + emitBranch, + path, + }) => { + const index = sanitizeIdentifier(String(node.properties.index ?? "i")); + const startValue = resolveValueInput("start", "0"); + const endValue = resolveValueInput("end", "0"); + const stepValue = resolveValueInput("step", "1"); + + const lines = [ + `${indent( + indentLevel + )}for ${index} = ${startValue}, ${endValue}, ${stepValue} do`, + ]; + + const loopLines = emitBranch("loop", { + indentLevel: indentLevel + 1, + path: new Set(path), + }); + if (!loopLines.length) { + lines.push(`${indent(indentLevel + 1)}-- loop body`); + } else { + lines.push(...loopLines); + } + + lines.push(`${indent(indentLevel)}end`); + lines.push( + ...emitBranch("completed", { + indentLevel, + path, + }) + ); + return lines; + }, + } +); diff --git a/scripts/nodes/library/getLocalVariable.js b/scripts/nodes/library/getLocalVariable.js new file mode 100644 index 0000000..e69de29 diff --git a/scripts/nodes/library/getVariable.js b/scripts/nodes/library/getVariable.js new file mode 100644 index 0000000..2f34518 --- /dev/null +++ b/scripts/nodes/library/getVariable.js @@ -0,0 +1,30 @@ +import { createNodeModule } from "../nodeTypes.js"; + +/** + * Exposes the current value of a variable. + */ +export const getVariableNode = createNodeModule( + { + id: "get_var", + title: "Get Variable", + category: "Logic", + description: "Expose the current value of a variable.", + searchTags: ["get", "read", "variable", "access"], + inputs: [], + outputs: [{ id: "value", name: "Value", direction: "output", kind: "any" }], + properties: [ + { + key: "name", + label: "Variable Name", + type: "string", + defaultValue: "score", + }, + ], + }, + { + evaluateValue: ({ node, sanitizeIdentifier }) => { + const name = sanitizeIdentifier(String(node.properties.name ?? "var")); + return name; + }, + } +); diff --git a/scripts/nodes/library/gpio/gpioRead.js b/scripts/nodes/library/gpio/gpioRead.js new file mode 100644 index 0000000..a69d104 --- /dev/null +++ b/scripts/nodes/library/gpio/gpioRead.js @@ -0,0 +1,33 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Reads a value from a GPIO pin using gpio(). + */ +export const gpioReadNode = createNodeModule( + { + id: "gpio_read", + title: "GPIO Read", + category: "IO", + description: "Fetch the current value of a GPIO pin.", + searchTags: ["gpio", "hardware", "input"], + inputs: [ + { + id: "index", + name: "Pin", + direction: "input", + kind: "number", + defaultValue: 0, + }, + ], + outputs: [ + { id: "value", name: "Value", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const pin = resolveValueInput("index", "0"); + return `gpio(${pin})`; + }, + } +); diff --git a/scripts/nodes/library/gpio/gpioWrite.js b/scripts/nodes/library/gpio/gpioWrite.js new file mode 100644 index 0000000..ed89130 --- /dev/null +++ b/scripts/nodes/library/gpio/gpioWrite.js @@ -0,0 +1,43 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Writes a value to a GPIO pin using gpio(). + */ +export const gpioWriteNode = createNodeModule( + { + id: "gpio_write", + title: "GPIO Write", + category: "IO", + description: "Set the value for a GPIO pin.", + searchTags: ["gpio", "hardware", "output"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "index", + name: "Pin", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "value", + name: "Value", + direction: "input", + kind: "number", + defaultValue: 0, + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const pin = resolveValueInput("index", "0"); + const value = resolveValueInput("value", "0"); + const line = `${indent(indentLevel)}gpio(${pin}, ${value})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/gpio/index.js b/scripts/nodes/library/gpio/index.js new file mode 100644 index 0000000..9b8d7a2 --- /dev/null +++ b/scripts/nodes/library/gpio/index.js @@ -0,0 +1,16 @@ +import { gpioReadNode } from "./gpioRead.js"; +import { gpioWriteNode } from "./gpioWrite.js"; + +/** + * Node modules covering GPIO helpers. + */ +export const gpioNodes = [gpioReadNode, gpioWriteNode]; + +/** + * Checklist tracking coverage of GPIO read/write helpers. + * @type {Array<{ function: string, nodeId: string, implemented: boolean }>} + */ +export const gpioFunctionChecklist = [ + { function: "GPIO (read)", nodeId: "gpio_read", implemented: true }, + { function: "GPIO (write)", nodeId: "gpio_write", implemented: true }, +]; diff --git a/scripts/nodes/library/graphics/camera.js b/scripts/nodes/library/graphics/camera.js new file mode 100644 index 0000000..d483ded --- /dev/null +++ b/scripts/nodes/library/graphics/camera.js @@ -0,0 +1,37 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Applies a camera offset to subsequent draw calls. + */ +export const cameraNode = createNodeModule( + { + id: "graphics_camera", + title: "Add Camera Offset", + category: "Graphics", + description: "Shift the draw origin by the specified camera offset.", + searchTags: ["camera", "offset", "scroll", "view"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { id: "x", name: "X", direction: "input", kind: "number" }, + { id: "y", name: "Y", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const OMIT = "__pg_omit__"; + const x = resolveValueInput("x", OMIT); + const y = resolveValueInput("y", OMIT); + let call = "camera()"; + if (x !== OMIT) { + call = `camera(${x}, ${y === OMIT ? "0" : y})`; + } + const lines = [`${indent(indentLevel)}${call}`]; + lines.push(...emitNextExec("exec_out")); + return lines; + }, + } +); diff --git a/scripts/nodes/library/graphics/circ.js b/scripts/nodes/library/graphics/circ.js new file mode 100644 index 0000000..5742c39 --- /dev/null +++ b/scripts/nodes/library/graphics/circ.js @@ -0,0 +1,57 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Draws an outlined circle. + */ +export const circNode = createNodeModule( + { + id: "graphics_circ", + title: "Draw Circle", + category: "Graphics", + description: "Render an outlined circle at the specified position.", + searchTags: ["circle", "outline", "shape", "draw"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "x", + name: "X", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "y", + name: "Y", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "radius", + name: "Radius", + direction: "input", + kind: "number", + defaultValue: 4, + }, + { id: "color", name: "Color", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const x = resolveValueInput("x", "0"); + const y = resolveValueInput("y", "0"); + const radius = resolveValueInput("radius", "4"); + const color = resolveValueInput("color", "__pg_omit__"); + const args = [x, y, radius]; + if (color !== "__pg_omit__") { + args.push(color); + } + const line = `${indent(indentLevel)}circ(${args.join(", ")})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/graphics/circfill.js b/scripts/nodes/library/graphics/circfill.js new file mode 100644 index 0000000..6d7e584 --- /dev/null +++ b/scripts/nodes/library/graphics/circfill.js @@ -0,0 +1,57 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Draws a filled circle. + */ +export const circfillNode = createNodeModule( + { + id: "graphics_circfill", + title: "Draw Filled Circle", + category: "Graphics", + description: "Render a filled circle at the specified position.", + searchTags: ["circle", "filled", "shape", "draw"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "x", + name: "X", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "y", + name: "Y", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "radius", + name: "Radius", + direction: "input", + kind: "number", + defaultValue: 4, + }, + { id: "color", name: "Color", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const x = resolveValueInput("x", "0"); + const y = resolveValueInput("y", "0"); + const radius = resolveValueInput("radius", "4"); + const color = resolveValueInput("color", "__pg_omit__"); + const args = [x, y, radius]; + if (color !== "__pg_omit__") { + args.push(color); + } + const line = `${indent(indentLevel)}circfill(${args.join(", ")})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/graphics/clip.js b/scripts/nodes/library/graphics/clip.js new file mode 100644 index 0000000..a14a30d --- /dev/null +++ b/scripts/nodes/library/graphics/clip.js @@ -0,0 +1,63 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Adjusts the active clipping rectangle for subsequent draw calls. + */ +export const clipNode = createNodeModule( + { + id: "graphics_clip", + title: "Set Clipping Rectangle", + category: "Graphics", + description: + "Set or reset the clipping rectangle that constrains all draw operations.", + searchTags: ["clip", "clipping", "graphics", "viewport"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { id: "x", name: "X", direction: "input", kind: "number" }, + { id: "y", name: "Y", direction: "input", kind: "number" }, + { id: "w", name: "Width", direction: "input", kind: "number" }, + { id: "h", name: "Height", direction: "input", kind: "number" }, + { + id: "clip_previous", + name: "Clip Previous", + direction: "input", + kind: "boolean", + defaultValue: false, + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const OMIT = "__pg_omit__"; + const x = resolveValueInput("x", OMIT); + const y = resolveValueInput("y", OMIT); + const w = resolveValueInput("w", OMIT); + const h = resolveValueInput("h", OMIT); + const clipPrevious = resolveValueInput("clip_previous", OMIT); + + let call; + if (x === OMIT && y === OMIT && w === OMIT && h === OMIT) { + call = "clip()"; + } else { + const args = [ + x === OMIT ? "0" : x, + y === OMIT ? "0" : y, + w === OMIT ? "128" : w, + h === OMIT ? "128" : h, + ]; + if (clipPrevious !== OMIT) { + args.push(clipPrevious); + } + call = `clip(${args.join(", ")})`; + } + + const lines = [`${indent(indentLevel)}${call}`]; + lines.push(...emitNextExec("exec_out")); + return lines; + }, + } +); diff --git a/scripts/nodes/library/graphics/cls.js b/scripts/nodes/library/graphics/cls.js new file mode 100644 index 0000000..c803e57 --- /dev/null +++ b/scripts/nodes/library/graphics/cls.js @@ -0,0 +1,32 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Clears the screen and resets clipping. + */ +export const clsNode = createNodeModule( + { + id: "graphics_cls", + title: "Clear Screen", + category: "Graphics", + description: "Clear the display, optionally filling with a specific color.", + searchTags: ["cls", "clear", "screen", "background"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { id: "color", name: "Color", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const OMIT = "__pg_omit__"; + const color = resolveValueInput("color", OMIT); + const call = color === OMIT ? "cls()" : `cls(${color})`; + const lines = [`${indent(indentLevel)}${call}`]; + lines.push(...emitNextExec("exec_out")); + return lines; + }, + } +); diff --git a/scripts/nodes/library/graphics/color.js b/scripts/nodes/library/graphics/color.js new file mode 100644 index 0000000..a1d4997 --- /dev/null +++ b/scripts/nodes/library/graphics/color.js @@ -0,0 +1,32 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Updates the current draw color. + */ +export const colorNode = createNodeModule( + { + id: "graphics_color", + title: "Set Active Color", + category: "Graphics", + description: "Set the active draw color used by subsequent graphics calls.", + searchTags: ["color", "color", "ink", "draw"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { id: "color", name: "Color", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const OMIT = "__pg_omit__"; + const color = resolveValueInput("color", OMIT); + const call = color === OMIT ? "color()" : `color(${color})`; + const lines = [`${indent(indentLevel)}${call}`]; + lines.push(...emitNextExec("exec_out")); + return lines; + }, + } +); diff --git a/scripts/nodes/library/graphics/cursor.js b/scripts/nodes/library/graphics/cursor.js new file mode 100644 index 0000000..6001ed6 --- /dev/null +++ b/scripts/nodes/library/graphics/cursor.js @@ -0,0 +1,46 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Positions the print cursor and optionally sets the draw color. + */ +export const cursorNode = createNodeModule( + { + id: "graphics_cursor", + title: "Set Cursor", + category: "Graphics", + description: + "Move the print cursor and optionally change the active color.", + searchTags: ["cursor", "print", "text", "position"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { id: "x", name: "X", direction: "input", kind: "number" }, + { id: "y", name: "Y", direction: "input", kind: "number" }, + { id: "color", name: "Color", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const OMIT = "__pg_omit__"; + const x = resolveValueInput("x", OMIT); + const y = resolveValueInput("y", OMIT); + const color = resolveValueInput("color", OMIT); + + let call = "cursor()"; + if (x !== OMIT) { + const args = [x, y === OMIT ? "0" : y]; + if (color !== OMIT) { + args.push(color); + } + call = `cursor(${args.join(", ")})`; + } + + const lines = [`${indent(indentLevel)}${call}`]; + lines.push(...emitNextExec("exec_out")); + return lines; + }, + } +); diff --git a/scripts/nodes/library/graphics/fget.js b/scripts/nodes/library/graphics/fget.js new file mode 100644 index 0000000..a44cff3 --- /dev/null +++ b/scripts/nodes/library/graphics/fget.js @@ -0,0 +1,39 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Reads sprite flag data. + */ +export const fgetNode = createNodeModule( + { + id: "graphics_fget", + title: "Get Sprite Flag", + category: "Graphics", + description: "Retrieve the bitfield or specific flag value for a sprite.", + searchTags: ["fget", "flag", "sprite", "meta"], + inputs: [ + { + id: "sprite", + name: "Sprite", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { id: "flag", name: "Flag", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "value", name: "Value", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const OMIT = "__pg_omit__"; + const sprite = resolveValueInput("sprite", "0"); + const flag = resolveValueInput("flag", OMIT); + if (flag === OMIT) { + return `fget(${sprite})`; + } + return `fget(${sprite}, ${flag})`; + }, + } +); diff --git a/scripts/nodes/library/graphics/fillp.js b/scripts/nodes/library/graphics/fillp.js new file mode 100644 index 0000000..7289c8b --- /dev/null +++ b/scripts/nodes/library/graphics/fillp.js @@ -0,0 +1,31 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Configures the active fill pattern. + */ +export const fillpNode = createNodeModule( + { + id: "graphics_fillp", + title: "Set Fill Pattern", + category: "Graphics", + description: "Set the 4x4 fill pattern bitfield used by many draw calls.", + searchTags: ["fillp", "pattern", "fill", "hatch"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { id: "pattern", name: "Pattern", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const pattern = resolveValueInput("pattern", "__pg_omit__"); + const call = pattern === "__pg_omit__" ? "fillp()" : `fillp(${pattern})`; + const lines = [`${indent(indentLevel)}${call}`]; + lines.push(...emitNextExec("exec_out")); + return lines; + }, + } +); diff --git a/scripts/nodes/library/graphics/fset.js b/scripts/nodes/library/graphics/fset.js new file mode 100644 index 0000000..0509ee4 --- /dev/null +++ b/scripts/nodes/library/graphics/fset.js @@ -0,0 +1,52 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Updates sprite flag values. + */ +export const fsetNode = createNodeModule( + { + id: "graphics_fset", + title: "Set Sprite Flag", + category: "Graphics", + description: "Set a sprite's flag bitfield or a specific flag value.", + searchTags: ["fset", "flag", "sprite", "meta"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "sprite", + name: "Sprite", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { id: "flag", name: "Flag", direction: "input", kind: "number" }, + { + id: "value", + name: "Value", + direction: "input", + kind: "any", + defaultValue: true, + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const OMIT = "__pg_omit__"; + const sprite = resolveValueInput("sprite", "0"); + const flag = resolveValueInput("flag", OMIT); + const value = resolveValueInput("value", "false"); + const args = [sprite]; + if (flag === OMIT) { + args.push(value); + } else { + args.push(flag, value); + } + const line = `${indent(indentLevel)}fset(${args.join(", ")})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/graphics/index.js b/scripts/nodes/library/graphics/index.js new file mode 100644 index 0000000..a3ed689 --- /dev/null +++ b/scripts/nodes/library/graphics/index.js @@ -0,0 +1,88 @@ +import { clipNode } from "./clip.js"; +import { psetNode } from "./pset.js"; +import { pgetNode } from "./pget.js"; +import { sgetNode } from "./sget.js"; +import { ssetNode } from "./sset.js"; +import { fgetNode } from "./fget.js"; +import { fsetNode } from "./fset.js"; +import { cursorNode } from "./cursor.js"; +import { colorNode } from "./color.js"; +import { clsNode } from "./cls.js"; +import { cameraNode } from "./camera.js"; +import { circNode } from "./circ.js"; +import { circfillNode } from "./circfill.js"; +import { ovalNode } from "./oval.js"; +import { ovalfillNode } from "./ovalfill.js"; +import { lineNode } from "./line.js"; +import { rectNode } from "./rect.js"; +import { rectfillNode } from "./rectfill.js"; +import { rrectNode } from "./rrect.js"; +import { rrectfillNode } from "./rrectfill.js"; +import { palNode } from "./pal.js"; +import { paltNode } from "./palt.js"; +import { sprNode } from "./spr.js"; +import { ssprNode } from "./sspr.js"; +import { fillpNode } from "./fillp.js"; + +/** + * All graphics related node modules backed by PICO-8 draw helpers. + */ +export const graphicsNodes = [ + clipNode, + psetNode, + pgetNode, + sgetNode, + ssetNode, + fgetNode, + fsetNode, + cursorNode, + colorNode, + clsNode, + cameraNode, + circNode, + circfillNode, + ovalNode, + ovalfillNode, + lineNode, + rectNode, + rectfillNode, + rrectNode, + rrectfillNode, + palNode, + paltNode, + sprNode, + ssprNode, + fillpNode, +]; + +/** + * Checklist tracking coverage of documented graphics helpers. + * @type {Array<{ function: string, nodeId: string, implemented: boolean }>} + */ +export const graphicsFunctionChecklist = [ + { function: "CLIP", nodeId: "graphics_clip", implemented: true }, + { function: "PSET", nodeId: "graphics_pset", implemented: true }, + { function: "PGET", nodeId: "graphics_pget", implemented: true }, + { function: "SGET", nodeId: "graphics_sget", implemented: true }, + { function: "SSET", nodeId: "graphics_sset", implemented: true }, + { function: "FGET", nodeId: "graphics_fget", implemented: true }, + { function: "FSET", nodeId: "graphics_fset", implemented: true }, + { function: "CURSOR", nodeId: "graphics_cursor", implemented: true }, + { function: "COLOR", nodeId: "graphics_color", implemented: true }, + { function: "CLS", nodeId: "graphics_cls", implemented: true }, + { function: "CAMERA", nodeId: "graphics_camera", implemented: true }, + { function: "CIRC", nodeId: "graphics_circ", implemented: true }, + { function: "CIRCFILL", nodeId: "graphics_circfill", implemented: true }, + { function: "OVAL", nodeId: "graphics_oval", implemented: true }, + { function: "OVALFILL", nodeId: "graphics_ovalfill", implemented: true }, + { function: "LINE", nodeId: "graphics_line", implemented: true }, + { function: "RECT", nodeId: "graphics_rect", implemented: true }, + { function: "RECTFILL", nodeId: "graphics_rectfill", implemented: true }, + { function: "RRECT", nodeId: "graphics_rrect", implemented: true }, + { function: "RRECTFILL", nodeId: "graphics_rrectfill", implemented: true }, + { function: "PAL", nodeId: "graphics_pal", implemented: true }, + { function: "PALT", nodeId: "graphics_palt", implemented: true }, + { function: "SPR", nodeId: "graphics_spr", implemented: true }, + { function: "SSPR", nodeId: "graphics_sspr", implemented: true }, + { function: "FILLP", nodeId: "graphics_fillp", implemented: true }, +]; diff --git a/scripts/nodes/library/graphics/line.js b/scripts/nodes/library/graphics/line.js new file mode 100644 index 0000000..39f0127 --- /dev/null +++ b/scripts/nodes/library/graphics/line.js @@ -0,0 +1,60 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Draws a line segment. + */ +export const lineNode = createNodeModule( + { + id: "graphics_line", + title: "Draw Line", + category: "Graphics", + description: + "Draw a line between two points or update the current pen position.", + searchTags: ["line", "draw", "segment", "stroke"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "x0", + name: "X0", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "y0", + name: "Y0", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { id: "x1", name: "X1", direction: "input", kind: "number" }, + { id: "y1", name: "Y1", direction: "input", kind: "number" }, + { id: "color", name: "Color", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const OMIT = "__pg_omit__"; + const x0 = resolveValueInput("x0", "0"); + const y0 = resolveValueInput("y0", "0"); + const x1 = resolveValueInput("x1", OMIT); + const y1 = resolveValueInput("y1", OMIT); + const color = resolveValueInput("color", OMIT); + + const args = [x0, y0]; + if (x1 !== OMIT && y1 !== OMIT) { + args.push(x1, y1); + if (color !== OMIT) { + args.push(color); + } + } + + const line = `${indent(indentLevel)}line(${args.join(", ")})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/graphics/oval.js b/scripts/nodes/library/graphics/oval.js new file mode 100644 index 0000000..d48a596 --- /dev/null +++ b/scripts/nodes/library/graphics/oval.js @@ -0,0 +1,65 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Draws an outlined oval. + */ +export const ovalNode = createNodeModule( + { + id: "graphics_oval", + title: "Draw Oval", + category: "Graphics", + description: "Render an ellipse using the provided bounding box.", + searchTags: ["oval", "ellipse", "shape", "outline"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "x0", + name: "X0", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "y0", + name: "Y0", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "x1", + name: "X1", + direction: "input", + kind: "number", + defaultValue: 8, + }, + { + id: "y1", + name: "Y1", + direction: "input", + kind: "number", + defaultValue: 8, + }, + { id: "color", name: "Color", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const x0 = resolveValueInput("x0", "0"); + const y0 = resolveValueInput("y0", "0"); + const x1 = resolveValueInput("x1", "8"); + const y1 = resolveValueInput("y1", "8"); + const color = resolveValueInput("color", "__pg_omit__"); + const args = [x0, y0, x1, y1]; + if (color !== "__pg_omit__") { + args.push(color); + } + const line = `${indent(indentLevel)}oval(${args.join(", ")})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/graphics/ovalfill.js b/scripts/nodes/library/graphics/ovalfill.js new file mode 100644 index 0000000..b26bc92 --- /dev/null +++ b/scripts/nodes/library/graphics/ovalfill.js @@ -0,0 +1,65 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Draws a filled oval. + */ +export const ovalfillNode = createNodeModule( + { + id: "graphics_ovalfill", + title: "Draw Filled Oval", + category: "Graphics", + description: "Render a filled ellipse using the provided bounding box.", + searchTags: ["oval", "ellipse", "filled", "shape"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "x0", + name: "X0", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "y0", + name: "Y0", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "x1", + name: "X1", + direction: "input", + kind: "number", + defaultValue: 8, + }, + { + id: "y1", + name: "Y1", + direction: "input", + kind: "number", + defaultValue: 8, + }, + { id: "color", name: "Color", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const x0 = resolveValueInput("x0", "0"); + const y0 = resolveValueInput("y0", "0"); + const x1 = resolveValueInput("x1", "8"); + const y1 = resolveValueInput("y1", "8"); + const color = resolveValueInput("color", "__pg_omit__"); + const args = [x0, y0, x1, y1]; + if (color !== "__pg_omit__") { + args.push(color); + } + const line = `${indent(indentLevel)}ovalfill(${args.join(", ")})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/graphics/pal.js b/scripts/nodes/library/graphics/pal.js new file mode 100644 index 0000000..29214f0 --- /dev/null +++ b/scripts/nodes/library/graphics/pal.js @@ -0,0 +1,51 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Remaps palette colors. + */ +export const palNode = createNodeModule( + { + id: "graphics_pal", + title: "Remap Palette Color", + category: "Graphics", + description: "Swap one palette color for another or reset the palette.", + searchTags: ["pal", "palette", "color", "remap"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { id: "c0", name: "Color 0", direction: "input", kind: "number" }, + { + id: "c1", + name: "Color 1", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { id: "p", name: "Palette", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const OMIT = "__pg_omit__"; + const c0 = resolveValueInput("c0", OMIT); + const c1 = resolveValueInput("c1", "0"); + const p = resolveValueInput("p", OMIT); + + let call = "pal()"; + if (c0 !== OMIT) { + const args = [c0, c1]; + if (p !== OMIT) { + args.push(p); + } + call = `pal(${args.join(", ")})`; + } + + const lines = [`${indent(indentLevel)}${call}`]; + lines.push(...emitNextExec("exec_out")); + return lines; + }, + } +); diff --git a/scripts/nodes/library/graphics/palt.js b/scripts/nodes/library/graphics/palt.js new file mode 100644 index 0000000..daa9726 --- /dev/null +++ b/scripts/nodes/library/graphics/palt.js @@ -0,0 +1,47 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Adjusts sprite transparency settings. + */ +export const paltNode = createNodeModule( + { + id: "graphics_palt", + title: "Set Sprite Transparency", + category: "Graphics", + description: "Set or reset sprite transparency flags.", + searchTags: ["palt", "transparency", "palette", "sprite"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { id: "color", name: "Color", direction: "input", kind: "number" }, + { + id: "transparent", + name: "Transparent", + direction: "input", + kind: "boolean", + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const OMIT = "__pg_omit__"; + const color = resolveValueInput("color", OMIT); + const transparent = resolveValueInput("transparent", OMIT); + + let call = "palt()"; + if (color !== OMIT) { + if (transparent === OMIT) { + call = `palt(${color})`; + } else { + call = `palt(${color}, ${transparent})`; + } + } + const lines = [`${indent(indentLevel)}${call}`]; + lines.push(...emitNextExec("exec_out")); + return lines; + }, + } +); diff --git a/scripts/nodes/library/graphics/pget.js b/scripts/nodes/library/graphics/pget.js new file mode 100644 index 0000000..352d838 --- /dev/null +++ b/scripts/nodes/library/graphics/pget.js @@ -0,0 +1,41 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Reads the color of a screen pixel. + */ +export const pgetNode = createNodeModule( + { + id: "graphics_pget", + title: "Get Pixel Color", + category: "Graphics", + description: "Fetch the color index at the given screen coordinate.", + searchTags: ["pget", "pixel", "read", "sample"], + inputs: [ + { + id: "x", + name: "X", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "y", + name: "Y", + direction: "input", + kind: "number", + defaultValue: 0, + }, + ], + outputs: [ + { id: "value", name: "Color", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const x = resolveValueInput("x", "0"); + const y = resolveValueInput("y", "0"); + return `pget(${x}, ${y})`; + }, + } +); diff --git a/scripts/nodes/library/graphics/pset.js b/scripts/nodes/library/graphics/pset.js new file mode 100644 index 0000000..8a4adab --- /dev/null +++ b/scripts/nodes/library/graphics/pset.js @@ -0,0 +1,50 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Draws a single pixel using the current or specified color. + */ +export const psetNode = createNodeModule( + { + id: "graphics_pset", + title: "Set Pixel Color", + category: "Graphics", + description: "Set a pixel on screen to a color index.", + searchTags: ["pset", "pixel", "draw", "plot"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "x", + name: "X", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "y", + name: "Y", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { id: "color", name: "Color", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const OMIT = "__pg_omit__"; + const x = resolveValueInput("x", "0"); + const y = resolveValueInput("y", "0"); + const color = resolveValueInput("color", OMIT); + const args = [x, y]; + if (color !== OMIT) { + args.push(color); + } + const line = `${indent(indentLevel)}pset(${args.join(", ")})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/graphics/rect.js b/scripts/nodes/library/graphics/rect.js new file mode 100644 index 0000000..367898e --- /dev/null +++ b/scripts/nodes/library/graphics/rect.js @@ -0,0 +1,65 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Draws an outlined rectangle. + */ +export const rectNode = createNodeModule( + { + id: "graphics_rect", + title: "Draw Rectangle", + category: "Graphics", + description: "Render a rectangle outline defined by two corners.", + searchTags: ["rect", "rectangle", "outline", "shape"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "x0", + name: "X0", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "y0", + name: "Y0", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "x1", + name: "X1", + direction: "input", + kind: "number", + defaultValue: 8, + }, + { + id: "y1", + name: "Y1", + direction: "input", + kind: "number", + defaultValue: 8, + }, + { id: "color", name: "Color", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const x0 = resolveValueInput("x0", "0"); + const y0 = resolveValueInput("y0", "0"); + const x1 = resolveValueInput("x1", "8"); + const y1 = resolveValueInput("y1", "8"); + const color = resolveValueInput("color", "__pg_omit__"); + const args = [x0, y0, x1, y1]; + if (color !== "__pg_omit__") { + args.push(color); + } + const line = `${indent(indentLevel)}rect(${args.join(", ")})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/graphics/rectfill.js b/scripts/nodes/library/graphics/rectfill.js new file mode 100644 index 0000000..308b13c --- /dev/null +++ b/scripts/nodes/library/graphics/rectfill.js @@ -0,0 +1,65 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Draws a filled rectangle. + */ +export const rectfillNode = createNodeModule( + { + id: "graphics_rectfill", + title: "Draw Filled Rectangle", + category: "Graphics", + description: "Render a filled rectangle defined by two corners.", + searchTags: ["rect", "rectangle", "filled", "shape"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "x0", + name: "X0", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "y0", + name: "Y0", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "x1", + name: "X1", + direction: "input", + kind: "number", + defaultValue: 8, + }, + { + id: "y1", + name: "Y1", + direction: "input", + kind: "number", + defaultValue: 8, + }, + { id: "color", name: "Color", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const x0 = resolveValueInput("x0", "0"); + const y0 = resolveValueInput("y0", "0"); + const x1 = resolveValueInput("x1", "8"); + const y1 = resolveValueInput("y1", "8"); + const color = resolveValueInput("color", "__pg_omit__"); + const args = [x0, y0, x1, y1]; + if (color !== "__pg_omit__") { + args.push(color); + } + const line = `${indent(indentLevel)}rectfill(${args.join(", ")})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/graphics/rrect.js b/scripts/nodes/library/graphics/rrect.js new file mode 100644 index 0000000..6dc4f51 --- /dev/null +++ b/scripts/nodes/library/graphics/rrect.js @@ -0,0 +1,73 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Draws an outlined rounded rectangle. + */ +export const rrectNode = createNodeModule( + { + id: "graphics_rrect", + title: "Draw Rounded Rectangle", + category: "Graphics", + description: "Render a rounded rectangle outline with the supplied radius.", + searchTags: ["rounded", "rectangle", "outline", "shape"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "x", + name: "X", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "y", + name: "Y", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "w", + name: "Width", + direction: "input", + kind: "number", + defaultValue: 8, + }, + { + id: "h", + name: "Height", + direction: "input", + kind: "number", + defaultValue: 8, + }, + { + id: "r", + name: "Radius", + direction: "input", + kind: "number", + defaultValue: 2, + }, + { id: "color", name: "Color", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const x = resolveValueInput("x", "0"); + const y = resolveValueInput("y", "0"); + const w = resolveValueInput("w", "8"); + const h = resolveValueInput("h", "8"); + const r = resolveValueInput("r", "2"); + const color = resolveValueInput("color", "__pg_omit__"); + const args = [x, y, w, h, r]; + if (color !== "__pg_omit__") { + args.push(color); + } + const line = `${indent(indentLevel)}rrect(${args.join(", ")})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/graphics/rrectfill.js b/scripts/nodes/library/graphics/rrectfill.js new file mode 100644 index 0000000..bd2699a --- /dev/null +++ b/scripts/nodes/library/graphics/rrectfill.js @@ -0,0 +1,73 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Draws a filled rounded rectangle. + */ +export const rrectfillNode = createNodeModule( + { + id: "graphics_rrectfill", + title: "Draw Filled Rounded Rectangle", + category: "Graphics", + description: "Render a filled rounded rectangle with the supplied radius.", + searchTags: ["rounded", "rectangle", "filled", "shape"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "x", + name: "X", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "y", + name: "Y", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "w", + name: "Width", + direction: "input", + kind: "number", + defaultValue: 8, + }, + { + id: "h", + name: "Height", + direction: "input", + kind: "number", + defaultValue: 8, + }, + { + id: "r", + name: "Radius", + direction: "input", + kind: "number", + defaultValue: 2, + }, + { id: "color", name: "Color", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const x = resolveValueInput("x", "0"); + const y = resolveValueInput("y", "0"); + const w = resolveValueInput("w", "8"); + const h = resolveValueInput("h", "8"); + const r = resolveValueInput("r", "2"); + const color = resolveValueInput("color", "__pg_omit__"); + const args = [x, y, w, h, r]; + if (color !== "__pg_omit__") { + args.push(color); + } + const line = `${indent(indentLevel)}rrectfill(${args.join(", ")})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/graphics/sget.js b/scripts/nodes/library/graphics/sget.js new file mode 100644 index 0000000..a86af2f --- /dev/null +++ b/scripts/nodes/library/graphics/sget.js @@ -0,0 +1,42 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Reads a pixel from the sprite sheet. + */ +export const sgetNode = createNodeModule( + { + id: "graphics_sget", + title: "Get Sprite Pixel", + category: "Graphics", + description: + "Retrieve the color index from the sprite sheet at the given coordinate.", + searchTags: ["sget", "sprite", "sheet", "read"], + inputs: [ + { + id: "x", + name: "X", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "y", + name: "Y", + direction: "input", + kind: "number", + defaultValue: 0, + }, + ], + outputs: [ + { id: "value", name: "Color", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const x = resolveValueInput("x", "0"); + const y = resolveValueInput("y", "0"); + return `sget(${x}, ${y})`; + }, + } +); diff --git a/scripts/nodes/library/graphics/spr.js b/scripts/nodes/library/graphics/spr.js new file mode 100644 index 0000000..7bc15e2 --- /dev/null +++ b/scripts/nodes/library/graphics/spr.js @@ -0,0 +1,76 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Draws sprites from the sprite sheet. + */ +export const sprNode = createNodeModule( + { + id: "graphics_spr", + title: "Draw Sprite", + category: "Graphics", + description: "Blit one or more sprites at the given screen position.", + searchTags: ["spr", "sprite", "draw", "blit"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "sprite", + name: "Sprite", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "x", + name: "X", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "y", + name: "Y", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { id: "w", name: "Width", direction: "input", kind: "number" }, + { id: "h", name: "Height", direction: "input", kind: "number" }, + { id: "flip_x", name: "Flip X", direction: "input", kind: "boolean" }, + { id: "flip_y", name: "Flip Y", direction: "input", kind: "boolean" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const OMIT = "__pg_omit__"; + const sprite = resolveValueInput("sprite", "0"); + const x = resolveValueInput("x", "0"); + const y = resolveValueInput("y", "0"); + const w = resolveValueInput("w", OMIT); + const h = resolveValueInput("h", OMIT); + const flipX = resolveValueInput("flip_x", OMIT); + const flipY = resolveValueInput("flip_y", OMIT); + + const args = [sprite, x, y]; + const includeSize = + w !== OMIT || h !== OMIT || flipX !== OMIT || flipY !== OMIT; + + if (includeSize) { + args.push(w === OMIT ? "1" : w, h === OMIT ? "1" : h); + } + + if (flipX !== OMIT || flipY !== OMIT) { + args.push( + flipX === OMIT ? "false" : flipX, + flipY === OMIT ? "false" : flipY + ); + } + + const line = `${indent(indentLevel)}spr(${args.join(", ")})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/graphics/sset.js b/scripts/nodes/library/graphics/sset.js new file mode 100644 index 0000000..8832afc --- /dev/null +++ b/scripts/nodes/library/graphics/sset.js @@ -0,0 +1,51 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Writes a pixel to the sprite sheet. + */ +export const ssetNode = createNodeModule( + { + id: "graphics_sset", + title: "Set Sprite Pixel", + category: "Graphics", + description: "Assign a color to the sprite sheet at the given coordinate.", + searchTags: ["sset", "sprite", "sheet", "write"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "x", + name: "X", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "y", + name: "Y", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "color", + name: "Color", + direction: "input", + kind: "number", + defaultValue: 0, + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const x = resolveValueInput("x", "0"); + const y = resolveValueInput("y", "0"); + const color = resolveValueInput("color", "0"); + const line = `${indent(indentLevel)}sset(${x}, ${y}, ${color})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/graphics/sspr.js b/scripts/nodes/library/graphics/sspr.js new file mode 100644 index 0000000..fedbff8 --- /dev/null +++ b/scripts/nodes/library/graphics/sspr.js @@ -0,0 +1,100 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Draws a rectangular region of the sprite sheet with scaling support. + */ +export const ssprNode = createNodeModule( + { + id: "graphics_sspr", + title: "Draw Scaled Sprite", + category: "Graphics", + description: "Stretch or flip a sprite sheet region to the screen.", + searchTags: ["sspr", "sprite", "scale", "blit"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "sx", + name: "SX", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "sy", + name: "SY", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "sw", + name: "SW", + direction: "input", + kind: "number", + defaultValue: 8, + }, + { + id: "sh", + name: "SH", + direction: "input", + kind: "number", + defaultValue: 8, + }, + { + id: "dx", + name: "DX", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "dy", + name: "DY", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { id: "dw", name: "DW", direction: "input", kind: "number" }, + { id: "dh", name: "DH", direction: "input", kind: "number" }, + { id: "flip_x", name: "Flip X", direction: "input", kind: "boolean" }, + { id: "flip_y", name: "Flip Y", direction: "input", kind: "boolean" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const OMIT = "__pg_omit__"; + const sx = resolveValueInput("sx", "0"); + const sy = resolveValueInput("sy", "0"); + const sw = resolveValueInput("sw", "8"); + const sh = resolveValueInput("sh", "8"); + const dx = resolveValueInput("dx", "0"); + const dy = resolveValueInput("dy", "0"); + const dw = resolveValueInput("dw", OMIT); + const dh = resolveValueInput("dh", OMIT); + const flipX = resolveValueInput("flip_x", OMIT); + const flipY = resolveValueInput("flip_y", OMIT); + + const args = [sx, sy, sw, sh, dx, dy]; + const includeDestSize = + dw !== OMIT || dh !== OMIT || flipX !== OMIT || flipY !== OMIT; + + if (includeDestSize) { + args.push(dw === OMIT ? sw : dw, dh === OMIT ? sh : dh); + } + + if (flipX !== OMIT || flipY !== OMIT) { + args.push( + flipX === OMIT ? "false" : flipX, + flipY === OMIT ? "false" : flipY + ); + } + + const line = `${indent(indentLevel)}sspr(${args.join(", ")})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/ifNode.js b/scripts/nodes/library/ifNode.js new file mode 100644 index 0000000..a7515c9 --- /dev/null +++ b/scripts/nodes/library/ifNode.js @@ -0,0 +1,67 @@ +import { createNodeModule } from "../nodeTypes.js"; + +/** + * Branches execution based on a boolean condition. + */ +export const ifNode = createNodeModule( + { + id: "if", + title: "If", + category: "Logic", + description: "Branch execution based on a boolean condition.", + searchTags: ["if", "condition", "branch", "logic"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "condition", + name: "Condition", + direction: "input", + kind: "boolean", + }, + ], + outputs: [ + { id: "then", name: "Then", direction: "output", kind: "exec" }, + { id: "else", name: "Else", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ + indent, + indentLevel, + resolveValueInput, + emitBranch, + findExecTargets, + path, + }) => { + const condition = resolveValueInput("condition", "false"); + const lines = [`${indent(indentLevel)}if ${condition} then`]; + + const thenLines = emitBranch("then", { + indentLevel: indentLevel + 1, + path: new Set(path), + }); + if (!thenLines.length) { + lines.push(`${indent(indentLevel + 1)}-- then branch`); + } else { + lines.push(...thenLines); + } + + if (findExecTargets("else").length) { + lines.push(`${indent(indentLevel)}else`); + const elseLines = emitBranch("else", { + indentLevel: indentLevel + 1, + path: new Set(path), + }); + if (!elseLines.length) { + lines.push(`${indent(indentLevel + 1)}-- else branch`); + } else { + lines.push(...elseLines); + } + } + + lines.push(`${indent(indentLevel)}end`); + return lines; + }, + } +); diff --git a/scripts/nodes/library/index.js b/scripts/nodes/library/index.js new file mode 100644 index 0000000..ac160cd --- /dev/null +++ b/scripts/nodes/library/index.js @@ -0,0 +1,87 @@ +import { eventInitNode } from "./eventStart.js"; +import { eventUpdateNode } from "./eventUpdate.js"; +import { eventDrawNode } from "./eventDraw.js"; +import { printNode } from "./print.js"; +import { setVariableNode } from "./setVariable.js"; +import { getVariableNode } from "./getVariable.js"; +import { numberLiteralNode } from "./numberLiteral.js"; +import { stringLiteralNode } from "./stringLiteral.js"; +import { booleanLiteralNode } from "./booleanLiteral.js"; +import { addNumberNode } from "./addNumber.js"; +import { multiplyNumberNode } from "./multiplyNumber.js"; +import { compareNode } from "./compare.js"; +import { sequenceNode } from "./sequence.js"; +import { ifNode } from "./ifNode.js"; +import { forLoopNode } from "./forLoop.js"; +import { customEventNode } from "./customEvent.js"; +import { callCustomEventNode } from "./callCustomEvent.js"; +import { graphicsNodes, graphicsFunctionChecklist } from "./graphics/index.js"; +import { systemNodes, systemFunctionChecklist } from "./system/index.js"; +import { tableNodes, tableFunctionChecklist } from "./table/index.js"; +import { inputNodes, inputFunctionChecklist } from "./input/index.js"; +import { audioNodes, audioFunctionChecklist } from "./audio/index.js"; +import { mapNodes, mapFunctionChecklist } from "./map/index.js"; +import { memoryNodes, memoryFunctionChecklist } from "./memory/index.js"; +import { mathNodes, mathFunctionChecklist } from "./math/index.js"; +import { menuNodes, menuFunctionChecklist } from "./menu/index.js"; +import { stringNodes, stringFunctionChecklist } from "./strings/index.js"; +import { dataNodes, dataFunctionChecklist } from "./data/index.js"; +import { gpioNodes, gpioFunctionChecklist } from "./gpio/index.js"; +import { serialNodes, serialFunctionChecklist } from "./serial/index.js"; +import { devkitNodes, devkitFunctionChecklist } from "./devkit/index.js"; +import { luaNodes, luaFunctionChecklist } from "./lua/index.js"; +import { localVariableNodes } from "./localVariables.js"; + +export const nodeModules = [ + eventInitNode, + eventUpdateNode, + eventDrawNode, + printNode, + setVariableNode, + getVariableNode, + numberLiteralNode, + stringLiteralNode, + booleanLiteralNode, + addNumberNode, + multiplyNumberNode, + compareNode, + sequenceNode, + ifNode, + forLoopNode, + customEventNode, + callCustomEventNode, + ...localVariableNodes, + ...graphicsNodes, + ...systemNodes, + ...tableNodes, + ...inputNodes, + ...audioNodes, + ...mapNodes, + ...memoryNodes, + ...mathNodes, + ...menuNodes, + ...stringNodes, + ...dataNodes, + ...gpioNodes, + ...serialNodes, + ...devkitNodes, + ...luaNodes, +]; + +export { + systemFunctionChecklist, + graphicsFunctionChecklist, + tableFunctionChecklist, + inputFunctionChecklist, + audioFunctionChecklist, + mapFunctionChecklist, + memoryFunctionChecklist, + mathFunctionChecklist, + menuFunctionChecklist, + stringFunctionChecklist, + dataFunctionChecklist, + gpioFunctionChecklist, + serialFunctionChecklist, + devkitFunctionChecklist, + luaFunctionChecklist, +}; diff --git a/scripts/nodes/library/input/btn.js b/scripts/nodes/library/input/btn.js new file mode 100644 index 0000000..fca18b8 --- /dev/null +++ b/scripts/nodes/library/input/btn.js @@ -0,0 +1,55 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Reads the state of a PICO-8 button using BTN. + */ +export const btnNode = createNodeModule( + { + id: "input_btn", + title: "Button State", + category: "Input", + description: "Read the current pressed state for a controller button.", + searchTags: ["btn", "input", "button", "press"], + inputs: [ + { + id: "button", + name: "Button", + direction: "input", + kind: "number", + description: "Button index or glyph code", + }, + { + id: "player", + name: "Player", + direction: "input", + kind: "number", + description: "Optional player index", + }, + ], + outputs: [ + { id: "value", name: "Pressed", direction: "output", kind: "boolean" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const OMIT = "__pg_omit__"; + const button = resolveValueInput("button", OMIT); + const player = resolveValueInput("player", OMIT); + + if (button === OMIT && player === OMIT) { + return "btn()"; + } + + if (button === OMIT) { + return `btn(nil, ${player})`; + } + + if (player === OMIT) { + return `btn(${button})`; + } + + return `btn(${button}, ${player})`; + }, + } +); diff --git a/scripts/nodes/library/input/btnp.js b/scripts/nodes/library/input/btnp.js new file mode 100644 index 0000000..e70565b --- /dev/null +++ b/scripts/nodes/library/input/btnp.js @@ -0,0 +1,56 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Reads button press transitions using BTNP. + */ +export const btnpNode = createNodeModule( + { + id: "input_btnp", + title: "Button Press", + category: "Input", + description: + "Detect when a button is initially pressed or repeats using BTNP.", + searchTags: ["btnp", "input", "button", "press", "repeat"], + inputs: [ + { + id: "button", + name: "Button", + direction: "input", + kind: "number", + description: "Button index or glyph code", + }, + { + id: "player", + name: "Player", + direction: "input", + kind: "number", + description: "Optional player index", + }, + ], + outputs: [ + { id: "value", name: "Pressed", direction: "output", kind: "boolean" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const OMIT = "__pg_omit__"; + const button = resolveValueInput("button", OMIT); + const player = resolveValueInput("player", OMIT); + + if (button === OMIT && player === OMIT) { + return "btnp()"; + } + + if (button === OMIT) { + return `btnp(nil, ${player})`; + } + + if (player === OMIT) { + return `btnp(${button})`; + } + + return `btnp(${button}, ${player})`; + }, + } +); diff --git a/scripts/nodes/library/input/controllerButtons.js b/scripts/nodes/library/input/controllerButtons.js new file mode 100644 index 0000000..dd2369b --- /dev/null +++ b/scripts/nodes/library/input/controllerButtons.js @@ -0,0 +1,175 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +const DEFAULT_PLAYER_VALUE = "0"; + +/** + * Normalizes the stored player property to a valid controller index. + * + * @param {unknown} value Raw inspector value. + * @returns {number} + */ +const resolvePlayerIndex = (value) => { + const numeric = Number.parseInt( + typeof value === "string" ? value : String(value ?? DEFAULT_PLAYER_VALUE), + 10 + ); + if (!Number.isFinite(numeric) || numeric < 0) { + return 0; + } + if (numeric > 7) { + return 7; + } + return numeric; +}; + +const BUTTON_OPTIONS = [ + { value: "0", label: "Left" }, + { value: "1", label: "Right" }, + { value: "2", label: "Up" }, + { value: "3", label: "Down" }, + { value: "4", label: "O" }, + { value: "5", label: "X" }, +]; + +const BUTTON_VALUE_SET = new Set(BUTTON_OPTIONS.map((option) => option.value)); + +const DEFAULT_BUTTON_VALUE = BUTTON_OPTIONS[0]?.value ?? "0"; + +/** + * Normalizes button property values to a valid BTN index represented as a string. + * + * @param {unknown} value Raw inspector value. + * @returns {string} + */ +const resolveButtonValue = (value) => { + const numeric = Number.parseInt( + typeof value === "string" ? value : String(value ?? DEFAULT_BUTTON_VALUE), + 10 + ); + + const candidate = Number.isFinite(numeric) ? String(numeric) : DEFAULT_BUTTON_VALUE; + if (BUTTON_VALUE_SET.has(candidate)) { + return candidate; + } + return DEFAULT_BUTTON_VALUE; +}; + +const PLAYER_OPTIONS = [ + { value: "0", label: "Player 0 (Cursors / Z X)" }, + { value: "1", label: "Player 1 (SFED / Tab Q W A)" }, + { value: "2", label: "Player 2" }, + { value: "3", label: "Player 3" }, + { value: "4", label: "Player 4" }, + { value: "5", label: "Player 5" }, + { value: "6", label: "Player 6" }, + { value: "7", label: "Player 7" }, +]; + +/** + * Evaluates whether the configured button is currently pressed. + */ +export const controllerButtonsNode = createNodeModule( + { + id: "input_button_constants", + title: "Named Button", + category: "Input", + description: + "Read a specific controller button with a dropdown selector and optional player override.", + searchTags: [ + "button", + "controller", + "dpad", + "input", + "player", + "named", + "press", + ], + inputs: [ + { + id: "button", + name: "Button", + direction: "input", + kind: "number", + description: "Optional button index override", + }, + { + id: "player", + name: "Player", + direction: "input", + kind: "number", + description: "Optional player index override", + }, + ], + outputs: [ + { + id: "pressed", + name: "Pressed", + direction: "output", + kind: "boolean", + }, + ], + properties: [ + { + key: "button", + label: "Button", + type: "enum", + defaultValue: DEFAULT_BUTTON_VALUE, + options: BUTTON_OPTIONS, + }, + { + key: "player", + label: "Default Player", + type: "enum", + defaultValue: DEFAULT_PLAYER_VALUE, + options: PLAYER_OPTIONS, + }, + ], + initializeProperties: (properties) => { + properties.button = resolveButtonValue(properties.button); + const normalizedPlayer = resolvePlayerIndex(properties.player); + properties.player = String(normalizedPlayer); + const buttonInlineKey = "pin:button"; + const playerInlineKey = "pin:player"; + const numericButton = Number.parseInt(properties.button, 10); + const numericPlayer = normalizedPlayer; + properties[buttonInlineKey] = Number.isFinite(numericButton) + ? numericButton + : 0; + properties[playerInlineKey] = Number.isFinite(numericPlayer) + ? numericPlayer + : 0; + }, + onPropertiesChanged: (properties) => { + properties.button = resolveButtonValue(properties.button); + const normalizedPlayer = resolvePlayerIndex(properties.player); + properties.player = String(normalizedPlayer); + const buttonInlineKey = "pin:button"; + const playerInlineKey = "pin:player"; + const numericButton = Number.parseInt(properties.button, 10); + const numericPlayer = normalizedPlayer; + properties[buttonInlineKey] = Number.isFinite(numericButton) + ? numericButton + : 0; + properties[playerInlineKey] = Number.isFinite(numericPlayer) + ? numericPlayer + : 0; + }, + }, + { + evaluateValue: ({ node, resolveValueInput, formatLiteral }) => { + const buttonValue = resolveButtonValue(node.properties.button); + const parsedButton = Number.parseInt(buttonValue, 10); + const buttonIndex = Number.isFinite(parsedButton) ? parsedButton : 0; + const fallbackButton = formatLiteral("number", buttonIndex); + const buttonInput = resolveValueInput("button", fallbackButton); + + const fallbackPlayer = formatLiteral( + "number", + resolvePlayerIndex(node.properties.player) + ); + const playerInput = resolveValueInput("player", fallbackPlayer); + + return `btn(${buttonInput}, ${playerInput})`; + }, + } +); diff --git a/scripts/nodes/library/input/index.js b/scripts/nodes/library/input/index.js new file mode 100644 index 0000000..d2fbde4 --- /dev/null +++ b/scripts/nodes/library/input/index.js @@ -0,0 +1,17 @@ +import { btnNode } from "./btn.js"; +import { btnpNode } from "./btnp.js"; +import { controllerButtonsNode } from "./controllerButtons.js"; + +/** + * All input-related node modules backed by PICO-8 helpers. + */ +export const inputNodes = [controllerButtonsNode, btnNode, btnpNode]; + +/** + * Checklist tracking coverage of documented input helpers. + * @type {Array<{ function: string, nodeId: string, implemented: boolean }>} + */ +export const inputFunctionChecklist = [ + { function: "BTN", nodeId: "input_btn", implemented: true }, + { function: "BTNP", nodeId: "input_btnp", implemented: true }, +]; diff --git a/scripts/nodes/library/localVariable.js b/scripts/nodes/library/localVariable.js new file mode 100644 index 0000000..e69de29 diff --git a/scripts/nodes/library/localVariables.js b/scripts/nodes/library/localVariables.js new file mode 100644 index 0000000..a1ff05a --- /dev/null +++ b/scripts/nodes/library/localVariables.js @@ -0,0 +1,169 @@ +import { createNodeModule } from "../nodeTypes.js"; + +const LOCAL_TYPES = [ + { value: "number", label: "Number" }, + { value: "string", label: "String" }, + { value: "boolean", label: "Boolean" }, + { value: "table", label: "Table" }, +]; + +const DEFAULT_LOCAL_TYPE = LOCAL_TYPES[0]?.value ?? "number"; + +const sanitizeType = (raw) => { + const candidate = typeof raw === "string" ? raw.toLowerCase() : DEFAULT_LOCAL_TYPE; + return LOCAL_TYPES.some((entry) => entry.value === candidate) + ? candidate + : DEFAULT_LOCAL_TYPE; +}; + +const ensureName = (raw, fallback = "localVar") => { + const value = typeof raw === "string" ? raw.trim() : ""; + return value.length ? value : fallback; +}; + +const resolveValuePropertyKey = (type) => { + switch (type) { + case "string": + return "valueString"; + case "boolean": + return "valueBoolean"; + case "table": + return "valueTable"; + case "number": + default: + return "valueNumber"; + } +}; + +const defaultValueForType = (type) => { + switch (type) { + case "string": + return ""; + case "boolean": + return false; + case "table": + return "{}"; + case "number": + default: + return 0; + } +}; + +const initializeLocalProperties = (properties, { includeValue }) => { + const type = sanitizeType(properties.variableType); + properties.variableType = type; + properties.name = ensureName(properties.name); + + if (typeof properties.valueNumber !== "number") { + properties.valueNumber = Number.isFinite(Number(properties.valueNumber)) + ? Number(properties.valueNumber) + : 0; + } + if (typeof properties.valueString !== "string") { + properties.valueString = String(properties.valueString ?? ""); + } + if (typeof properties.valueBoolean !== "boolean") { + properties.valueBoolean = Boolean(properties.valueBoolean); + } + if (typeof properties.valueTable !== "string") { + properties.valueTable = String(properties.valueTable ?? "{}"); + } + + if (includeValue) { + const key = resolveValuePropertyKey(type); + const value = properties[key] ?? defaultValueForType(type); + properties[key] = value; + } +}; + +const normalizeLocalProperties = (properties, { includeValue }) => { + const type = sanitizeType(properties.variableType); + properties.variableType = type; + properties.name = ensureName(properties.name); + if (includeValue) { + const key = resolveValuePropertyKey(type); + const value = properties[key] ?? defaultValueForType(type); + properties[key] = value; + } +}; + +export const setLocalVariableNode = createNodeModule( + { + id: "set_local_var", + title: "Set Local", + category: "Logic", + description: "Declare or assign a local variable with an inline literal.", + searchTags: ["local", "set", "assign", "variable"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "value", + name: "Value", + direction: "input", + kind: "any", + description: "Optional override for the inline literal", + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + initializeProperties: (properties) => { + initializeLocalProperties(properties, { includeValue: true }); + }, + onPropertiesChanged: (properties) => { + normalizeLocalProperties(properties, { includeValue: true }); + }, + }, + { + emitExec: ({ + node, + indent, + indentLevel, + resolveValueInput, + sanitizeIdentifier, + emitNextExec, + formatLiteral, + }) => { + const type = sanitizeType(node.properties.variableType); + const name = sanitizeIdentifier(ensureName(node.properties.name)); + const valueKey = resolveValuePropertyKey(type); + const inlineValue = node.properties[valueKey] ?? defaultValueForType(type); + const fallbackLiteral = + type === "table" + ? String(inlineValue ?? "{}") || "{}" + : formatLiteral(type, inlineValue); + const valueExpression = resolveValueInput("value", fallbackLiteral); + const statement = `${indent(indentLevel)}local ${name} = ${valueExpression}`; + return [statement, ...emitNextExec("exec_out")]; + }, + } +); + +export const getLocalVariableNode = createNodeModule( + { + id: "get_local_var", + title: "Get Local", + category: "Logic", + description: "Access a local variable declared earlier in the flow.", + searchTags: ["local", "get", "variable", "read"], + inputs: [], + outputs: [ + { id: "value", name: "Value", direction: "output", kind: "any" }, + ], + properties: [], + initializeProperties: (properties) => { + initializeLocalProperties(properties, { includeValue: false }); + }, + onPropertiesChanged: (properties) => { + normalizeLocalProperties(properties, { includeValue: false }); + }, + }, + { + evaluateValue: ({ node, sanitizeIdentifier }) => { + return sanitizeIdentifier(ensureName(node.properties.name)); + }, + } +); + +export const localVariableNodes = [setLocalVariableNode, getLocalVariableNode]; diff --git a/scripts/nodes/library/lua/coroutineCreate.js b/scripts/nodes/library/lua/coroutineCreate.js new file mode 100644 index 0000000..ce24dc1 --- /dev/null +++ b/scripts/nodes/library/lua/coroutineCreate.js @@ -0,0 +1,38 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Creates a coroutine from a function using coroutine.create(). + */ +export const luaCoroutineCreateNode = createNodeModule( + { + id: "lua_coroutine_create", + title: "Coroutine Create", + category: "Lua", + description: "Wrap a function in a coroutine via coroutine.create().", + searchTags: ["coroutine", "create", "lua"], + inputs: [ + { + id: "fn", + name: "Function", + direction: "input", + kind: "any", + description: "Function to run inside the coroutine", + }, + ], + outputs: [ + { + id: "coroutine", + name: "Coroutine", + direction: "output", + kind: "any", + }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const fn = resolveValueInput("fn", "function() end"); + return `coroutine.create(${fn})`; + }, + } +); diff --git a/scripts/nodes/library/lua/coroutineResume.js b/scripts/nodes/library/lua/coroutineResume.js new file mode 100644 index 0000000..ab65a6a --- /dev/null +++ b/scripts/nodes/library/lua/coroutineResume.js @@ -0,0 +1,91 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Resumes a coroutine and exposes the success flag and first result. + */ +export const luaCoroutineResumeNode = createNodeModule( + { + id: "lua_coroutine_resume", + title: "Coroutine Resume", + category: "Lua", + description: + "Resume a coroutine and capture the success flag alongside the first returned value.", + searchTags: ["coroutine", "resume", "lua"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "coroutine", + name: "Coroutine", + direction: "input", + kind: "any", + }, + { + id: "arg1", + name: "Arg 1", + direction: "input", + kind: "any", + description: "Optional first value passed to coroutine", + }, + { + id: "arg2", + name: "Arg 2", + direction: "input", + kind: "any", + description: "Optional second value passed to coroutine", + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + { + id: "success", + name: "Success", + direction: "output", + kind: "boolean", + }, + { + id: "result", + name: "Result", + direction: "output", + kind: "any", + }, + ], + properties: [], + }, + { + emitExec: ({ + node, + indent, + indentLevel, + resolveValueInput, + sanitizeIdentifier, + emitNextExec, + }) => { + const OMIT = "__pg_omit__"; + const target = resolveValueInput("coroutine", "nil"); + const arg1 = resolveValueInput("arg1", OMIT); + const arg2 = resolveValueInput("arg2", OMIT); + const args = [target]; + if (arg1 !== OMIT) { + args.push(arg1); + } + if (arg2 !== OMIT) { + args.push(arg2); + } + const successName = sanitizeIdentifier(`__pg_${node.id}_co_success`); + const resultName = sanitizeIdentifier(`__pg_${node.id}_co_result`); + const line = `${indent(indentLevel)}local ${successName}, ${resultName} = coroutine.resume(${args.join(", ")})`; + return [line, ...emitNextExec("exec_out")]; + }, + evaluateValue: ({ node, pinId, sanitizeIdentifier }) => { + const successName = sanitizeIdentifier(`__pg_${node.id}_co_success`); + const resultName = sanitizeIdentifier(`__pg_${node.id}_co_result`); + if (pinId === "success") { + return successName; + } + if (pinId === "result") { + return resultName; + } + return "nil"; + }, + } +); diff --git a/scripts/nodes/library/lua/coroutineRunning.js b/scripts/nodes/library/lua/coroutineRunning.js new file mode 100644 index 0000000..081206f --- /dev/null +++ b/scripts/nodes/library/lua/coroutineRunning.js @@ -0,0 +1,27 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Returns the currently running coroutine via coroutine.running(). + */ +export const luaCoroutineRunningNode = createNodeModule( + { + id: "lua_coroutine_running", + title: "Coroutine Running", + category: "Lua", + description: "Access the currently running coroutine reference.", + searchTags: ["coroutine", "running", "lua"], + inputs: [], + outputs: [ + { + id: "coroutine", + name: "Coroutine", + direction: "output", + kind: "any", + }, + ], + properties: [], + }, + { + evaluateValue: () => "coroutine.running()", + } +); diff --git a/scripts/nodes/library/lua/coroutineStatus.js b/scripts/nodes/library/lua/coroutineStatus.js new file mode 100644 index 0000000..f759231 --- /dev/null +++ b/scripts/nodes/library/lua/coroutineStatus.js @@ -0,0 +1,32 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Reports a coroutine's status via coroutine.status(). + */ +export const luaCoroutineStatusNode = createNodeModule( + { + id: "lua_coroutine_status", + title: "Coroutine Status", + category: "Lua", + description: "Retrieve a coroutine's status string using coroutine.status().", + searchTags: ["coroutine", "status", "lua"], + inputs: [ + { + id: "coroutine", + name: "Coroutine", + direction: "input", + kind: "any", + }, + ], + outputs: [ + { id: "status", name: "Status", direction: "output", kind: "string" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const target = resolveValueInput("coroutine", "nil"); + return `coroutine.status(${target})`; + }, + } +); diff --git a/scripts/nodes/library/lua/coroutineYield.js b/scripts/nodes/library/lua/coroutineYield.js new file mode 100644 index 0000000..1f4c8ad --- /dev/null +++ b/scripts/nodes/library/lua/coroutineYield.js @@ -0,0 +1,53 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Yields from within a coroutine using coroutine.yield(). + */ +export const luaCoroutineYieldNode = createNodeModule( + { + id: "lua_coroutine_yield", + title: "Coroutine Yield", + category: "Lua", + description: "Yield execution from within a coroutine and optionally return values.", + searchTags: ["coroutine", "yield", "lua"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "value1", + name: "Value 1", + direction: "input", + kind: "any", + description: "Optional first value yielded to the caller", + }, + { + id: "value2", + name: "Value 2", + direction: "input", + kind: "any", + description: "Optional second value yielded to the caller", + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const OMIT = "__pg_omit__"; + const value1 = resolveValueInput("value1", OMIT); + const value2 = resolveValueInput("value2", OMIT); + const args = []; + if (value1 !== OMIT) { + args.push(value1); + } + if (value2 !== OMIT) { + args.push(value2); + } + const call = args.length + ? `${indent(indentLevel)}coroutine.yield(${args.join(", ")})` + : `${indent(indentLevel)}coroutine.yield()`; + return [call, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/lua/getMetatable.js b/scripts/nodes/library/lua/getMetatable.js new file mode 100644 index 0000000..984dce2 --- /dev/null +++ b/scripts/nodes/library/lua/getMetatable.js @@ -0,0 +1,27 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Retrieves the metatable associated with a table via getmetatable(). + */ +export const luaGetMetatableNode = createNodeModule( + { + id: "lua_getmetatable", + title: "Get Metatable", + category: "Lua", + description: "Fetch a table's metatable using getmetatable().", + searchTags: ["getmetatable", "metatable", "lua"], + inputs: [ + { id: "table", name: "Table", direction: "input", kind: "table" }, + ], + outputs: [ + { id: "metatable", name: "Metatable", direction: "output", kind: "table" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const tableValue = resolveValueInput("table", "{}"); + return `getmetatable(${tableValue})`; + }, + } +); diff --git a/scripts/nodes/library/lua/index.js b/scripts/nodes/library/lua/index.js new file mode 100644 index 0000000..2ec7ee6 --- /dev/null +++ b/scripts/nodes/library/lua/index.js @@ -0,0 +1,63 @@ +import { luaSetMetatableNode } from "./setMetatable.js"; +import { luaGetMetatableNode } from "./getMetatable.js"; +import { luaRawGetNode } from "./rawGet.js"; +import { luaRawSetNode } from "./rawSet.js"; +import { luaRawEqualNode } from "./rawEqual.js"; +import { luaCoroutineCreateNode } from "./coroutineCreate.js"; +import { luaCoroutineResumeNode } from "./coroutineResume.js"; +import { luaCoroutineYieldNode } from "./coroutineYield.js"; +import { luaCoroutineStatusNode } from "./coroutineStatus.js"; +import { luaCoroutineRunningNode } from "./coroutineRunning.js"; + +/** + * Node modules covering Lua metatable and coroutine helpers. + */ +export const luaNodes = [ + luaSetMetatableNode, + luaGetMetatableNode, + luaRawGetNode, + luaRawSetNode, + luaRawEqualNode, + luaCoroutineCreateNode, + luaCoroutineResumeNode, + luaCoroutineYieldNode, + luaCoroutineStatusNode, + luaCoroutineRunningNode, +]; + +/** + * Checklist tracking coverage of Lua metatable and coroutine helpers. + * @type {Array<{ function: string, nodeId: string, implemented: boolean }>} + */ +export const luaFunctionChecklist = [ + { function: "SETMETATABLE", nodeId: "lua_setmetatable", implemented: true }, + { function: "GETMETATABLE", nodeId: "lua_getmetatable", implemented: true }, + { function: "RAWGET", nodeId: "lua_rawget", implemented: true }, + { function: "RAWSET", nodeId: "lua_rawset", implemented: true }, + { function: "RAWEQUAL", nodeId: "lua_rawequal", implemented: true }, + { + function: "COROUTINE.CREATE", + nodeId: "lua_coroutine_create", + implemented: true, + }, + { + function: "COROUTINE.RESUME", + nodeId: "lua_coroutine_resume", + implemented: true, + }, + { + function: "COROUTINE.YIELD", + nodeId: "lua_coroutine_yield", + implemented: true, + }, + { + function: "COROUTINE.STATUS", + nodeId: "lua_coroutine_status", + implemented: true, + }, + { + function: "COROUTINE.RUNNING", + nodeId: "lua_coroutine_running", + implemented: true, + }, +]; diff --git a/scripts/nodes/library/lua/rawEqual.js b/scripts/nodes/library/lua/rawEqual.js new file mode 100644 index 0000000..4f0913f --- /dev/null +++ b/scripts/nodes/library/lua/rawEqual.js @@ -0,0 +1,29 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Compares two values using rawequal(). + */ +export const luaRawEqualNode = createNodeModule( + { + id: "lua_rawequal", + title: "Raw Equal", + category: "Lua", + description: "Compare values without metamethods using rawequal().", + searchTags: ["rawequal", "compare", "lua"], + inputs: [ + { id: "a", name: "A", direction: "input", kind: "any" }, + { id: "b", name: "B", direction: "input", kind: "any" }, + ], + outputs: [ + { id: "equal", name: "Equal", direction: "output", kind: "boolean" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const a = resolveValueInput("a", "nil"); + const b = resolveValueInput("b", "nil"); + return `rawequal(${a}, ${b})`; + }, + } +); diff --git a/scripts/nodes/library/lua/rawGet.js b/scripts/nodes/library/lua/rawGet.js new file mode 100644 index 0000000..044a6bb --- /dev/null +++ b/scripts/nodes/library/lua/rawGet.js @@ -0,0 +1,29 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Retrieves a raw table entry using rawget(). + */ +export const luaRawGetNode = createNodeModule( + { + id: "lua_rawget", + title: "Raw Get", + category: "Lua", + description: "Access a table entry without metamethods using rawget().", + searchTags: ["rawget", "table", "lua"], + inputs: [ + { id: "table", name: "Table", direction: "input", kind: "table" }, + { id: "key", name: "Key", direction: "input", kind: "any" }, + ], + outputs: [ + { id: "value", name: "Value", direction: "output", kind: "any" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const tableValue = resolveValueInput("table", "{}"); + const key = resolveValueInput("key", "nil"); + return `rawget(${tableValue}, ${key})`; + }, + } +); diff --git a/scripts/nodes/library/lua/rawSet.js b/scripts/nodes/library/lua/rawSet.js new file mode 100644 index 0000000..8de2f58 --- /dev/null +++ b/scripts/nodes/library/lua/rawSet.js @@ -0,0 +1,33 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Writes a raw table entry using rawset(). + */ +export const luaRawSetNode = createNodeModule( + { + id: "lua_rawset", + title: "Raw Set", + category: "Lua", + description: "Assign a table entry without metamethods using rawset().", + searchTags: ["rawset", "table", "lua"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { id: "table", name: "Table", direction: "input", kind: "table" }, + { id: "key", name: "Key", direction: "input", kind: "any" }, + { id: "value", name: "Value", direction: "input", kind: "any" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const tableValue = resolveValueInput("table", "{}"); + const key = resolveValueInput("key", "nil"); + const value = resolveValueInput("value", "nil"); + const line = `${indent(indentLevel)}rawset(${tableValue}, ${key}, ${value})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/lua/setMetatable.js b/scripts/nodes/library/lua/setMetatable.js new file mode 100644 index 0000000..cab19b7 --- /dev/null +++ b/scripts/nodes/library/lua/setMetatable.js @@ -0,0 +1,35 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Applies a metatable to a table via setmetatable(). + */ +export const luaSetMetatableNode = createNodeModule( + { + id: "lua_setmetatable", + title: "Set Metatable", + category: "Lua", + description: "Assign a metatable to a table using setmetatable().", + searchTags: ["setmetatable", "metatable", "lua"], + inputs: [ + { id: "table", name: "Table", direction: "input", kind: "table" }, + { + id: "metatable", + name: "Metatable", + direction: "input", + kind: "table", + defaultValue: "nil", + }, + ], + outputs: [ + { id: "table_out", name: "Table", direction: "output", kind: "table" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const tableValue = resolveValueInput("table", "{}"); + const metatable = resolveValueInput("metatable", "nil"); + return `setmetatable(${tableValue}, ${metatable})`; + }, + } +); diff --git a/scripts/nodes/library/map/index.js b/scripts/nodes/library/map/index.js new file mode 100644 index 0000000..5e217fc --- /dev/null +++ b/scripts/nodes/library/map/index.js @@ -0,0 +1,20 @@ +import { mapMgetNode } from "./mget.js"; +import { mapMsetNode } from "./mset.js"; +import { mapDrawNode } from "./map.js"; +import { mapTlineNode } from "./tline.js"; + +/** + * All map-related node modules backed by PICO-8 helpers. + */ +export const mapNodes = [mapMgetNode, mapMsetNode, mapDrawNode, mapTlineNode]; + +/** + * Checklist tracking coverage of documented map helpers. + * @type {Array<{ function: string, nodeId: string, implemented: boolean }>} + */ +export const mapFunctionChecklist = [ + { function: "MGET", nodeId: "map_mget", implemented: true }, + { function: "MSET", nodeId: "map_mset", implemented: true }, + { function: "MAP", nodeId: "map_map", implemented: true }, + { function: "TLINE", nodeId: "map_tline", implemented: true }, +]; diff --git a/scripts/nodes/library/map/map.js b/scripts/nodes/library/map/map.js new file mode 100644 index 0000000..6eb2bdc --- /dev/null +++ b/scripts/nodes/library/map/map.js @@ -0,0 +1,108 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Draws map tiles using PICO-8's map helper. + */ +export const mapDrawNode = createNodeModule( + { + id: "map_map", + title: "Draw Map", + category: "Map", + description: + "Render a section of the map to the screen with optional layers.", + searchTags: ["map", "draw", "tiles", "layer"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "tile_x", + name: "Tile X", + direction: "input", + kind: "number", + description: "Starting tile X coordinate", + }, + { + id: "tile_y", + name: "Tile Y", + direction: "input", + kind: "number", + description: "Starting tile Y coordinate", + }, + { + id: "screen_x", + name: "Screen X", + direction: "input", + kind: "number", + description: "Screen X position in pixels", + }, + { + id: "screen_y", + name: "Screen Y", + direction: "input", + kind: "number", + description: "Screen Y position in pixels", + }, + { + id: "tile_w", + name: "Tile Width", + direction: "input", + kind: "number", + description: "Number of tiles across", + }, + { + id: "tile_h", + name: "Tile Height", + direction: "input", + kind: "number", + description: "Number of tiles down", + }, + { + id: "layers", + name: "Layers", + direction: "input", + kind: "number", + description: "Sprite flag bitmask", + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const OMIT = "__pg_omit__"; + const tileX = resolveValueInput("tile_x", OMIT); + const tileY = resolveValueInput("tile_y", OMIT); + const screenX = resolveValueInput("screen_x", OMIT); + const screenY = resolveValueInput("screen_y", OMIT); + const tileW = resolveValueInput("tile_w", OMIT); + const tileH = resolveValueInput("tile_h", OMIT); + const layers = resolveValueInput("layers", OMIT); + + const values = [ + { value: tileX, placeholder: "0" }, + { value: tileY, placeholder: "0" }, + { value: screenX, placeholder: "0" }, + { value: screenY, placeholder: "0" }, + { value: tileW, placeholder: "nil" }, + { value: tileH, placeholder: "nil" }, + { value: layers, placeholder: "nil" }, + ]; + + let lastIndex = values.length - 1; + while (lastIndex >= 0 && values[lastIndex].value === OMIT) { + lastIndex -= 1; + } + + const args = []; + for (let index = 0; index <= lastIndex; index += 1) { + const entry = values[index]; + args.push(entry.value === OMIT ? entry.placeholder : entry.value); + } + + const call = args.length ? `map(${args.join(", ")})` : "map()"; + const line = `${indent(indentLevel)}${call}`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/map/mget.js b/scripts/nodes/library/map/mget.js new file mode 100644 index 0000000..d1d0bb5 --- /dev/null +++ b/scripts/nodes/library/map/mget.js @@ -0,0 +1,41 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Reads a map tile value using PICO-8's mget helper. + */ +export const mapMgetNode = createNodeModule( + { + id: "map_mget", + title: "Get Map Tile", + category: "Map", + description: "Fetch the numeric map value at a tile coordinate.", + searchTags: ["mget", "map", "tile", "read"], + inputs: [ + { + id: "x", + name: "Tile X", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "y", + name: "Tile Y", + direction: "input", + kind: "number", + defaultValue: 0, + }, + ], + outputs: [ + { id: "value", name: "Value", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const x = resolveValueInput("x", "0"); + const y = resolveValueInput("y", "0"); + return `mget(${x}, ${y})`; + }, + } +); diff --git a/scripts/nodes/library/map/mset.js b/scripts/nodes/library/map/mset.js new file mode 100644 index 0000000..b39c246 --- /dev/null +++ b/scripts/nodes/library/map/mset.js @@ -0,0 +1,51 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Writes a map tile value using PICO-8's mset helper. + */ +export const mapMsetNode = createNodeModule( + { + id: "map_mset", + title: "Set Map Tile", + category: "Map", + description: "Store a numeric value at a tile coordinate.", + searchTags: ["mset", "map", "tile", "write"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "x", + name: "Tile X", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "y", + name: "Tile Y", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "value", + name: "Value", + direction: "input", + kind: "number", + defaultValue: 0, + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const x = resolveValueInput("x", "0"); + const y = resolveValueInput("y", "0"); + const value = resolveValueInput("value", "0"); + const line = `${indent(indentLevel)}mset(${x}, ${y}, ${value})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/map/tline.js b/scripts/nodes/library/map/tline.js new file mode 100644 index 0000000..a34e208 --- /dev/null +++ b/scripts/nodes/library/map/tline.js @@ -0,0 +1,157 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Draws textured lines or configures precision using PICO-8's tline helper. + */ +export const mapTlineNode = createNodeModule( + { + id: "map_tline", + title: "Draw Textured Line", + category: "Map", + description: + "Render a textured line from map data or adjust tline precision.", + searchTags: ["tline", "map", "texture", "line"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "x0", + name: "X0", + direction: "input", + kind: "number", + description: "Line start X", + }, + { + id: "y0", + name: "Y0", + direction: "input", + kind: "number", + description: "Line start Y", + }, + { + id: "x1", + name: "X1", + direction: "input", + kind: "number", + description: "Line end X", + }, + { + id: "y1", + name: "Y1", + direction: "input", + kind: "number", + description: "Line end Y", + }, + { + id: "mx", + name: "Map X", + direction: "input", + kind: "number", + description: "Map sample X (tiles)", + }, + { + id: "my", + name: "Map Y", + direction: "input", + kind: "number", + description: "Map sample Y (tiles)", + }, + { + id: "mdx", + name: "MDX", + direction: "input", + kind: "number", + description: "Map X delta", + }, + { + id: "mdy", + name: "MDY", + direction: "input", + kind: "number", + description: "Map Y delta", + }, + { + id: "layers", + name: "Layers", + direction: "input", + kind: "number", + description: "Sprite flag bitmask", + }, + { + id: "precision", + name: "Precision", + direction: "input", + kind: "number", + description: "Precision override when setting mode", + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [ + { + key: "mode", + label: "Mode", + type: "enum", + defaultValue: "draw", + options: [ + { label: "Draw Line", value: "draw" }, + { label: "Set Precision", value: "precision" }, + ], + }, + ], + }, + { + emitExec: ({ + node, + indent, + indentLevel, + resolveValueInput, + emitNextExec, + }) => { + const mode = String(node.properties.mode ?? "draw"); + if (mode === "precision") { + const precision = resolveValueInput("precision", "16"); + const line = `${indent(indentLevel)}tline(${precision})`; + return [line, ...emitNextExec("exec_out")]; + } + + const OMIT = "__pg_omit__"; + const x0 = resolveValueInput("x0", OMIT); + const y0 = resolveValueInput("y0", OMIT); + const x1 = resolveValueInput("x1", OMIT); + const y1 = resolveValueInput("y1", OMIT); + const mx = resolveValueInput("mx", OMIT); + const my = resolveValueInput("my", OMIT); + const mdx = resolveValueInput("mdx", OMIT); + const mdy = resolveValueInput("mdy", OMIT); + const layers = resolveValueInput("layers", OMIT); + + const values = [ + { value: x0, placeholder: "0" }, + { value: y0, placeholder: "0" }, + { value: x1, placeholder: "0" }, + { value: y1, placeholder: "0" }, + { value: mx, placeholder: "0" }, + { value: my, placeholder: "0" }, + { value: mdx, placeholder: "0.125" }, + { value: mdy, placeholder: "0" }, + { value: layers, placeholder: "nil" }, + ]; + + let lastIndex = values.length - 1; + while (lastIndex >= 0 && values[lastIndex].value === OMIT) { + lastIndex -= 1; + } + + const args = []; + for (let index = 0; index <= lastIndex; index += 1) { + const entry = values[index]; + args.push(entry.value === OMIT ? entry.placeholder : entry.value); + } + + const call = args.length ? `tline(${args.join(", ")})` : "tline()"; + const line = `${indent(indentLevel)}${call}`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/math/abs.js b/scripts/nodes/library/math/abs.js new file mode 100644 index 0000000..876af07 --- /dev/null +++ b/scripts/nodes/library/math/abs.js @@ -0,0 +1,25 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Computes the absolute value using abs. + */ +export const mathAbsNode = createNodeModule( + { + id: "math_abs", + title: "Abs", + category: "Math", + description: "Return the absolute value of a number.", + searchTags: ["abs", "absolute", "math"], + inputs: [{ id: "value", name: "Value", direction: "input", kind: "number" }], + outputs: [ + { id: "result", name: "Result", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const value = resolveValueInput("value", "0"); + return `abs(${value})`; + }, + } +); diff --git a/scripts/nodes/library/math/atan2.js b/scripts/nodes/library/math/atan2.js new file mode 100644 index 0000000..2035ccf --- /dev/null +++ b/scripts/nodes/library/math/atan2.js @@ -0,0 +1,30 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Computes an angle from delta components using atan2. + */ +export const mathAtan2Node = createNodeModule( + { + id: "math_atan2", + title: "Atan2", + category: "Math", + description: + "Return the screenspace angle for a delta X and Y pair.", + searchTags: ["atan2", "angle", "math", "trig"], + inputs: [ + { id: "dx", name: "DX", direction: "input", kind: "number" }, + { id: "dy", name: "DY", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "result", name: "Result", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const dx = resolveValueInput("dx", "0"); + const dy = resolveValueInput("dy", "0"); + return `atan2(${dx}, ${dy})`; + }, + } +); diff --git a/scripts/nodes/library/math/band.js b/scripts/nodes/library/math/band.js new file mode 100644 index 0000000..c106f27 --- /dev/null +++ b/scripts/nodes/library/math/band.js @@ -0,0 +1,29 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Performs bitwise and using band. + */ +export const mathBandNode = createNodeModule( + { + id: "math_band", + title: "Bitwise AND", + category: "Math", + description: "Return bits set in both operands using BAND().", + searchTags: ["band", "bitwise", "and", "math"], + inputs: [ + { id: "a", name: "A", direction: "input", kind: "number" }, + { id: "b", name: "B", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "result", name: "Result", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const a = resolveValueInput("a", "0"); + const b = resolveValueInput("b", "0"); + return `band(${a}, ${b})`; + }, + } +); diff --git a/scripts/nodes/library/math/bnot.js b/scripts/nodes/library/math/bnot.js new file mode 100644 index 0000000..47c8d40 --- /dev/null +++ b/scripts/nodes/library/math/bnot.js @@ -0,0 +1,25 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Performs bitwise not using bnot. + */ +export const mathBnotNode = createNodeModule( + { + id: "math_bnot", + title: "Bitwise NOT", + category: "Math", + description: "Invert every bit in a value using BNOT().", + searchTags: ["bnot", "bitwise", "not", "math"], + inputs: [{ id: "value", name: "Value", direction: "input", kind: "number" }], + outputs: [ + { id: "result", name: "Result", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const value = resolveValueInput("value", "0"); + return `bnot(${value})`; + }, + } +); diff --git a/scripts/nodes/library/math/bor.js b/scripts/nodes/library/math/bor.js new file mode 100644 index 0000000..ab94e3e --- /dev/null +++ b/scripts/nodes/library/math/bor.js @@ -0,0 +1,29 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Performs bitwise or using bor. + */ +export const mathBorNode = createNodeModule( + { + id: "math_bor", + title: "Bitwise OR", + category: "Math", + description: "Return bits set in either operand using BOR().", + searchTags: ["bor", "bitwise", "or", "math"], + inputs: [ + { id: "a", name: "A", direction: "input", kind: "number" }, + { id: "b", name: "B", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "result", name: "Result", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const a = resolveValueInput("a", "0"); + const b = resolveValueInput("b", "0"); + return `bor(${a}, ${b})`; + }, + } +); diff --git a/scripts/nodes/library/math/bxor.js b/scripts/nodes/library/math/bxor.js new file mode 100644 index 0000000..183f428 --- /dev/null +++ b/scripts/nodes/library/math/bxor.js @@ -0,0 +1,29 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Performs bitwise exclusive-or using bxor. + */ +export const mathBxorNode = createNodeModule( + { + id: "math_bxor", + title: "Bitwise XOR", + category: "Math", + description: "Return bits set in exactly one operand using BXOR().", + searchTags: ["bxor", "bitwise", "xor", "math"], + inputs: [ + { id: "a", name: "A", direction: "input", kind: "number" }, + { id: "b", name: "B", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "result", name: "Result", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const a = resolveValueInput("a", "0"); + const b = resolveValueInput("b", "0"); + return `bxor(${a}, ${b})`; + }, + } +); diff --git a/scripts/nodes/library/math/ceil.js b/scripts/nodes/library/math/ceil.js new file mode 100644 index 0000000..41f1c19 --- /dev/null +++ b/scripts/nodes/library/math/ceil.js @@ -0,0 +1,25 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Ceils a numeric value using ceil. + */ +export const mathCeilNode = createNodeModule( + { + id: "math_ceil", + title: "Ceil", + category: "Math", + description: "Round a value up to the nearest integer.", + searchTags: ["ceil", "ceiling", "math", "round"], + inputs: [{ id: "value", name: "Value", direction: "input", kind: "number" }], + outputs: [ + { id: "result", name: "Result", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const value = resolveValueInput("value", "0"); + return `ceil(${value})`; + }, + } +); diff --git a/scripts/nodes/library/math/cos.js b/scripts/nodes/library/math/cos.js new file mode 100644 index 0000000..5f85034 --- /dev/null +++ b/scripts/nodes/library/math/cos.js @@ -0,0 +1,26 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Computes the cosine of a value using cos. + */ +export const mathCosNode = createNodeModule( + { + id: "math_cos", + title: "Cos", + category: "Math", + description: + "Return the cosine of an angle where 1.0 represents a full turn.", + searchTags: ["cos", "cosine", "math", "trig"], + inputs: [{ id: "value", name: "Value", direction: "input", kind: "number" }], + outputs: [ + { id: "result", name: "Result", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const value = resolveValueInput("value", "0"); + return `cos(${value})`; + }, + } +); diff --git a/scripts/nodes/library/math/div.js b/scripts/nodes/library/math/div.js new file mode 100644 index 0000000..5e03348 --- /dev/null +++ b/scripts/nodes/library/math/div.js @@ -0,0 +1,29 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Performs integer division using the floor operation. + */ +export const mathDivNode = createNodeModule( + { + id: "math_div", + title: "Integer Divide", + category: "Math", + description: "Return the floored quotient of two numbers (A \\ B).", + searchTags: ["div", "integer", "divide", "math"], + inputs: [ + { id: "a", name: "A", direction: "input", kind: "number" }, + { id: "b", name: "B", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "result", name: "Result", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const a = resolveValueInput("a", "0"); + const b = resolveValueInput("b", "1"); + return `(${a}) \\ (${b})`; + }, + } +); diff --git a/scripts/nodes/library/math/flr.js b/scripts/nodes/library/math/flr.js new file mode 100644 index 0000000..72a1c51 --- /dev/null +++ b/scripts/nodes/library/math/flr.js @@ -0,0 +1,25 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Floors a numeric value using flr. + */ +export const mathFlrNode = createNodeModule( + { + id: "math_flr", + title: "Floor", + category: "Math", + description: "Round a value down to the nearest integer.", + searchTags: ["flr", "floor", "math", "round"], + inputs: [{ id: "value", name: "Value", direction: "input", kind: "number" }], + outputs: [ + { id: "result", name: "Result", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const value = resolveValueInput("value", "0"); + return `flr(${value})`; + }, + } +); diff --git a/scripts/nodes/library/math/index.js b/scripts/nodes/library/math/index.js new file mode 100644 index 0000000..c00af62 --- /dev/null +++ b/scripts/nodes/library/math/index.js @@ -0,0 +1,79 @@ +import { mathMaxNode } from "./max.js"; +import { mathMinNode } from "./min.js"; +import { mathMidNode } from "./mid.js"; +import { mathFlrNode } from "./flr.js"; +import { mathCeilNode } from "./ceil.js"; +import { mathCosNode } from "./cos.js"; +import { mathSinNode } from "./sin.js"; +import { mathAtan2Node } from "./atan2.js"; +import { mathSqrtNode } from "./sqrt.js"; +import { mathAbsNode } from "./abs.js"; +import { mathRndNode } from "./rnd.js"; +import { mathSrandNode } from "./srand.js"; +import { mathBandNode } from "./band.js"; +import { mathBorNode } from "./bor.js"; +import { mathBxorNode } from "./bxor.js"; +import { mathBnotNode } from "./bnot.js"; +import { mathShlNode } from "./shl.js"; +import { mathShrNode } from "./shr.js"; +import { mathLshrNode } from "./lshr.js"; +import { mathRotlNode } from "./rotl.js"; +import { mathRotrNode } from "./rotr.js"; +import { mathDivNode } from "./div.js"; + +/** + * All math-related node modules backed by PICO-8 helpers. + */ +export const mathNodes = [ + mathMaxNode, + mathMinNode, + mathMidNode, + mathFlrNode, + mathCeilNode, + mathCosNode, + mathSinNode, + mathAtan2Node, + mathSqrtNode, + mathAbsNode, + mathRndNode, + mathSrandNode, + mathBandNode, + mathBorNode, + mathBxorNode, + mathBnotNode, + mathShlNode, + mathShrNode, + mathLshrNode, + mathRotlNode, + mathRotrNode, + mathDivNode, +]; + +/** + * Checklist tracking coverage of documented math helpers. + * @type {Array<{ function: string, nodeId: string, implemented: boolean }>} + */ +export const mathFunctionChecklist = [ + { function: "MAX", nodeId: "math_max", implemented: true }, + { function: "MIN", nodeId: "math_min", implemented: true }, + { function: "MID", nodeId: "math_mid", implemented: true }, + { function: "FLR", nodeId: "math_flr", implemented: true }, + { function: "CEIL", nodeId: "math_ceil", implemented: true }, + { function: "COS", nodeId: "math_cos", implemented: true }, + { function: "SIN", nodeId: "math_sin", implemented: true }, + { function: "ATAN2", nodeId: "math_atan2", implemented: true }, + { function: "SQRT", nodeId: "math_sqrt", implemented: true }, + { function: "ABS", nodeId: "math_abs", implemented: true }, + { function: "RND", nodeId: "math_rnd", implemented: true }, + { function: "SRAND", nodeId: "math_srand", implemented: true }, + { function: "BAND", nodeId: "math_band", implemented: true }, + { function: "BOR", nodeId: "math_bor", implemented: true }, + { function: "BXOR", nodeId: "math_bxor", implemented: true }, + { function: "BNOT", nodeId: "math_bnot", implemented: true }, + { function: "SHL", nodeId: "math_shl", implemented: true }, + { function: "SHR", nodeId: "math_shr", implemented: true }, + { function: "LSHR", nodeId: "math_lshr", implemented: true }, + { function: "ROTL", nodeId: "math_rotl", implemented: true }, + { function: "ROTR", nodeId: "math_rotr", implemented: true }, + { function: "\\", nodeId: "math_div", implemented: true }, +]; diff --git a/scripts/nodes/library/math/lshr.js b/scripts/nodes/library/math/lshr.js new file mode 100644 index 0000000..598145f --- /dev/null +++ b/scripts/nodes/library/math/lshr.js @@ -0,0 +1,29 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Performs a logical right bit shift using lshr. + */ +export const mathLshrNode = createNodeModule( + { + id: "math_lshr", + title: "Logical Shift Right", + category: "Math", + description: "Shift bits right with zeros entering from the left.", + searchTags: ["lshr", "shift", "bitwise", "math"], + inputs: [ + { id: "value", name: "Value", direction: "input", kind: "number" }, + { id: "amount", name: "Amount", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "result", name: "Result", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const value = resolveValueInput("value", "0"); + const amount = resolveValueInput("amount", "0"); + return `lshr(${value}, ${amount})`; + }, + } +); diff --git a/scripts/nodes/library/math/max.js b/scripts/nodes/library/math/max.js new file mode 100644 index 0000000..0c389fb --- /dev/null +++ b/scripts/nodes/library/math/max.js @@ -0,0 +1,29 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Returns the maximum of two values using max. + */ +export const mathMaxNode = createNodeModule( + { + id: "math_max", + title: "Max", + category: "Math", + description: "Return the greater of two numeric values.", + searchTags: ["max", "maximum", "math", "compare"], + inputs: [ + { id: "a", name: "A", direction: "input", kind: "number" }, + { id: "b", name: "B", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "value", name: "Result", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const a = resolveValueInput("a", "0"); + const b = resolveValueInput("b", "0"); + return `max(${a}, ${b})`; + }, + } +); diff --git a/scripts/nodes/library/math/mid.js b/scripts/nodes/library/math/mid.js new file mode 100644 index 0000000..30de5f2 --- /dev/null +++ b/scripts/nodes/library/math/mid.js @@ -0,0 +1,31 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Returns the median of three values using mid. + */ +export const mathMidNode = createNodeModule( + { + id: "math_mid", + title: "Mid", + category: "Math", + description: "Return the middle value from three numeric inputs.", + searchTags: ["mid", "median", "math", "compare"], + inputs: [ + { id: "a", name: "A", direction: "input", kind: "number" }, + { id: "b", name: "B", direction: "input", kind: "number" }, + { id: "c", name: "C", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "value", name: "Result", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const a = resolveValueInput("a", "0"); + const b = resolveValueInput("b", "0"); + const c = resolveValueInput("c", "0"); + return `mid(${a}, ${b}, ${c})`; + }, + } +); diff --git a/scripts/nodes/library/math/min.js b/scripts/nodes/library/math/min.js new file mode 100644 index 0000000..da8612c --- /dev/null +++ b/scripts/nodes/library/math/min.js @@ -0,0 +1,29 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Returns the minimum of two values using min. + */ +export const mathMinNode = createNodeModule( + { + id: "math_min", + title: "Min", + category: "Math", + description: "Return the lesser of two numeric values.", + searchTags: ["min", "minimum", "math", "compare"], + inputs: [ + { id: "a", name: "A", direction: "input", kind: "number" }, + { id: "b", name: "B", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "value", name: "Result", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const a = resolveValueInput("a", "0"); + const b = resolveValueInput("b", "0"); + return `min(${a}, ${b})`; + }, + } +); diff --git a/scripts/nodes/library/math/rnd.js b/scripts/nodes/library/math/rnd.js new file mode 100644 index 0000000..ea81e2c --- /dev/null +++ b/scripts/nodes/library/math/rnd.js @@ -0,0 +1,25 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Generates random numbers using rnd. + */ +export const mathRndNode = createNodeModule( + { + id: "math_rnd", + title: "Random", + category: "Math", + description: "Return a random number in the range [0, x).", + searchTags: ["rnd", "random", "math"], + inputs: [{ id: "value", name: "Range", direction: "input", kind: "number" }], + outputs: [ + { id: "result", name: "Result", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const value = resolveValueInput("value", "1"); + return `rnd(${value})`; + }, + } +); diff --git a/scripts/nodes/library/math/rotl.js b/scripts/nodes/library/math/rotl.js new file mode 100644 index 0000000..f093368 --- /dev/null +++ b/scripts/nodes/library/math/rotl.js @@ -0,0 +1,29 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Performs a left bit rotation using rotl. + */ +export const mathRotlNode = createNodeModule( + { + id: "math_rotl", + title: "Rotate Left", + category: "Math", + description: "Rotate bits left by a given amount.", + searchTags: ["rotl", "rotate", "bitwise", "math"], + inputs: [ + { id: "value", name: "Value", direction: "input", kind: "number" }, + { id: "amount", name: "Amount", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "result", name: "Result", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const value = resolveValueInput("value", "0"); + const amount = resolveValueInput("amount", "0"); + return `rotl(${value}, ${amount})`; + }, + } +); diff --git a/scripts/nodes/library/math/rotr.js b/scripts/nodes/library/math/rotr.js new file mode 100644 index 0000000..404827f --- /dev/null +++ b/scripts/nodes/library/math/rotr.js @@ -0,0 +1,29 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Performs a right bit rotation using rotr. + */ +export const mathRotrNode = createNodeModule( + { + id: "math_rotr", + title: "Rotate Right", + category: "Math", + description: "Rotate bits right by a given amount.", + searchTags: ["rotr", "rotate", "bitwise", "math"], + inputs: [ + { id: "value", name: "Value", direction: "input", kind: "number" }, + { id: "amount", name: "Amount", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "result", name: "Result", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const value = resolveValueInput("value", "0"); + const amount = resolveValueInput("amount", "0"); + return `rotr(${value}, ${amount})`; + }, + } +); diff --git a/scripts/nodes/library/math/shl.js b/scripts/nodes/library/math/shl.js new file mode 100644 index 0000000..eb80968 --- /dev/null +++ b/scripts/nodes/library/math/shl.js @@ -0,0 +1,29 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Performs a left bit shift using shl. + */ +export const mathShlNode = createNodeModule( + { + id: "math_shl", + title: "Shift Left", + category: "Math", + description: "Shift bits left with zeros entering from the right.", + searchTags: ["shl", "shift", "bitwise", "math"], + inputs: [ + { id: "value", name: "Value", direction: "input", kind: "number" }, + { id: "amount", name: "Amount", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "result", name: "Result", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const value = resolveValueInput("value", "0"); + const amount = resolveValueInput("amount", "0"); + return `shl(${value}, ${amount})`; + }, + } +); diff --git a/scripts/nodes/library/math/shr.js b/scripts/nodes/library/math/shr.js new file mode 100644 index 0000000..eea74e1 --- /dev/null +++ b/scripts/nodes/library/math/shr.js @@ -0,0 +1,29 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Performs an arithmetic right bit shift using shr. + */ +export const mathShrNode = createNodeModule( + { + id: "math_shr", + title: "Shift Right", + category: "Math", + description: "Shift bits right while extending the sign bit.", + searchTags: ["shr", "shift", "bitwise", "math"], + inputs: [ + { id: "value", name: "Value", direction: "input", kind: "number" }, + { id: "amount", name: "Amount", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "result", name: "Result", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const value = resolveValueInput("value", "0"); + const amount = resolveValueInput("amount", "0"); + return `shr(${value}, ${amount})`; + }, + } +); diff --git a/scripts/nodes/library/math/sin.js b/scripts/nodes/library/math/sin.js new file mode 100644 index 0000000..a35ddf8 --- /dev/null +++ b/scripts/nodes/library/math/sin.js @@ -0,0 +1,26 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Computes the sine of a value using sin. + */ +export const mathSinNode = createNodeModule( + { + id: "math_sin", + title: "Sin", + category: "Math", + description: + "Return the sine of an angle where 1.0 represents a full turn.", + searchTags: ["sin", "sine", "math", "trig"], + inputs: [{ id: "value", name: "Value", direction: "input", kind: "number" }], + outputs: [ + { id: "result", name: "Result", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const value = resolveValueInput("value", "0"); + return `sin(${value})`; + }, + } +); diff --git a/scripts/nodes/library/math/sqrt.js b/scripts/nodes/library/math/sqrt.js new file mode 100644 index 0000000..2df603d --- /dev/null +++ b/scripts/nodes/library/math/sqrt.js @@ -0,0 +1,25 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Computes a square root using sqrt. + */ +export const mathSqrtNode = createNodeModule( + { + id: "math_sqrt", + title: "Sqrt", + category: "Math", + description: "Return the square root of a numeric value.", + searchTags: ["sqrt", "square root", "math"], + inputs: [{ id: "value", name: "Value", direction: "input", kind: "number" }], + outputs: [ + { id: "result", name: "Result", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const value = resolveValueInput("value", "0"); + return `sqrt(${value})`; + }, + } +); diff --git a/scripts/nodes/library/math/srand.js b/scripts/nodes/library/math/srand.js new file mode 100644 index 0000000..fdaf190 --- /dev/null +++ b/scripts/nodes/library/math/srand.js @@ -0,0 +1,29 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Seeds the random number generator using srand. + */ +export const mathSrandNode = createNodeModule( + { + id: "math_srand", + title: "Seed Random", + category: "Math", + description: "Set the random number generator seed.", + searchTags: ["srand", "random", "seed", "math"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { id: "value", name: "Seed", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const value = resolveValueInput("value", "0"); + const line = `${indent(indentLevel)}srand(${value})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/memory/cstore.js b/scripts/nodes/library/memory/cstore.js new file mode 100644 index 0000000..4798b6a --- /dev/null +++ b/scripts/nodes/library/memory/cstore.js @@ -0,0 +1,67 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Copies data from base RAM to cartridge ROM using cstore. + */ +export const memoryCstoreNode = createNodeModule( + { + id: "memory_cstore", + title: "Store To Cart", + category: "Memory", + description: + "Copy base RAM into cart ROM, optionally targeting another cartridge.", + searchTags: ["cstore", "memory", "cart", "save"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "dest", + name: "Dest", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "source", + name: "Source", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "length", + name: "Length", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "filename", + name: "Filename", + direction: "input", + kind: "string", + description: "Optional cartridge filename", + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const OMIT = "__pg_omit__"; + const dest = resolveValueInput("dest", "0"); + const source = resolveValueInput("source", "0"); + const length = resolveValueInput("length", "0"); + const filename = resolveValueInput("filename", OMIT); + + const args = [dest, source, length]; + if (filename !== OMIT) { + args.push(filename); + } + + const line = `${indent(indentLevel)}cstore(${args.join(", ")})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/memory/index.js b/scripts/nodes/library/memory/index.js new file mode 100644 index 0000000..0cab7e0 --- /dev/null +++ b/scripts/nodes/library/memory/index.js @@ -0,0 +1,43 @@ +import { memoryPeekNode } from "./peek.js"; +import { memoryPeek2Node } from "./peek2.js"; +import { memoryPeek4Node } from "./peek4.js"; +import { memoryPokeNode } from "./poke.js"; +import { memoryPoke2Node } from "./poke2.js"; +import { memoryPoke4Node } from "./poke4.js"; +import { memoryMemcpyNode } from "./memcpy.js"; +import { memoryMemsetNode } from "./memset.js"; +import { memoryReloadNode } from "./reload.js"; +import { memoryCstoreNode } from "./cstore.js"; + +/** + * All memory-related node modules backed by PICO-8 helpers. + */ +export const memoryNodes = [ + memoryPeekNode, + memoryPeek2Node, + memoryPeek4Node, + memoryPokeNode, + memoryPoke2Node, + memoryPoke4Node, + memoryMemcpyNode, + memoryMemsetNode, + memoryReloadNode, + memoryCstoreNode, +]; + +/** + * Checklist tracking coverage of documented memory helpers. + * @type {Array<{ function: string, nodeId: string, implemented: boolean }>} + */ +export const memoryFunctionChecklist = [ + { function: "PEEK", nodeId: "memory_peek", implemented: true }, + { function: "PEEK2", nodeId: "memory_peek2", implemented: true }, + { function: "PEEK4", nodeId: "memory_peek4", implemented: true }, + { function: "POKE", nodeId: "memory_poke", implemented: true }, + { function: "POKE2", nodeId: "memory_poke2", implemented: true }, + { function: "POKE4", nodeId: "memory_poke4", implemented: true }, + { function: "MEMCPY", nodeId: "memory_memcpy", implemented: true }, + { function: "MEMSET", nodeId: "memory_memset", implemented: true }, + { function: "RELOAD", nodeId: "memory_reload", implemented: true }, + { function: "CSTORE", nodeId: "memory_cstore", implemented: true }, +]; diff --git a/scripts/nodes/library/memory/memcpy.js b/scripts/nodes/library/memory/memcpy.js new file mode 100644 index 0000000..fc635c0 --- /dev/null +++ b/scripts/nodes/library/memory/memcpy.js @@ -0,0 +1,51 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Copies memory regions using memcpy. + */ +export const memoryMemcpyNode = createNodeModule( + { + id: "memory_memcpy", + title: "Copy Memory", + category: "Memory", + description: "Copy a block of memory within base RAM.", + searchTags: ["memcpy", "memory", "copy", "block"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "dest", + name: "Dest", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "source", + name: "Source", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "length", + name: "Length", + direction: "input", + kind: "number", + defaultValue: 0, + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const dest = resolveValueInput("dest", "0"); + const source = resolveValueInput("source", "0"); + const length = resolveValueInput("length", "0"); + const line = `${indent(indentLevel)}memcpy(${dest}, ${source}, ${length})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/memory/memset.js b/scripts/nodes/library/memory/memset.js new file mode 100644 index 0000000..8e8f35b --- /dev/null +++ b/scripts/nodes/library/memory/memset.js @@ -0,0 +1,51 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Fills memory with a value using memset. + */ +export const memoryMemsetNode = createNodeModule( + { + id: "memory_memset", + title: "Fill Memory", + category: "Memory", + description: "Fill a region of base RAM with a repeated value.", + searchTags: ["memset", "memory", "fill"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "dest", + name: "Dest", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "value", + name: "Value", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "length", + name: "Length", + direction: "input", + kind: "number", + defaultValue: 0, + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const dest = resolveValueInput("dest", "0"); + const value = resolveValueInput("value", "0"); + const length = resolveValueInput("length", "0"); + const line = `${indent(indentLevel)}memset(${dest}, ${value}, ${length})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/memory/peek.js b/scripts/nodes/library/memory/peek.js new file mode 100644 index 0000000..6f1af59 --- /dev/null +++ b/scripts/nodes/library/memory/peek.js @@ -0,0 +1,45 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Reads bytes from base RAM using PICO-8's peek helper. + */ +export const memoryPeekNode = createNodeModule( + { + id: "memory_peek", + title: "Peek Memory", + category: "Memory", + description: "Read a byte or sequence of bytes from base RAM.", + searchTags: ["peek", "memory", "read"], + inputs: [ + { + id: "address", + name: "Address", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "count", + name: "Count", + direction: "input", + kind: "number", + description: "Optional number of bytes to read", + }, + ], + outputs: [ + { id: "value", name: "Value", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const OMIT = "__pg_omit__"; + const address = resolveValueInput("address", "0"); + const count = resolveValueInput("count", OMIT); + if (count === OMIT) { + return `peek(${address})`; + } + return `peek(${address}, ${count})`; + }, + } +); diff --git a/scripts/nodes/library/memory/peek2.js b/scripts/nodes/library/memory/peek2.js new file mode 100644 index 0000000..13cb73e --- /dev/null +++ b/scripts/nodes/library/memory/peek2.js @@ -0,0 +1,33 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Reads a 16-bit little-endian value using peek2. + */ +export const memoryPeek2Node = createNodeModule( + { + id: "memory_peek2", + title: "Peek16 Memory", + category: "Memory", + description: "Read a 16-bit little-endian value from base RAM.", + searchTags: ["peek2", "memory", "read", "16-bit"], + inputs: [ + { + id: "address", + name: "Address", + direction: "input", + kind: "number", + defaultValue: 0, + }, + ], + outputs: [ + { id: "value", name: "Value", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const address = resolveValueInput("address", "0"); + return `peek2(${address})`; + }, + } +); diff --git a/scripts/nodes/library/memory/peek4.js b/scripts/nodes/library/memory/peek4.js new file mode 100644 index 0000000..9502eda --- /dev/null +++ b/scripts/nodes/library/memory/peek4.js @@ -0,0 +1,33 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Reads a 32-bit little-endian value using peek4. + */ +export const memoryPeek4Node = createNodeModule( + { + id: "memory_peek4", + title: "Peek32 Memory", + category: "Memory", + description: "Read a 32-bit little-endian value from base RAM.", + searchTags: ["peek4", "memory", "read", "32-bit"], + inputs: [ + { + id: "address", + name: "Address", + direction: "input", + kind: "number", + defaultValue: 0, + }, + ], + outputs: [ + { id: "value", name: "Value", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const address = resolveValueInput("address", "0"); + return `peek4(${address})`; + }, + } +); diff --git a/scripts/nodes/library/memory/poke.js b/scripts/nodes/library/memory/poke.js new file mode 100644 index 0000000..1387abb --- /dev/null +++ b/scripts/nodes/library/memory/poke.js @@ -0,0 +1,67 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Writes one or more bytes to base RAM using poke. + */ +export const memoryPokeNode = createNodeModule( + { + id: "memory_poke", + title: "Poke Memory", + category: "Memory", + description: "Write sequential bytes to base RAM.", + searchTags: ["poke", "memory", "write"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "address", + name: "Address", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "value", + name: "Value", + direction: "input", + kind: "number", + defaultValue: 0, + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [ + { + key: "extraValues", + label: "Additional Values", + type: "string", + placeholder: "e.g. 0x10, 0x11", + }, + ], + }, + { + emitExec: ({ + node, + indent, + indentLevel, + resolveValueInput, + emitNextExec, + }) => { + const address = resolveValueInput("address", "0"); + const value = resolveValueInput("value", "0"); + const args = [address, value]; + + const extras = String(node.properties.extraValues ?? "").trim(); + if (extras.length) { + extras + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .forEach((entry) => args.push(entry)); + } + + const line = `${indent(indentLevel)}poke(${args.join(", ")})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/memory/poke2.js b/scripts/nodes/library/memory/poke2.js new file mode 100644 index 0000000..46b87e5 --- /dev/null +++ b/scripts/nodes/library/memory/poke2.js @@ -0,0 +1,43 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Writes a 16-bit little-endian value using poke2. + */ +export const memoryPoke2Node = createNodeModule( + { + id: "memory_poke2", + title: "Poke16 Memory", + category: "Memory", + description: "Write a 16-bit little-endian value to base RAM.", + searchTags: ["poke2", "memory", "write", "16-bit"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "address", + name: "Address", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "value", + name: "Value", + direction: "input", + kind: "number", + defaultValue: 0, + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const address = resolveValueInput("address", "0"); + const value = resolveValueInput("value", "0"); + const line = `${indent(indentLevel)}poke2(${address}, ${value})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/memory/poke4.js b/scripts/nodes/library/memory/poke4.js new file mode 100644 index 0000000..b82d6b1 --- /dev/null +++ b/scripts/nodes/library/memory/poke4.js @@ -0,0 +1,43 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Writes a 32-bit little-endian value using poke4. + */ +export const memoryPoke4Node = createNodeModule( + { + id: "memory_poke4", + title: "Poke32 Memory", + category: "Memory", + description: "Write a 32-bit little-endian value to base RAM.", + searchTags: ["poke4", "memory", "write", "32-bit"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "address", + name: "Address", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "value", + name: "Value", + direction: "input", + kind: "number", + defaultValue: 0, + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const address = resolveValueInput("address", "0"); + const value = resolveValueInput("value", "0"); + const line = `${indent(indentLevel)}poke4(${address}, ${value})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/memory/reload.js b/scripts/nodes/library/memory/reload.js new file mode 100644 index 0000000..e8e4d5c --- /dev/null +++ b/scripts/nodes/library/memory/reload.js @@ -0,0 +1,67 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Copies data from cartridge ROM into base RAM using reload. + */ +export const memoryReloadNode = createNodeModule( + { + id: "memory_reload", + title: "Reload Memory", + category: "Memory", + description: + "Copy data from cart ROM into base RAM, optionally from another cart.", + searchTags: ["reload", "memory", "copy", "rom"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "dest", + name: "Dest", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "source", + name: "Source", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "length", + name: "Length", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "filename", + name: "Filename", + direction: "input", + kind: "string", + description: "Optional cartridge filename", + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const OMIT = "__pg_omit__"; + const dest = resolveValueInput("dest", "0"); + const source = resolveValueInput("source", "0"); + const length = resolveValueInput("length", "0"); + const filename = resolveValueInput("filename", OMIT); + + const args = [dest, source, length]; + if (filename !== OMIT) { + args.push(filename); + } + + const line = `${indent(indentLevel)}reload(${args.join(", ")})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/menu/index.js b/scripts/nodes/library/menu/index.js new file mode 100644 index 0000000..1bd814a --- /dev/null +++ b/scripts/nodes/library/menu/index.js @@ -0,0 +1,14 @@ +import { menuItemNode } from "./menuItem.js"; + +/** + * All menu-related node modules backed by PICO-8 helpers. + */ +export const menuNodes = [menuItemNode]; + +/** + * Checklist tracking coverage of documented menu helpers. + * @type {Array<{ function: string, nodeId: string, implemented: boolean }>} + */ +export const menuFunctionChecklist = [ + { function: "MENUITEM", nodeId: "menu_item", implemented: true }, +]; diff --git a/scripts/nodes/library/menu/menuItem.js b/scripts/nodes/library/menu/menuItem.js new file mode 100644 index 0000000..5a7d7bb --- /dev/null +++ b/scripts/nodes/library/menu/menuItem.js @@ -0,0 +1,63 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Adds or updates a pause menu item using PICO-8's menuitem helper. + */ +export const menuItemNode = createNodeModule( + { + id: "menu_item", + title: "Menu Item", + category: "Menu", + description: + "Add, update, or remove a custom pause menu entry using MENUITEM().", + searchTags: ["menu", "menuitem", "pause", "ui"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "index", + name: "Index", + direction: "input", + kind: "number", + description: "Menu slot and optional mask", + defaultValue: 1, + }, + { + id: "label", + name: "Label", + direction: "input", + kind: "string", + description: "Menu label or nil to remove", + }, + { + id: "callback", + name: "Callback", + direction: "input", + kind: "any", + description: "Function reference to invoke", + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const OMIT = "__pg_omit__"; + const index = resolveValueInput("index", "1"); + const label = resolveValueInput("label", OMIT); + const callback = resolveValueInput("callback", OMIT); + + const args = [index]; + if (label !== OMIT || callback !== OMIT) { + args.push(label === OMIT ? "nil" : label); + if (callback !== OMIT) { + args.push(callback); + } + } + + const line = `${indent(indentLevel)}menuitem(${args.join(", ")})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/multiplyNumber.js b/scripts/nodes/library/multiplyNumber.js new file mode 100644 index 0000000..f427f32 --- /dev/null +++ b/scripts/nodes/library/multiplyNumber.js @@ -0,0 +1,41 @@ +import { createNodeModule } from "../nodeTypes.js"; + +/** + * Multiplies two numeric values. + */ +export const multiplyNumberNode = createNodeModule( + { + id: "multiply_number", + title: "Multiply", + category: "Math", + description: "Multiply two numbers.", + searchTags: ["multiply", "product", "math", "number"], + inputs: [ + { + id: "a", + name: "A", + direction: "input", + kind: "number", + defaultValue: 1, + }, + { + id: "b", + name: "B", + direction: "input", + kind: "number", + defaultValue: 1, + }, + ], + outputs: [ + { id: "res", name: "Result", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const a = resolveValueInput("a", "1"); + const b = resolveValueInput("b", "1"); + return `(${a}) * (${b})`; + }, + } +); diff --git a/scripts/nodes/library/numberLiteral.js b/scripts/nodes/library/numberLiteral.js new file mode 100644 index 0000000..a8a7b9b --- /dev/null +++ b/scripts/nodes/library/numberLiteral.js @@ -0,0 +1,26 @@ +import { createNodeModule } from "../nodeTypes.js"; + +/** + * Constant numeric literal. + */ +export const numberLiteralNode = createNodeModule( + { + id: "number_literal", + title: "Number", + category: "Values", + description: "Constant numeric literal.", + searchTags: ["number", "literal", "constant", "value"], + inputs: [], + outputs: [ + { id: "value", name: "Value", direction: "output", kind: "number" }, + ], + properties: [ + { key: "value", label: "Number", type: "number", defaultValue: 0 }, + ], + }, + { + evaluateValue: ({ node, formatLiteral }) => { + return formatLiteral("number", node.properties.value ?? 0); + }, + } +); diff --git a/scripts/nodes/library/print.js b/scripts/nodes/library/print.js new file mode 100644 index 0000000..8b5e53a --- /dev/null +++ b/scripts/nodes/library/print.js @@ -0,0 +1,59 @@ +import { createNodeModule } from "../nodeTypes.js"; + +/** + * Emits a PICO-8 print call. + */ +export const printNode = createNodeModule( + { + id: "print", + title: "Print", + category: "PICO-8", + description: "Render text on screen using the PICO-8 print command.", + searchTags: ["print", "text", "output", "debug"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "msg", + name: "Text", + direction: "input", + kind: "string", + defaultValue: "hello", + }, + { + id: "x", + name: "X", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "y", + name: "Y", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "color", + name: "Color", + direction: "input", + kind: "number", + defaultValue: 7, + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const text = resolveValueInput("msg", '""'); + const x = resolveValueInput("x", "0"); + const y = resolveValueInput("y", "0"); + const color = resolveValueInput("color", "7"); + const line = `${indent(indentLevel)}print(${text}, ${x}, ${y}, ${color})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/sequence.js b/scripts/nodes/library/sequence.js new file mode 100644 index 0000000..879a733 --- /dev/null +++ b/scripts/nodes/library/sequence.js @@ -0,0 +1,74 @@ +import { createNodeModule } from "../nodeTypes.js"; + +/** + * Executes multiple branches in sequence. + */ +export const sequenceNode = createNodeModule( + { + id: "sequence", + title: "Sequence", + category: "Logic", + description: "Execute connected branches sequentially.", + searchTags: ["sequence", "flow", "order", "multi"], + inputs: [{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" }], + outputs: [ + { id: "a", name: "A", direction: "output", kind: "exec" }, + { id: "b", name: "B", direction: "output", kind: "exec" }, + { id: "c", name: "C", direction: "output", kind: "exec" }, + ], + properties: [], + initializeProperties: (properties) => { + if (!Array.isArray(properties.branches)) { + properties.branches = [ + { id: "a" }, + { id: "b" }, + { id: "c" }, + ]; + } else { + properties.branches = properties.branches + .map((entry) => + entry && typeof entry.id === "string" + ? { id: entry.id.trim() || "" } + : null + ) + .filter((entry) => entry && entry.id); + if (!properties.branches.length) { + properties.branches = [ + { id: "a" }, + { id: "b" }, + { id: "c" }, + ]; + } + } + + if (!Number.isFinite(properties.branchCounter)) { + properties.branchCounter = properties.branches.length; + } + }, + }, + { + emitExec: ({ + node, + indent, + indentLevel, + findExecTargets, + emitExecChain, + path, + }) => { + const lines = []; + node.outputs.forEach((pin) => { + const label = (pin.name ?? pin.id).toLowerCase(); + lines.push(`${indent(indentLevel)}-- sequence ${label}`); + const targets = findExecTargets(pin.id); + if (!targets.length) { + return; + } + targets.forEach((target) => { + const branchPath = new Set(path); + lines.push(...emitExecChain(target.nodeId, indentLevel, branchPath)); + }); + }); + return lines; + }, + } +); diff --git a/scripts/nodes/library/serial/index.js b/scripts/nodes/library/serial/index.js new file mode 100644 index 0000000..df565b0 --- /dev/null +++ b/scripts/nodes/library/serial/index.js @@ -0,0 +1,16 @@ +import { serialSendNode } from "./serialSend.js"; +import { serialReceiveNode } from "./serialReceive.js"; + +/** + * Node modules covering serial helpers. + */ +export const serialNodes = [serialSendNode, serialReceiveNode]; + +/** + * Checklist tracking coverage of serial helpers. + * @type {Array<{ function: string, nodeId: string, implemented: boolean }>} + */ +export const serialFunctionChecklist = [ + { function: "SERIAL (send)", nodeId: "serial_send", implemented: true }, + { function: "SERIAL (receive)", nodeId: "serial_receive", implemented: true }, +]; diff --git a/scripts/nodes/library/serial/serialReceive.js b/scripts/nodes/library/serial/serialReceive.js new file mode 100644 index 0000000..bd1d88f --- /dev/null +++ b/scripts/nodes/library/serial/serialReceive.js @@ -0,0 +1,33 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Reads pending data from a serial channel using serial(). + */ +export const serialReceiveNode = createNodeModule( + { + id: "serial_receive", + title: "Serial Receive", + category: "IO", + description: "Poll a serial channel for the next message.", + searchTags: ["serial", "io", "receive"], + inputs: [ + { + id: "channel", + name: "Channel", + direction: "input", + kind: "number", + defaultValue: 0, + }, + ], + outputs: [ + { id: "message", name: "Message", direction: "output", kind: "any" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const channel = resolveValueInput("channel", "0"); + return `serial(${channel})`; + }, + } +); diff --git a/scripts/nodes/library/serial/serialSend.js b/scripts/nodes/library/serial/serialSend.js new file mode 100644 index 0000000..127ab6c --- /dev/null +++ b/scripts/nodes/library/serial/serialSend.js @@ -0,0 +1,43 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Sends data over a serial channel using serial(). + */ +export const serialSendNode = createNodeModule( + { + id: "serial_send", + title: "Serial Send", + category: "IO", + description: "Transmit a string to a serial channel.", + searchTags: ["serial", "io", "send"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "channel", + name: "Channel", + direction: "input", + kind: "number", + defaultValue: 0, + }, + { + id: "message", + name: "Message", + direction: "input", + kind: "string", + defaultValue: "", + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const channel = resolveValueInput("channel", "0"); + const message = resolveValueInput("message", '""'); + const line = `${indent(indentLevel)}serial(${channel}, ${message})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/setLocalVariable.js b/scripts/nodes/library/setLocalVariable.js new file mode 100644 index 0000000..e69de29 diff --git a/scripts/nodes/library/setVariable.js b/scripts/nodes/library/setVariable.js new file mode 100644 index 0000000..53cc292 --- /dev/null +++ b/scripts/nodes/library/setVariable.js @@ -0,0 +1,44 @@ +import { createNodeModule } from "../nodeTypes.js"; + +/** + * Assigns a value to a variable within the current scope. + */ +export const setVariableNode = createNodeModule( + { + id: "set_var", + title: "Set Variable", + category: "Logic", + description: "Assign a value to a variable in the current scope.", + searchTags: ["set", "assign", "variable", "write"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { id: "value", name: "Value", direction: "input", kind: "any" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [ + { + key: "name", + label: "Variable Name", + type: "string", + defaultValue: "score", + }, + ], + }, + { + emitExec: ({ + node, + indent, + indentLevel, + resolveValueInput, + sanitizeIdentifier, + emitNextExec, + }) => { + const name = sanitizeIdentifier(String(node.properties.name ?? "var")); + const value = resolveValueInput("value", "nil"); + const line = `${indent(indentLevel)}${name} = ${value}`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/stringLiteral.js b/scripts/nodes/library/stringLiteral.js new file mode 100644 index 0000000..555df75 --- /dev/null +++ b/scripts/nodes/library/stringLiteral.js @@ -0,0 +1,26 @@ +import { createNodeModule } from "../nodeTypes.js"; + +/** + * Constant string literal. + */ +export const stringLiteralNode = createNodeModule( + { + id: "string_literal", + title: "String", + category: "Values", + description: "Constant string literal.", + searchTags: ["string", "literal", "text", "value"], + inputs: [], + outputs: [ + { id: "value", name: "Value", direction: "output", kind: "string" }, + ], + properties: [ + { key: "value", label: "Text", type: "string", defaultValue: "hello" }, + ], + }, + { + evaluateValue: ({ node, formatLiteral }) => { + return formatLiteral("string", node.properties.value ?? ""); + }, + } +); diff --git a/scripts/nodes/library/strings/chr.js b/scripts/nodes/library/strings/chr.js new file mode 100644 index 0000000..510a5fc --- /dev/null +++ b/scripts/nodes/library/strings/chr.js @@ -0,0 +1,43 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Converts ordinal values to characters using chr. + */ +export const stringChrNode = createNodeModule( + { + id: "string_chr", + title: "Character From Ordinals", + category: "Strings", + description: "Build a string from ordinal values using CHR().", + searchTags: ["chr", "string", "character"], + inputs: [ + { id: "value", name: "Value", direction: "input", kind: "any" }, + ], + outputs: [ + { id: "result", name: "Result", direction: "output", kind: "string" }, + ], + properties: [ + { + key: "extraValues", + label: "Additional Values", + type: "string", + placeholder: "e.g. 65, 66, 67", + }, + ], + }, + { + evaluateValue: ({ node, resolveValueInput }) => { + const value = resolveValueInput("value", "0"); + const args = [value]; + const extras = String(node.properties.extraValues ?? "").trim(); + if (extras.length) { + extras + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .forEach((entry) => args.push(entry)); + } + return `chr(${args.join(", ")})`; + }, + } +); diff --git a/scripts/nodes/library/strings/index.js b/scripts/nodes/library/strings/index.js new file mode 100644 index 0000000..d28ac2a --- /dev/null +++ b/scripts/nodes/library/strings/index.js @@ -0,0 +1,34 @@ +import { stringTostrNode } from "./tostr.js"; +import { stringTonumNode } from "./tonum.js"; +import { stringChrNode } from "./chr.js"; +import { stringOrdNode } from "./ord.js"; +import { stringSubNode } from "./sub.js"; +import { stringSplitNode } from "./split.js"; +import { stringTypeNode } from "./typeOf.js"; + +/** + * All string-related node modules backed by PICO-8 helpers. + */ +export const stringNodes = [ + stringTostrNode, + stringTonumNode, + stringChrNode, + stringOrdNode, + stringSubNode, + stringSplitNode, + stringTypeNode, +]; + +/** + * Checklist tracking coverage of documented string helpers. + * @type {Array<{ function: string, nodeId: string, implemented: boolean }>} + */ +export const stringFunctionChecklist = [ + { function: "TOSTR", nodeId: "string_tostr", implemented: true }, + { function: "TONUM", nodeId: "string_tonum", implemented: true }, + { function: "CHR", nodeId: "string_chr", implemented: true }, + { function: "ORD", nodeId: "string_ord", implemented: true }, + { function: "SUB", nodeId: "string_sub", implemented: true }, + { function: "SPLIT", nodeId: "string_split", implemented: true }, + { function: "TYPE", nodeId: "string_type", implemented: true }, +]; diff --git a/scripts/nodes/library/strings/ord.js b/scripts/nodes/library/strings/ord.js new file mode 100644 index 0000000..8398ed1 --- /dev/null +++ b/scripts/nodes/library/strings/ord.js @@ -0,0 +1,55 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Converts characters to ordinal values using ord. + */ +export const stringOrdNode = createNodeModule( + { + id: "string_ord", + title: "Ordinals From String", + category: "Strings", + description: + "Return ordinal codes for characters in a string using ORD().", + searchTags: ["ord", "string", "character"], + inputs: [ + { id: "string", name: "String", direction: "input", kind: "string" }, + { + id: "index", + name: "Index", + direction: "input", + kind: "number", + description: "Optional starting index", + }, + { + id: "count", + name: "Count", + direction: "input", + kind: "number", + description: "Optional result count", + }, + ], + outputs: [ + { id: "result", name: "Result", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const OMIT = "__pg_omit__"; + const str = resolveValueInput("string", "\"\""); + const index = resolveValueInput("index", OMIT); + const count = resolveValueInput("count", OMIT); + + if (index === OMIT && count === OMIT) { + return `ord(${str})`; + } + + if (count === OMIT) { + return `ord(${str}, ${index === OMIT ? "nil" : index})`; + } + + const indexArg = index === OMIT ? "nil" : index; + return `ord(${str}, ${indexArg}, ${count})`; + }, + } +); diff --git a/scripts/nodes/library/strings/split.js b/scripts/nodes/library/strings/split.js new file mode 100644 index 0000000..985e827 --- /dev/null +++ b/scripts/nodes/library/strings/split.js @@ -0,0 +1,54 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Splits strings into tables using split. + */ +export const stringSplitNode = createNodeModule( + { + id: "string_split", + title: "Split String", + category: "Strings", + description: "Split a string into tokens using SPLIT().", + searchTags: ["split", "string", "table", "parse"], + inputs: [ + { id: "string", name: "String", direction: "input", kind: "string" }, + { + id: "separator", + name: "Separator", + direction: "input", + kind: "any", + description: "Optional separator (string or number)", + }, + { + id: "convert", + name: "Convert Numbers", + direction: "input", + kind: "boolean", + description: "Override numeric conversion (defaults true)", + }, + ], + outputs: [ + { id: "result", name: "Result", direction: "output", kind: "table" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const OMIT = "__pg_omit__"; + const str = resolveValueInput("string", "\"\""); + const sep = resolveValueInput("separator", OMIT); + const convert = resolveValueInput("convert", OMIT); + + if (sep === OMIT && convert === OMIT) { + return `split(${str})`; + } + + if (convert === OMIT) { + return `split(${str}, ${sep === OMIT ? "nil" : sep})`; + } + + const sepArg = sep === OMIT ? "nil" : sep; + return `split(${str}, ${sepArg}, ${convert})`; + }, + } +); diff --git a/scripts/nodes/library/strings/sub.js b/scripts/nodes/library/strings/sub.js new file mode 100644 index 0000000..cd14d14 --- /dev/null +++ b/scripts/nodes/library/strings/sub.js @@ -0,0 +1,48 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Extracts substrings using sub. + */ +export const stringSubNode = createNodeModule( + { + id: "string_sub", + title: "Substring", + category: "Strings", + description: "Extract a substring using SUB().", + searchTags: ["sub", "substring", "string"], + inputs: [ + { id: "string", name: "String", direction: "input", kind: "string" }, + { + id: "start", + name: "Start", + direction: "input", + kind: "number", + }, + { + id: "stop", + name: "Stop", + direction: "input", + kind: "any", + description: "Optional end position or truthy for single character", + }, + ], + outputs: [ + { id: "result", name: "Result", direction: "output", kind: "string" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const OMIT = "__pg_omit__"; + const str = resolveValueInput("string", "\"\""); + const start = resolveValueInput("start", "1"); + const stop = resolveValueInput("stop", OMIT); + + if (stop === OMIT) { + return `sub(${str}, ${start})`; + } + + return `sub(${str}, ${start}, ${stop})`; + }, + } +); diff --git a/scripts/nodes/library/strings/tonum.js b/scripts/nodes/library/strings/tonum.js new file mode 100644 index 0000000..02538af --- /dev/null +++ b/scripts/nodes/library/strings/tonum.js @@ -0,0 +1,39 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Converts values to numbers using tonum. + */ +export const stringTonumNode = createNodeModule( + { + id: "string_tonum", + title: "To Number", + category: "Strings", + description: "Convert a value to a number with optional format flags.", + searchTags: ["tonum", "string", "number", "convert"], + inputs: [ + { id: "value", name: "Value", direction: "input", kind: "any" }, + { + id: "flags", + name: "Flags", + direction: "input", + kind: "number", + description: "Optional format flags bitfield", + }, + ], + outputs: [ + { id: "result", name: "Result", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const OMIT = "__pg_omit__"; + const value = resolveValueInput("value", "nil"); + const flags = resolveValueInput("flags", OMIT); + if (flags === OMIT) { + return `tonum(${value})`; + } + return `tonum(${value}, ${flags})`; + }, + } +); diff --git a/scripts/nodes/library/strings/tostr.js b/scripts/nodes/library/strings/tostr.js new file mode 100644 index 0000000..2488f9e --- /dev/null +++ b/scripts/nodes/library/strings/tostr.js @@ -0,0 +1,39 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Converts values to strings using tostr. + */ +export const stringTostrNode = createNodeModule( + { + id: "string_tostr", + title: "To String", + category: "Strings", + description: "Convert a value to a string with optional format flags.", + searchTags: ["tostr", "string", "convert"], + inputs: [ + { id: "value", name: "Value", direction: "input", kind: "any" }, + { + id: "flags", + name: "Flags", + direction: "input", + kind: "number", + description: "Optional format flags bitfield", + }, + ], + outputs: [ + { id: "result", name: "Result", direction: "output", kind: "string" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const OMIT = "__pg_omit__"; + const value = resolveValueInput("value", "nil"); + const flags = resolveValueInput("flags", OMIT); + if (flags === OMIT) { + return `tostr(${value})`; + } + return `tostr(${value}, ${flags})`; + }, + } +); diff --git a/scripts/nodes/library/strings/typeOf.js b/scripts/nodes/library/strings/typeOf.js new file mode 100644 index 0000000..725641e --- /dev/null +++ b/scripts/nodes/library/strings/typeOf.js @@ -0,0 +1,25 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Returns the type of a value using type. + */ +export const stringTypeNode = createNodeModule( + { + id: "string_type", + title: "Type Of", + category: "Strings", + description: "Return the type name of a value using TYPE().", + searchTags: ["type", "string", "introspection"], + inputs: [{ id: "value", name: "Value", direction: "input", kind: "any" }], + outputs: [ + { id: "result", name: "Result", direction: "output", kind: "string" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const value = resolveValueInput("value", "nil"); + return `type(${value})`; + }, + } +); diff --git a/scripts/nodes/library/system/assert.js b/scripts/nodes/library/system/assert.js new file mode 100644 index 0000000..23a53f5 --- /dev/null +++ b/scripts/nodes/library/system/assert.js @@ -0,0 +1,41 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Stops execution when a condition is false, printing an optional message. + */ +export const assertNode = createNodeModule( + { + id: "system_assert", + title: "Assert", + category: "System", + description: + "Assert that a condition is true, stopping the cart when it fails.", + searchTags: ["assert", "debug", "check", "system"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "condition", + name: "Condition", + direction: "input", + kind: "boolean", + defaultValue: true, + }, + { id: "message", name: "Message", direction: "input", kind: "string" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const OMIT = "__pg_omit__"; + const condition = resolveValueInput("condition", "true"); + const message = resolveValueInput("message", OMIT); + const callArgs = message === OMIT ? condition : `${condition}, ${message}`; + const lines = [`${indent(indentLevel)}assert(${callArgs})`]; + lines.push(...emitNextExec("exec_out")); + return lines; + }, + } +); diff --git a/scripts/nodes/library/system/extcmd.js b/scripts/nodes/library/system/extcmd.js new file mode 100644 index 0000000..8f6cd3c --- /dev/null +++ b/scripts/nodes/library/system/extcmd.js @@ -0,0 +1,93 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +const EXT_CMD_DEFAULT = "reset"; + +const EXT_CMD_OPTIONS = [ + { value: "pause", label: "Pause Menu" }, + { value: EXT_CMD_DEFAULT, label: "Reset Cart" }, + { value: "go_back", label: "Return To Previous Cart" }, + { value: "label", label: "Capture Label" }, + { value: "screen", label: "Save Screenshot" }, + { value: "rec", label: "Mark Video Start" }, + { value: "rec_frames", label: "Mark Video Start (Frames)" }, + { value: "video", label: "Save Video" }, + { value: "audio_rec", label: "Start Audio Recording" }, + { value: "audio_end", label: "Finish Audio Recording" }, + { value: "shutdown", label: "Shutdown Cartridge" }, + { value: "folder", label: "Open Cartridge Folder" }, + { value: "set_filename", label: "Set Capture Filename" }, + { value: "set_title", label: "Set Window Title" }, +]; + +/** + * Issues special host commands such as screenshots or recordings. + */ +export const extcmdNode = createNodeModule( + { + id: "system_extcmd", + title: "System Command", + category: "System", + description: "Execute a special system command using extcmd().", + searchTags: [ + "extcmd", + "system", + "command", + "host", + "screenshot", + "video", + "audio", + "pause", + "reset", + ], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { id: "command", name: "Command", direction: "input", kind: "string" }, + { id: "param1", name: "Param 1", direction: "input", kind: "number" }, + { id: "param2", name: "Param 2", direction: "input", kind: "number" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [ + { + key: "command", + label: "Command", + type: "enum", + defaultValue: EXT_CMD_DEFAULT, + options: EXT_CMD_OPTIONS, + }, + ], + }, + { + emitExec: ({ + node, + indent, + indentLevel, + resolveValueInput, + emitNextExec, + formatLiteral, + }) => { + const OMIT = "__pg_omit__"; + const fallbackCommand = formatLiteral( + "string", + node.properties.command ?? EXT_CMD_DEFAULT + ); + const command = resolveValueInput("command", fallbackCommand); + const param1 = resolveValueInput("param1", OMIT); + const param2 = resolveValueInput("param2", OMIT); + + const args = [command]; + if (param1 !== OMIT || param2 !== OMIT) { + const param1Arg = param1 === OMIT ? "nil" : param1; + args.push(param1Arg); + if (param2 !== OMIT) { + args.push(param2); + } + } + + const lines = [`${indent(indentLevel)}extcmd(${args.join(", ")})`]; + lines.push(...emitNextExec("exec_out")); + return lines; + }, + } +); diff --git a/scripts/nodes/library/system/flip.js b/scripts/nodes/library/system/flip.js new file mode 100644 index 0000000..d341b3f --- /dev/null +++ b/scripts/nodes/library/system/flip.js @@ -0,0 +1,27 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Performs a manual buffer flip when using a custom main loop. + */ +export const flipNode = createNodeModule( + { + id: "system_flip", + title: "Flip Buffer", + category: "System", + description: + "Flip the back buffer to the screen and wait for the next frame.", + searchTags: ["flip", "sync", "frame", "system"], + inputs: [{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" }], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, emitNextExec }) => { + const lines = [`${indent(indentLevel)}flip()`]; + lines.push(...emitNextExec("exec_out")); + return lines; + }, + } +); diff --git a/scripts/nodes/library/system/folder.js b/scripts/nodes/library/system/folder.js new file mode 100644 index 0000000..68e6bfc --- /dev/null +++ b/scripts/nodes/library/system/folder.js @@ -0,0 +1,26 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Opens the cartridge folder on the host operating system. + */ +export const folderNode = createNodeModule( + { + id: "system_folder", + title: "Open Folder", + category: "System", + description: "Open the cartridge folder on the host operating system.", + searchTags: ["folder", "system", "directory", "open"], + inputs: [{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" }], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, emitNextExec }) => { + const lines = [`${indent(indentLevel)}folder()`]; + lines.push(...emitNextExec("exec_out")); + return lines; + }, + } +); diff --git a/scripts/nodes/library/system/index.js b/scripts/nodes/library/system/index.js new file mode 100644 index 0000000..89f2f4e --- /dev/null +++ b/scripts/nodes/library/system/index.js @@ -0,0 +1,61 @@ +import { loadNode } from "./load.js"; +import { saveNode } from "./save.js"; +import { folderNode } from "./folder.js"; +import { lsNode } from "./ls.js"; +import { runNode } from "./run.js"; +import { stopNode } from "./stop.js"; +import { resumeNode } from "./resume.js"; +import { assertNode } from "./assert.js"; +import { rebootNode } from "./reboot.js"; +import { resetNode } from "./reset.js"; +import { infoNode } from "./info.js"; +import { flipNode } from "./flip.js"; +import { printhNode } from "./printh.js"; +import { timeNode } from "./time.js"; +import { statNode } from "./stat.js"; +import { extcmdNode } from "./extcmd.js"; + +/** + * All system related node modules backed by PICO-8 helper functions. + */ +export const systemNodes = [ + loadNode, + saveNode, + folderNode, + lsNode, + runNode, + stopNode, + resumeNode, + assertNode, + rebootNode, + resetNode, + infoNode, + flipNode, + printhNode, + timeNode, + statNode, + extcmdNode, +]; + +/** + * Checklist tracking coverage of documented system helpers. + * @type {Array<{ function: string, nodeId: string, implemented: boolean }>} + */ +export const systemFunctionChecklist = [ + { function: "LOAD", nodeId: "system_load", implemented: true }, + { function: "SAVE", nodeId: "system_save", implemented: true }, + { function: "FOLDER", nodeId: "system_folder", implemented: true }, + { function: "LS", nodeId: "system_ls", implemented: true }, + { function: "RUN", nodeId: "system_run", implemented: true }, + { function: "STOP", nodeId: "system_stop", implemented: true }, + { function: "RESUME", nodeId: "system_resume", implemented: true }, + { function: "ASSERT", nodeId: "system_assert", implemented: true }, + { function: "REBOOT", nodeId: "system_reboot", implemented: true }, + { function: "RESET", nodeId: "system_reset", implemented: true }, + { function: "INFO", nodeId: "system_info", implemented: true }, + { function: "FLIP", nodeId: "system_flip", implemented: true }, + { function: "PRINTH", nodeId: "system_printh", implemented: true }, + { function: "TIME", nodeId: "system_time", implemented: true }, + { function: "STAT", nodeId: "system_stat", implemented: true }, + { function: "EXTCMD", nodeId: "system_extcmd", implemented: true }, +]; diff --git a/scripts/nodes/library/system/info.js b/scripts/nodes/library/system/info.js new file mode 100644 index 0000000..e1fbdf2 --- /dev/null +++ b/scripts/nodes/library/system/info.js @@ -0,0 +1,27 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Prints cartridge diagnostics to the console. + */ +export const infoNode = createNodeModule( + { + id: "system_info", + title: "Cartridge Info", + category: "System", + description: + "Print information about the cartridge including size and token counts.", + searchTags: ["info", "memory", "stats", "system"], + inputs: [{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" }], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, emitNextExec }) => { + const lines = [`${indent(indentLevel)}info()`]; + lines.push(...emitNextExec("exec_out")); + return lines; + }, + } +); diff --git a/scripts/nodes/library/system/load.js b/scripts/nodes/library/system/load.js new file mode 100644 index 0000000..f376b4d --- /dev/null +++ b/scripts/nodes/library/system/load.js @@ -0,0 +1,57 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Loads another cartridge and optionally supplies breadcrumb and parameter metadata. + */ +export const loadNode = createNodeModule( + { + id: "system_load", + title: "Load Cartridge", + category: "System", + description: + "Load a local or BBS cartridge, optionally providing breadcrumb and parameter string.", + searchTags: ["load", "cartridge", "system", "cart"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "filename", + name: "Filename", + direction: "input", + kind: "string", + defaultValue: "cart.p8", + }, + { + id: "breadcrumb", + name: "Breadcrumb", + direction: "input", + kind: "string", + }, + { id: "param", name: "Param String", direction: "input", kind: "string" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const OMIT = "__pg_omit__"; + const filename = resolveValueInput("filename", '""'); + const breadcrumb = resolveValueInput("breadcrumb", OMIT); + const param = resolveValueInput("param", OMIT); + + const args = [filename]; + if (breadcrumb !== OMIT || param !== OMIT) { + const breadcrumbArg = breadcrumb === OMIT ? "nil" : breadcrumb; + args.push(breadcrumbArg); + if (param !== OMIT) { + args.push(param); + } + } + + const lines = [`${indent(indentLevel)}load(${args.join(", ")})`]; + lines.push(...emitNextExec("exec_out")); + return lines; + }, + } +); diff --git a/scripts/nodes/library/system/ls.js b/scripts/nodes/library/system/ls.js new file mode 100644 index 0000000..ff9760d --- /dev/null +++ b/scripts/nodes/library/system/ls.js @@ -0,0 +1,33 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Lists cartridges within the provided directory relative to the virtual drive. + */ +export const lsNode = createNodeModule( + { + id: "system_ls", + title: "List Directory", + category: "System", + description: + "List .p8 and .p8.png files in a directory relative to the current path.", + searchTags: ["ls", "list", "files", "system"], + inputs: [ + { + id: "directory", + name: "Directory", + direction: "input", + kind: "string", + }, + ], + outputs: [ + { id: "entries", name: "Entries", direction: "output", kind: "table" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const directory = resolveValueInput("directory", "nil"); + return directory === "nil" ? "ls()" : `ls(${directory})`; + }, + } +); diff --git a/scripts/nodes/library/system/printh.js b/scripts/nodes/library/system/printh.js new file mode 100644 index 0000000..e58c359 --- /dev/null +++ b/scripts/nodes/library/system/printh.js @@ -0,0 +1,70 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Prints to the host console or files for debugging utilities. + */ +export const printhNode = createNodeModule( + { + id: "system_printh", + title: "Print Host", + category: "System", + description: + "Print a string to the host console or optionally to a file or clipboard.", + searchTags: ["printh", "print", "debug", "file", "system"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "text", + name: "Text", + direction: "input", + kind: "string", + defaultValue: "debug", + }, + { id: "filename", name: "Filename", direction: "input", kind: "string" }, + { + id: "overwrite", + name: "Overwrite", + direction: "input", + kind: "boolean", + defaultValue: false, + }, + { + id: "save_to_desktop", + name: "Save to Desktop", + direction: "input", + kind: "boolean", + defaultValue: false, + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const text = resolveValueInput("text", '""'); + const filename = resolveValueInput("filename", "nil"); + const overwrite = resolveValueInput("overwrite", "false"); + const saveToDesktop = resolveValueInput("save_to_desktop", "false"); + + const args = [text]; + const hasFilename = filename !== "nil"; + if (hasFilename) { + args.push(filename); + const includeOverwrite = + overwrite !== "false" || saveToDesktop !== "false"; + if (includeOverwrite) { + args.push(overwrite); + if (saveToDesktop !== "false") { + args.push(saveToDesktop); + } + } + } + + const lines = [`${indent(indentLevel)}printh(${args.join(", ")})`]; + lines.push(...emitNextExec("exec_out")); + return lines; + }, + } +); diff --git a/scripts/nodes/library/system/reboot.js b/scripts/nodes/library/system/reboot.js new file mode 100644 index 0000000..f5a9877 --- /dev/null +++ b/scripts/nodes/library/system/reboot.js @@ -0,0 +1,26 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Reboots PICO-8, returning to a fresh command prompt. + */ +export const rebootNode = createNodeModule( + { + id: "system_reboot", + title: "Reboot", + category: "System", + description: "Reboot the virtual machine to begin a new project.", + searchTags: ["reboot", "restart", "system", "cart"], + inputs: [{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" }], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, emitNextExec }) => { + const lines = [`${indent(indentLevel)}reboot()`]; + lines.push(...emitNextExec("exec_out")); + return lines; + }, + } +); diff --git a/scripts/nodes/library/system/reset.js b/scripts/nodes/library/system/reset.js new file mode 100644 index 0000000..da24d8c --- /dev/null +++ b/scripts/nodes/library/system/reset.js @@ -0,0 +1,27 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Restores draw state values such as palette and camera to their defaults. + */ +export const resetNode = createNodeModule( + { + id: "system_reset", + title: "Reset Draw State", + category: "System", + description: + "Reset memory range 0x5f00..0x5f7f, restoring draw state defaults.", + searchTags: ["reset", "clear", "system", "state"], + inputs: [{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" }], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, emitNextExec }) => { + const lines = [`${indent(indentLevel)}reset()`]; + lines.push(...emitNextExec("exec_out")); + return lines; + }, + } +); diff --git a/scripts/nodes/library/system/resume.js b/scripts/nodes/library/system/resume.js new file mode 100644 index 0000000..4213ad9 --- /dev/null +++ b/scripts/nodes/library/system/resume.js @@ -0,0 +1,26 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Resumes execution after a stop. + */ +export const resumeNode = createNodeModule( + { + id: "system_resume", + title: "Resume", + category: "System", + description: "Resume the program after it has been stopped.", + searchTags: ["resume", "continue", "system", "run"], + inputs: [{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" }], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, emitNextExec }) => { + const lines = [`${indent(indentLevel)}resume()`]; + lines.push(...emitNextExec("exec_out")); + return lines; + }, + } +); diff --git a/scripts/nodes/library/system/run.js b/scripts/nodes/library/system/run.js new file mode 100644 index 0000000..ca7a5c9 --- /dev/null +++ b/scripts/nodes/library/system/run.js @@ -0,0 +1,33 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Restarts the current program from the beginning. + */ +export const runNode = createNodeModule( + { + id: "system_run", + title: "Run", + category: "System", + description: + "Restart the program optionally providing a parameter string accessible via stat(6).", + searchTags: ["run", "launch", "cartridge", "system"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { id: "param", name: "Param String", direction: "input", kind: "string" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const OMIT = "__pg_omit__"; + const param = resolveValueInput("param", OMIT); + const call = param === OMIT ? "run()" : `run(${param})`; + const lines = [`${indent(indentLevel)}${call}`]; + lines.push(...emitNextExec("exec_out")); + return lines; + }, + } +); diff --git a/scripts/nodes/library/system/save.js b/scripts/nodes/library/system/save.js new file mode 100644 index 0000000..dcc4e19 --- /dev/null +++ b/scripts/nodes/library/system/save.js @@ -0,0 +1,36 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Persists the active cartridge to disk. + */ +export const saveNode = createNodeModule( + { + id: "system_save", + title: "Save Cartridge", + category: "System", + description: "Save the current cartridge to the specified filename.", + searchTags: ["save", "cartridge", "system", "cart"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { + id: "filename", + name: "Filename", + direction: "input", + kind: "string", + defaultValue: "cart.p8", + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const filename = resolveValueInput("filename", '""'); + const lines = [`${indent(indentLevel)}save(${filename})`]; + lines.push(...emitNextExec("exec_out")); + return lines; + }, + } +); diff --git a/scripts/nodes/library/system/stat.js b/scripts/nodes/library/system/stat.js new file mode 100644 index 0000000..48deda2 --- /dev/null +++ b/scripts/nodes/library/system/stat.js @@ -0,0 +1,31 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Queries system status values exposed through stat(). + */ +export const statNode = createNodeModule( + { + id: "system_stat", + title: "System Status", + category: "System", + description: "Fetch a system status value using the stat(index) helper.", + searchTags: ["stat", "status", "system", "metrics"], + inputs: [ + { + id: "index", + name: "Index", + direction: "input", + kind: "number", + defaultValue: 0, + }, + ], + outputs: [{ id: "value", name: "Value", direction: "output", kind: "any" }], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const index = resolveValueInput("index", "0"); + return `stat(${index})`; + }, + } +); diff --git a/scripts/nodes/library/system/stop.js b/scripts/nodes/library/system/stop.js new file mode 100644 index 0000000..ff7b6ed --- /dev/null +++ b/scripts/nodes/library/system/stop.js @@ -0,0 +1,32 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Stops the cart and optionally prints a message to the console. + */ +export const stopNode = createNodeModule( + { + id: "system_stop", + title: "Stop", + category: "System", + description: "Stop the current cart and optionally print a message.", + searchTags: ["stop", "halt", "exit", "system"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { id: "message", name: "Message", direction: "input", kind: "string" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const OMIT = "__pg_omit__"; + const message = resolveValueInput("message", OMIT); + const call = message === OMIT ? "stop()" : `stop(${message})`; + const lines = [`${indent(indentLevel)}${call}`]; + lines.push(...emitNextExec("exec_out")); + return lines; + }, + } +); diff --git a/scripts/nodes/library/system/time.js b/scripts/nodes/library/system/time.js new file mode 100644 index 0000000..8924da0 --- /dev/null +++ b/scripts/nodes/library/system/time.js @@ -0,0 +1,23 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Returns the number of seconds elapsed since the cart started running. + */ +export const timeNode = createNodeModule( + { + id: "system_time", + title: "Time", + category: "System", + description: + "Retrieve the elapsed time in seconds since the cart launched.", + searchTags: ["time", "seconds", "system", "elapsed"], + inputs: [], + outputs: [ + { id: "value", name: "Seconds", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: () => "time()", + } +); diff --git a/scripts/nodes/library/table/add.js b/scripts/nodes/library/table/add.js new file mode 100644 index 0000000..69d4f30 --- /dev/null +++ b/scripts/nodes/library/table/add.js @@ -0,0 +1,46 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Adds a value to a table using PICO-8's add helper. + */ +export const tableAddNode = createNodeModule( + { + id: "table_add", + title: "Add To Table", + category: "Tables", + description: "Append a value to a table, optionally inserting at an index.", + searchTags: ["add", "table", "insert", "append", "push"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { id: "table", name: "Table", direction: "input", kind: "table" }, + { id: "value", name: "Value", direction: "input", kind: "any" }, + { + id: "index", + name: "Index", + direction: "input", + kind: "number", + description: "Optional insertion index", + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const OMIT = "__pg_omit__"; + const tableValue = resolveValueInput("table", "{}"); + const value = resolveValueInput("value", "nil"); + const index = resolveValueInput("index", OMIT); + + const args = [tableValue, value]; + if (index !== OMIT) { + args.push(index); + } + + const line = `${indent(indentLevel)}add(${args.join(", ")})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/table/all.js b/scripts/nodes/library/table/all.js new file mode 100644 index 0000000..3a607d7 --- /dev/null +++ b/scripts/nodes/library/table/all.js @@ -0,0 +1,25 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Produces a table iterator using PICO-8's all helper. + */ +export const tableAllNode = createNodeModule( + { + id: "table_all", + title: "All Iterator", + category: "Tables", + description: "Create an iterator covering all indexed table values.", + searchTags: ["all", "table", "iterator", "loop"], + inputs: [{ id: "table", name: "Table", direction: "input", kind: "table" }], + outputs: [ + { id: "iterator", name: "Iterator", direction: "output", kind: "any" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const tableValue = resolveValueInput("table", "{}"); + return `all(${tableValue})`; + }, + } +); diff --git a/scripts/nodes/library/table/count.js b/scripts/nodes/library/table/count.js new file mode 100644 index 0000000..0b4530a --- /dev/null +++ b/scripts/nodes/library/table/count.js @@ -0,0 +1,40 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Counts entries in a table or matching values using PICO-8's count helper. + */ +export const tableCountNode = createNodeModule( + { + id: "table_count", + title: "Count Table", + category: "Tables", + description: "Return the number of entries in a table, optionally matching a value.", + searchTags: ["count", "table", "length", "size"], + inputs: [ + { id: "table", name: "Table", direction: "input", kind: "table" }, + { + id: "value", + name: "Value", + direction: "input", + kind: "any", + description: "Optional value to match", + }, + ], + outputs: [ + { id: "result", name: "Count", direction: "output", kind: "number" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const OMIT = "__pg_omit__"; + const tableValue = resolveValueInput("table", "{}"); + const value = resolveValueInput("value", OMIT); + const args = [tableValue]; + if (value !== OMIT) { + args.push(value); + } + return `count(${args.join(", ")})`; + }, + } +); diff --git a/scripts/nodes/library/table/del.js b/scripts/nodes/library/table/del.js new file mode 100644 index 0000000..8594e69 --- /dev/null +++ b/scripts/nodes/library/table/del.js @@ -0,0 +1,31 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Removes the first matching value from a table using PICO-8's del helper. + */ +export const tableDelNode = createNodeModule( + { + id: "table_del", + title: "Delete From Table", + category: "Tables", + description: "Remove the first matching value from a table.", + searchTags: ["del", "table", "remove", "delete"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { id: "table", name: "Table", direction: "input", kind: "table" }, + { id: "value", name: "Value", direction: "input", kind: "any" }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const tableValue = resolveValueInput("table", "{}"); + const value = resolveValueInput("value", "nil"); + const line = `${indent(indentLevel)}del(${tableValue}, ${value})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/table/deli.js b/scripts/nodes/library/table/deli.js new file mode 100644 index 0000000..714c261 --- /dev/null +++ b/scripts/nodes/library/table/deli.js @@ -0,0 +1,42 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Removes a table entry at a given index using PICO-8's deli helper. + */ +export const tableDeliNode = createNodeModule( + { + id: "table_deli", + title: "Delete Table Index", + category: "Tables", + description: "Remove and return the value at an index, or the last entry when omitted.", + searchTags: ["deli", "table", "remove", "pop"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { id: "table", name: "Table", direction: "input", kind: "table" }, + { + id: "index", + name: "Index", + direction: "input", + kind: "number", + description: "Optional index to remove", + }, + ], + outputs: [ + { id: "exec_out", name: "Exec", direction: "output", kind: "exec" }, + ], + properties: [], + }, + { + emitExec: ({ indent, indentLevel, resolveValueInput, emitNextExec }) => { + const OMIT = "__pg_omit__"; + const tableValue = resolveValueInput("table", "{}"); + const index = resolveValueInput("index", OMIT); + const args = [tableValue]; + if (index !== OMIT) { + args.push(index); + } + const line = `${indent(indentLevel)}deli(${args.join(", ")})`; + return [line, ...emitNextExec("exec_out")]; + }, + } +); diff --git a/scripts/nodes/library/table/foreach.js b/scripts/nodes/library/table/foreach.js new file mode 100644 index 0000000..2a173c6 --- /dev/null +++ b/scripts/nodes/library/table/foreach.js @@ -0,0 +1,116 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Iterates over a table with a callback using PICO-8's foreach helper and exposes loop variables. + */ +export const tableForeachNode = createNodeModule( + { + id: "table_foreach", + title: "Foreach Table", + category: "Tables", + description: + "Execute a callback for each value produced by foreach().", + searchTags: ["foreach", "table", "iterate", "loop", "index", "counter"], + inputs: [ + { id: "exec_in", name: "Exec", direction: "input", kind: "exec" }, + { id: "table", name: "Table", direction: "input", kind: "table" }, + ], + outputs: [ + { id: "loop", name: "Loop", direction: "output", kind: "exec" }, + { id: "item", name: "Item", direction: "output", kind: "any" }, + { id: "index", name: "Index", direction: "output", kind: "number" }, + { + id: "completed", + name: "Completed", + direction: "output", + kind: "exec", + }, + ], + properties: [ + { + key: "itemName", + label: "Item Variable", + type: "string", + defaultValue: "item", + }, + { + key: "indexName", + label: "Index Variable", + type: "string", + defaultValue: "i", + }, + ], + }, + { + emitExec: ({ + node, + indent, + indentLevel, + resolveValueInput, + sanitizeIdentifier, + emitBranch, + path, + }) => { + const tableValue = resolveValueInput("table", "{}"); + let itemName = sanitizeIdentifier(String(node.properties.itemName ?? "item")); + if (!itemName) { + itemName = "item"; + } + let indexName = sanitizeIdentifier(String(node.properties.indexName ?? "i")); + if (!indexName) { + indexName = "i"; + } + let counterName = sanitizeIdentifier(`__pg_${node.id}_index`); + if (!counterName) { + counterName = "__pg_index"; + } + if (counterName === indexName) { + counterName = `${counterName}_counter`; + } + const lines = [ + `${indent(indentLevel)}local ${counterName} = 0`, + `${indent(indentLevel)}foreach(${tableValue}, function(${itemName})`, + `${indent(indentLevel + 1)}${counterName} += 1`, + `${indent(indentLevel + 1)}local ${indexName} = ${counterName}`, + ]; + + const branchLines = emitBranch("loop", { + indentLevel: indentLevel + 1, + path: new Set(path), + }); + if (!branchLines.length) { + lines.push(`${indent(indentLevel + 1)}-- foreach body`); + } else { + lines.push(...branchLines); + } + + lines.push(`${indent(indentLevel)}end)`); + lines.push( + ...emitBranch("completed", { + indentLevel, + path, + }) + ); + + return lines; + }, + evaluateValue: ({ node, pinId, sanitizeIdentifier }) => { + switch (pinId) { + case "item": { + const name = sanitizeIdentifier( + String(node.properties.itemName ?? "item") + ); + return name || "item"; + } + case "index": { + const name = sanitizeIdentifier( + String(node.properties.indexName ?? "i") + ); + return name || "i"; + } + default: + return "nil"; + } + }, + } +); diff --git a/scripts/nodes/library/table/index.js b/scripts/nodes/library/table/index.js new file mode 100644 index 0000000..9d3d027 --- /dev/null +++ b/scripts/nodes/library/table/index.js @@ -0,0 +1,34 @@ +import { tableAddNode } from "./add.js"; +import { tableDelNode } from "./del.js"; +import { tableDeliNode } from "./deli.js"; +import { tableCountNode } from "./count.js"; +import { tableAllNode } from "./all.js"; +import { tableForeachNode } from "./foreach.js"; +import { tablePairsNode } from "./pairs.js"; + +/** + * All table-related node modules backed by PICO-8 helpers. + */ +export const tableNodes = [ + tableAddNode, + tableDelNode, + tableDeliNode, + tableCountNode, + tableAllNode, + tableForeachNode, + tablePairsNode, +]; + +/** + * Checklist tracking table helper coverage from the PICO-8 manual. + * @type {Array<{ function: string, nodeId: string, implemented: boolean }>} + */ +export const tableFunctionChecklist = [ + { function: "ADD", nodeId: "table_add", implemented: true }, + { function: "DEL", nodeId: "table_del", implemented: true }, + { function: "DELI", nodeId: "table_deli", implemented: true }, + { function: "COUNT", nodeId: "table_count", implemented: true }, + { function: "ALL", nodeId: "table_all", implemented: true }, + { function: "FOREACH", nodeId: "table_foreach", implemented: true }, + { function: "PAIRS", nodeId: "table_pairs", implemented: true }, +]; diff --git a/scripts/nodes/library/table/pairs.js b/scripts/nodes/library/table/pairs.js new file mode 100644 index 0000000..c9a746b --- /dev/null +++ b/scripts/nodes/library/table/pairs.js @@ -0,0 +1,25 @@ +import { createNodeModule } from "../../nodeTypes.js"; + +/** + * Produces a key-value iterator using PICO-8's pairs helper. + */ +export const tablePairsNode = createNodeModule( + { + id: "table_pairs", + title: "Pairs Iterator", + category: "Tables", + description: "Create an iterator yielding key-value pairs for a table.", + searchTags: ["pairs", "table", "iterator", "loop"], + inputs: [{ id: "table", name: "Table", direction: "input", kind: "table" }], + outputs: [ + { id: "iterator", name: "Iterator", direction: "output", kind: "any" }, + ], + properties: [], + }, + { + evaluateValue: ({ resolveValueInput }) => { + const tableValue = resolveValueInput("table", "{}"); + return `pairs(${tableValue})`; + }, + } +); diff --git a/scripts/nodes/nodeTypes.js b/scripts/nodes/nodeTypes.js new file mode 100644 index 0000000..d3e7bad --- /dev/null +++ b/scripts/nodes/nodeTypes.js @@ -0,0 +1,91 @@ +/** + * @typedef {Object} PropertySchema + * @property {string} key Property identifier. + * @property {string} label Human-friendly label. + * @property {('string'|'number'|'boolean'|'enum'|'multiline')} type Control type used in the inspector. + * @property {unknown} [defaultValue] Default property value. + * @property {Array<{label:string,value:string}>} [options] Option collection for enum values. + * @property {string} [placeholder] Optional placeholder text. + */ + +/** + * @typedef {Object} PinConfig + * @property {string} id Stable identifier unique within the node definition. + * @property {string} name Display name. + * @property {('input'|'output')} direction Pin direction relative to the node. + * @property {('exec'|'number'|'boolean'|'string'|'table'|'any')} kind Blueprint connection kind. + * @property {string} [description] Optional tooltip text. + * @property {unknown} [defaultValue] Default literal for data pins. + */ + +/** + * @typedef {Object} NodeDefinition + * @property {string} id Node identifier used for instantiation. + * @property {string} title Display title. + * @property {string} category Group name for palette organization. + * @property {string} [description] Optional description used in the palette. + * @property {Array} inputs Input pins. + * @property {Array} outputs Output pins. + * @property {Array} properties Property schema displayed in inspector. + * @property {Array} [searchTags] Optional keywords aiding palette searches. + * @property {(properties: Record) => void} [onPropertiesChanged] Lifecycle hook invoked after inspector edits. + * @property {(properties: Record) => void} [initializeProperties] Optional initializer for property defaults. + * @property {boolean} [unique=false] Whether the node is limited to a single instance within the workspace. + */ + +/** + * @typedef {import('../core/BlueprintNode.js').BlueprintNode} BlueprintNode + */ + +/** + * @typedef {Object} ExecContext + * @property {BlueprintNode} node Active node instance. + * @property {number} indentLevel Current indentation level. + * @property {Set} path Cycle detection guard set. + * @property {(pinId: string) => Array} emitNextExec Emits the next exec branch for the supplied output pin. + * @property {(pinId: string, overrides?: { indentLevel?: number, path?: Set }) => Array} emitBranch Emits all branches reachable from the supplied exec output pin. + * @property {(nodeId: string, indentLevel: number, path: Set) => Array} emitExecChain Walks the exec chain starting from the provided node identifier. + * @property {(pinId: string, fallback: string) => string} resolveValueInput Resolves a value input for the active node. + * @property {(pinId: string) => Array<{nodeId: string, pinId: string}>} findExecTargets Finds exec targets for the supplied pin. + * @property {(level?: number) => string} indent Produces indentation whitespace for the supplied level (defaults to the current level). + * @property {(name: string) => string} sanitizeIdentifier Normalizes identifiers for Lua emission. + * @property {(operator: string) => string} sanitizeOperator Normalizes comparison operators for Lua. + * @property {(kind: string, value: unknown) => string} formatLiteral Formats a literal value for Lua output. + */ + +/** + * @typedef {Object} ValueContext + * @property {BlueprintNode} node Active node instance. + * @property {(pinId: string, fallback: string) => string} resolveValueInput Resolves a value input for the active node. + * @property {(name: string) => string} sanitizeIdentifier Normalizes identifiers for Lua emission. + * @property {(operator: string) => string} sanitizeOperator Normalizes comparison operators for Lua. + * @property {(kind: string, value: unknown) => string} formatLiteral Formats a literal value for Lua output. + */ + +/** + * @typedef {Object} NodeBehavior + * @property {boolean} [isEntryPoint] Indicates whether the node should be treated as the primary entry point. + * @property {('_init'|'_update'|'_draw'|'_update60')} [eventName] Optional PICO-8 lifecycle callback to generate. + * @property {(context: ExecContext) => Array} [emitExec] Generates Lua statements for exec-oriented nodes. + * @property {(context: ValueContext) => string} [evaluateValue] Generates a Lua expression for value-oriented nodes. + */ + +/** + * @typedef {Object} NodeModule + * @property {NodeDefinition} definition Node metadata exposed to the editor. + * @property {NodeBehavior} [behavior] Optional Lua emission hooks for the node. + */ + +/** + * Creates a node module ensuring a behavior object is always present. + * + * @param {NodeDefinition} definition Node metadata. + * @param {NodeBehavior} [behavior] Optional behavior hooks. + * @returns {NodeModule} + */ +export const createNodeModule = (definition, behavior = {}) => ({ + definition, + behavior, +}); + +export {}; diff --git a/scripts/tools/generateOutline.js b/scripts/tools/generateOutline.js new file mode 100644 index 0000000..e531bb7 --- /dev/null +++ b/scripts/tools/generateOutline.js @@ -0,0 +1,234 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +/** + * @typedef {{ + * kind: 'class'|'method'|'function'|'arrow', + * name: string, + * line: number, + * className?: string, + * accessor?: 'get'|'set', + * isStatic?: boolean + * }} OutlineEntry + */ + +/** + * Entry point for the CLI. + */ +function main() { + const [, , inputArg, outputArg] = process.argv; + if (!inputArg || inputArg === "--help" || inputArg === "-h") { + printUsage(inputArg ? 0 : 1); + return; + } + + const inputPath = path.resolve(process.cwd(), inputArg); + if (!fs.existsSync(inputPath) || !fs.statSync(inputPath).isFile()) { + console.error(`Input file not found: ${inputArg}`); + process.exit(1); + } + + const suggestedName = `${path.basename( + inputPath, + path.extname(inputPath) + )}.outline.txt`; + const outputPath = path.resolve(process.cwd(), outputArg || suggestedName); + + const source = fs.readFileSync(inputPath, "utf8"); + const outline = collectOutline(source); + const formatted = formatOutline(outline, inputPath); + fs.writeFileSync(outputPath, formatted.join("\n"), "utf8"); + console.log(`Outline written to ${path.relative(process.cwd(), outputPath)}`); +} + +/** + * Prints usage details for the CLI. + * + * @param {number} exitCode Process exit code. + */ +function printUsage(exitCode) { + console.log( + "Usage: node scripts/tools/generateOutline.js [output.txt]" + ); + console.log("Creates a simple outline with line numbers and function names."); + process.exit(exitCode); +} + +/** + * Builds an outline of the provided source text. + * + * @param {string} source Raw JavaScript source. + * @returns {Array} Ordered outline entries. + */ +function collectOutline(source) { + const lines = source.split(/\r?\n/); + /** @type {Array} */ + const entries = []; + /** @type {Array<{ name: string, bodyDepth: number }>} */ + const classStack = []; + let braceDepth = 0; + + for (let index = 0; index < lines.length; index += 1) { + const rawLine = lines[index]; + const trimmed = rawLine.trim(); + const lineNumber = index + 1; + + if (!trimmed) { + braceDepth = updateBraceDepth(braceDepth, rawLine); + if ( + classStack.length && + braceDepth < classStack[classStack.length - 1].bodyDepth + ) { + classStack.pop(); + } + continue; + } + + const classMatch = trimmed.match(/^(?:export\s+)?class\s+([A-Za-z0-9_$]+)/); + if (classMatch) { + const className = classMatch[1]; + entries.push({ kind: "class", name: className, line: lineNumber }); + classStack.push({ name: className, bodyDepth: braceDepth + 1 }); + } + + const inClass = + classStack.length > 0 && + braceDepth >= classStack[classStack.length - 1].bodyDepth; + + if (inClass) { + const methodMatch = trimmed.match( + /^(?:(static)\s+)?(?:async\s+)?(?:(get|set)\s+)?([A-Za-z0-9_$#]+)\s*\([^;]*\)\s*\{/ + ); + if (methodMatch && !isControlKeyword(methodMatch[3])) { + entries.push({ + kind: "method", + name: methodMatch[3], + line: lineNumber, + className: classStack[classStack.length - 1].name, + accessor: methodMatch[2] || undefined, + isStatic: Boolean(methodMatch[1]), + }); + } + } + + const functionMatch = trimmed.match( + /^(?:export\s+)?(?:async\s+)?function\s+([A-Za-z0-9_$]+)\s*\(/ + ); + if (functionMatch) { + entries.push({ + kind: "function", + name: functionMatch[1], + line: lineNumber, + }); + } + + const arrowMatch = trimmed.match( + /^(?:export\s+)?(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*(?:async\s+)?\([^=]*\)\s*=>\s*\{/ + ); + if (arrowMatch) { + entries.push({ kind: "arrow", name: arrowMatch[1], line: lineNumber }); + } + + braceDepth = updateBraceDepth(braceDepth, rawLine); + while ( + classStack.length && + braceDepth < classStack[classStack.length - 1].bodyDepth + ) { + classStack.pop(); + } + } + + return entries; +} + +/** + * Determines whether the supplied identifier is a control-flow keyword. + * + * @param {string} name Identifier candidate. + * @returns {boolean} True when the name represents a keyword. + */ +function isControlKeyword(name) { + return ( + name === "if" || + name === "for" || + name === "while" || + name === "switch" || + name === "catch" || + name === "with" + ); +} + +/** + * Updates the running brace depth for the outline traversal. + * + * @param {number} current Existing brace depth. + * @param {string} line Source line. + * @returns {number} New brace depth. + */ +function updateBraceDepth(current, line) { + const open = countChar(line, "{"); + const close = countChar(line, "}"); + let next = current + open - close; + if (next < 0) { + next = 0; + } + return next; +} + +/** + * Counts the occurrences of a character within the provided string. + * + * @param {string} line Text to inspect. + * @param {string} char Target character. + * @returns {number} Occurrence count. + */ +function countChar(line, char) { + let count = 0; + for (let i = 0; i < line.length; i += 1) { + if (line[i] === char) { + count += 1; + } + } + return count; +} + +/** + * Converts outline data into printable strings. + * + * @param {Array} outline Ordered outline entries. + * @param {string} inputPath Original file path. + * @returns {Array} Lines ready for file output. + */ +function formatOutline(outline, inputPath) { + const header = [`Outline for ${path.relative(process.cwd(), inputPath)}`, ""]; + const body = outline.map((entry) => { + const label = buildLabel(entry); + return `${entry.line.toString().padStart(5, " ")} : ${label}`; + }); + return [...header, ...body]; +} + +/** + * Builds a human-friendly label for an outline entry. + * + * @param {OutlineEntry} entry Outline entry. + * @returns {string} Formatted label. + */ +function buildLabel(entry) { + if (entry.kind === "class") { + return `class ${entry.name}`; + } + if (entry.kind === "method" && entry.className) { + const prefix = entry.isStatic ? "static " : ""; + const accessor = entry.accessor ? `${entry.accessor} ` : ""; + return `method ${entry.className}.${prefix}${accessor}${entry.name}`; + } + if (entry.kind === "function") { + return `function ${entry.name}`; + } + return `arrow ${entry.name}`; +} + +main(); diff --git a/scripts/ui/BlueprintWorkspace.js b/scripts/ui/BlueprintWorkspace.js new file mode 100644 index 0000000..ea7bb12 --- /dev/null +++ b/scripts/ui/BlueprintWorkspace.js @@ -0,0 +1,6562 @@ +import { NodeGraph } from "../core/NodeGraph.js"; +import { WorkspaceGeometry } from "./workspace/WorkspaceGeometry.js"; +import { WorkspaceDragManager } from "./workspace/WorkspaceDragManager.js"; +import { WorkspaceHistoryManager } from "./workspace/WorkspaceHistoryManager.js"; +import { WorkspaceClipboardManager } from "./workspace/WorkspaceClipboardManager.js"; +import { WorkspacePersistenceManager } from "./workspace/WorkspacePersistenceManager.js"; +import { WorkspaceMarqueeManager } from "./workspace/WorkspaceMarqueeManager.js"; +import { WorkspaceContextMenuManager } from "./workspace/WorkspaceContextMenuManager.js"; +import { WorkspacePaletteManager } from "./workspace/WorkspacePaletteManager.js"; +import { WorkspaceDropManager } from "./workspace/WorkspaceDropManager.js"; +import { WorkspaceInlineEditorManager } from "./workspace/WorkspaceInlineEditorManager.js"; +import { WorkspaceNodeRenderer } from "./workspace/WorkspaceNodeRenderer.js"; +import { WorkspaceNavigationManager } from "./workspace/WorkspaceNavigationManager.js"; +import { renderEventList } from "./workspace/renderEventList.js"; +import { renderVariableList } from "./workspace/renderVariableList.js"; +import { + WORKSPACE_SHORTCUTS, +} from "./WorkspaceSpawnShortcuts.js"; + +/** + * @typedef {'any'|'number'|'string'|'boolean'|'table'} VariableType + */ + +/** @type {Array<{ value: VariableType, label: string }>} */ +const VARIABLE_TYPE_DEFINITIONS = [ + { value: "any", label: "Any" }, + { value: "number", label: "Number" }, + { value: "string", label: "String" }, + { value: "boolean", label: "Boolean" }, + { value: "table", label: "Table" }, +]; + +const SUPPORTED_VARIABLE_TYPES = new Set( + VARIABLE_TYPE_DEFINITIONS.map((entry) => entry.value) +); + +/** @typedef {import('./WorkspaceSpawnShortcuts.js').WorkspaceShortcut} WorkspaceShortcut */ +/** @typedef {import('./WorkspaceSpawnShortcuts.js').WorkspaceShortcutModifier} WorkspaceShortcutModifier */ + +/** + * @typedef {{ kind: 'variable', variableId: string } | { kind: 'custom_event_parameter', nodeId: string, parameterId: string } | { kind: 'local_variable', nodeId: string }} TypeDropdownContext + */ + +/** + * @typedef {{ id: string, key: string, value: string }} TableDefaultEntry + */ + +/** + * @typedef {string | number | boolean | null | Array} VariableDefaultValue + */ + +/** + * @typedef {{ id: string, name: string, type: VariableType, defaultValue: VariableDefaultValue }} WorkspaceVariable + */ + +/** + * @typedef {{ id: string, name: string, type: VariableType, optional: boolean }} CustomEventParameter + */ + +/** + * @typedef {'default'|'project'} InspectorView + */ + +/** + * @typedef {{ key: string, definitionId: string, button: number, modifiers: Set }} NormalizedSpawnShortcut + */ + +/** + * @typedef {{ key: string, command: string, modifiers: Set }} NormalizedCommandShortcut + */ + +/** + * @typedef {Object} WorkspaceOptions + * @property {HTMLElement} workspaceElement Root workspace container. + * @property {HTMLElement} nodeLayer DOM node layer container. + * @property {SVGElement} connectionLayer SVG connection layer. + * @property {HTMLElement} paletteList Palette list element. + * @property {HTMLInputElement} paletteSearch Palette search input element. + * @property {HTMLElement} paletteElement Palette container element. + * @property {HTMLElement} inspectorContent Inspector root element. + * @property {HTMLButtonElement} duplicateNodeButton Duplicate action button. + * @property {HTMLButtonElement} deleteNodeButton Delete action button. + * @property {LuaGenerator} generator Lua generator responsible for code output. + * @property {NodeRegistry} registry Registry containing available node definitions. + * @property {HTMLUListElement} eventList Event summary list element. + * @property {HTMLUListElement} variableList Variable summary list element. + * @property {HTMLButtonElement} addVariableButton Global variable creation button. + * @property {HTMLButtonElement} projectSettingsButton Project settings toggle. + * @property {HTMLButtonElement} paletteToggleButton Palette toggle control. + * @property {HTMLElement} appBodyElement Layout container hosting the workspace panes. + * @property {HTMLButtonElement} frameSelectionButton Frame selection control. + */ + +/** + * @typedef {{ type: 'variable', action: 'get'|'set', variableId: string }} VariablePaletteShortcut + */ + +/** + * @typedef {{ type: 'custom_event_call', eventNodeId: string }} CustomEventCallPaletteShortcut + */ + +/** + * @typedef {import('../nodes/nodeTypes.js').NodeDefinition & { + * shortcut?: VariablePaletteShortcut | CustomEventCallPaletteShortcut + * }} PaletteNodeDefinition + */ + +/** @typedef {import('./workspace/WorkspaceHistoryManager.js').SerializedWorkspace} SerializedWorkspace */ +/** @typedef {import('./workspace/WorkspaceHistoryManager.js').WorkspaceSnapshot} WorkspaceSnapshot */ + +const AUTO_CONNECT_DISTANCE = 300; +const DEFAULT_SEQUENCE_BRANCHES = 3; + +/** + * Manages blueprint workspace interactions and DOM rendering. + */ +export class BlueprintWorkspace { + /** + * @param {WorkspaceOptions} options Workspace setup options. + */ + constructor(options) { + this.workspaceElement = options.workspaceElement; + if ( + this.workspaceElement instanceof HTMLElement && + !this.workspaceElement.hasAttribute("tabindex") + ) { + this.workspaceElement.setAttribute("tabindex", "-1"); + } + this.nodeLayer = options.nodeLayer; + this.connectionLayer = options.connectionLayer; + this.paletteList = options.paletteList; + this.paletteSearch = options.paletteSearch; + /** @type {HTMLElement} */ + this.paletteElement = options.paletteElement; + this.inspectorContent = options.inspectorContent; + this.duplicateNodeButton = options.duplicateNodeButton; + this.deleteNodeButton = options.deleteNodeButton; + this.generator = options.generator; + this.registry = options.registry; + /** @type {HTMLUListElement} */ + this.eventListElement = options.eventList; + /** @type {HTMLUListElement} */ + this.variableListElement = options.variableList; + this.addVariableButton = options.addVariableButton; + /** @type {HTMLButtonElement} */ + this.projectSettingsButton = options.projectSettingsButton; + /** @type {HTMLButtonElement} */ + this.frameSelectionButton = options.frameSelectionButton; + /** @type {HTMLButtonElement} */ + this.paletteToggleButton = options.paletteToggleButton; + /** @type {HTMLElement} */ + this.appBodyElement = options.appBodyElement; + /** @type {Set} */ + this.activeShortcutKeys = new Set(); + /** @type {Map<'background', Array>} */ + this.spawnShortcutLookup = this.#normalizeSpawnShortcuts( + WORKSPACE_SHORTCUTS + ); + /** @type {Array} */ + this.commandShortcuts = this.#normalizeCommandShortcuts( + WORKSPACE_SHORTCUTS + ); + + /** @type {number} */ + this.gridSize = WorkspaceGeometry.resolveGridSize(this); + /** @type {{ element: HTMLElement, width: number, height: number, definitionId: string | null } | null} */ + this.paletteDragGhost = null; + /** @type {{ element: HTMLElement, width: number, height: number, nodeId: string | null } | null} */ + this.nodeDragGhost = null; + /** @type {string | null} */ + this.activePaletteDefinitionId = null; + /** @type {Map} */ + this.nodeDragRotation = new Map(); + /** @type {number | null} */ + this.nodeDragRotationDecayFrame = null; + /** @type {number | null} */ + this.connectionRefreshFrame = null; + + this.graph = new NodeGraph(); + /** @type {Map} */ + this.variables = new Map(); + /** @type {Array} */ + this.variableOrder = []; + this.variableCounter = 0; + /** @type {Map} */ + this.nodeElements = new Map(); + /** @type {Map} */ + this.connectionElements = new Map(); + /** @type {{ container: HTMLElement | null, trigger: HTMLButtonElement | null, context: TypeDropdownContext | null, onSelect: ((type: VariableType) => void) | null }} */ + this.typeDropdown = { + container: null, + trigger: null, + context: null, + onSelect: null, + }; + /** @type {Set} */ + this.highlightedPins = new Set(); + this.pendingConnection = null; + /** + * @type {{ startRef: { nodeId: string, pinId: string }, direction: 'input'|'output', kind: string } | null} + */ + this.pendingConnectionSpawn = null; + /** @type {{ variableId: string } | null} */ + this.pendingVariableSpawn = null; + /** @type {Set} */ + this.selectedNodeIds = new Set(); + /** @type {string | undefined} */ + this.selectedNodeId = undefined; + /** @type {string | null} */ + this.selectedVariableId = null; + /** @type {{ variableId: string, input: HTMLInputElement, label: HTMLElement, container: HTMLElement, wasDraggable: boolean } | null} */ + this.activeVariableRename = null; + /** @type {{ nodeId: string, property: string } | null} */ + this.pendingInspectorFocus = null; + /** @type {{ variableId: string, entryId: string, target: 'key' | 'value' } | null} */ + this.pendingVariableInspectorFocus = null; + /** @type {{ use60Fps: boolean }} */ + this.projectSettings = { use60Fps: false }; + /** @type {boolean} */ + this.isPaletteVisible = true; + /** @type {InspectorView} */ + this.inspectorView = "default"; + /** @type {boolean} */ + this.isDraggingNodes = false; + /** @type {boolean} */ + this.isRestoring = false; + /** @type {number} */ + this.workspaceDragDepth = 0; + /** @type {number} */ + this.spawnIndex = 0; + /** @type {string} */ + this.luaOutput = ""; + /** @type {WorkspaceHistoryManager | null} */ + this.historyManager = null; + /** @type {WorkspaceClipboardManager | null} */ + this.clipboardManager = null; + /** @type {WorkspacePersistenceManager | null} */ + this.persistenceManager = null; + /** @type {WorkspacePaletteManager | null} */ + this.paletteManager = null; + /** @type {WorkspaceContextMenuManager | null} */ + this.contextMenuManager = null; + /** @type {WorkspaceDropManager | null} */ + this.dropManager = null; + /** @type {WorkspaceMarqueeManager | null} */ + this.marqueeManager = null; + /** @type {WorkspaceInlineEditorManager | null} */ + this.inlineEditorManager = null; + /** @type {WorkspaceNodeRenderer | null} */ + this.nodeRenderer = null; + /** @type {WorkspaceNavigationManager | null} */ + this.navigationManager = null; + /** @type {WorkspaceDragManager | null} */ + this.dragManager = null; + /** @type {HTMLTemplateElement} */ + this.nodeTemplate = BlueprintWorkspace.#requireTemplate( + "nodeTemplate", + HTMLTemplateElement + ); + /** @type {HTMLTemplateElement} */ + this.pinTemplate = BlueprintWorkspace.#requireTemplate( + "pinTemplate", + HTMLTemplateElement + ); + /** @type {boolean} */ + this.isInitialized = false; + + Object.defineProperty(this, "zoomLevel", { + configurable: true, + enumerable: false, + get: () => this.navigationManager?.getZoomLevel() ?? 1, + set: (value) => { + if (this.navigationManager) { + this.navigationManager.setZoomLevel(value, { silent: true }); + this.navigationManager.updateZoomDisplay(); + } + }, + }); + + Object.defineProperty(this, "workspaceBackgroundOffset", { + configurable: true, + enumerable: false, + get: () => + this.navigationManager?.getBackgroundOffset?.() ?? { x: 0, y: 0 }, + set: (value) => { + if (this.navigationManager) { + this.navigationManager.setBackgroundOffset(value ?? { x: 0, y: 0 }); + } + }, + }); + } + + /** + * Ensures a DOM template with the requested identifier exists. + * + * @template T + * @param {string} id Element identifier. + * @param {new (...args: Array) => T} ctor Expected constructor. + * @returns {T} + */ + static #requireTemplate(id, ctor) { + const element = document.getElementById(id); + if (!(element instanceof ctor)) { + throw new Error(`Missing required template: ${id}`); + } + return element; + } + + /** + * Initializes manager instances, event bindings, and persisted state. + */ + initialize() { + if (this.isInitialized) { + return; + } + + this.dragManager = new WorkspaceDragManager(this); + this.historyManager = new WorkspaceHistoryManager(this); + this.persistenceManager = new WorkspacePersistenceManager(this); + this.clipboardManager = new WorkspaceClipboardManager( + this, + this.historyManager + ); + this.inlineEditorManager = new WorkspaceInlineEditorManager({ + getGraph: () => this.graph, + registry: this.registry, + formatVariableType: (type) => this.#formatVariableType(type), + openLocalVariableTypeDropdown: ( + nodeId, + trigger, + anchor, + currentType, + onSelect + ) => { + const typeOptions = this.#getVariableTypeOptions().filter( + (entry) => entry.value !== "any" + ); + this.#openTypeDropdown({ + context: { kind: "local_variable", nodeId }, + trigger, + anchor, + currentType, + onSelect, + placement: "top", + options: typeOptions, + }); + }, + closeTypeDropdown: () => this.#closeTypeDropdown(), + isTypeDropdownOpen: (context) => this.#isTypeDropdownOpen(context), + }); + this.contextMenuManager = new WorkspaceContextMenuManager(this); + this.paletteManager = new WorkspacePaletteManager(this); + this.dropManager = new WorkspaceDropManager(this); + this.marqueeManager = new WorkspaceMarqueeManager(this); + this.navigationManager = new WorkspaceNavigationManager(this, { + renderConnections: () => this.#renderConnections(), + schedulePersist: () => this.persistenceManager.schedulePersist(), + }); + this.nodeRenderer = new WorkspaceNodeRenderer(this, { + inlineEditorManager: this.inlineEditorManager, + applySelectionState: () => this.#applySelectionState(), + selectNode: (nodeId) => this.selectNode(nodeId), + startNodeDrag: (event, nodeId) => + this.dragManager?.startNodeDrag(event, nodeId), + refreshVariableNode: (nodeId) => this.#refreshVariableNode(nodeId), + refreshLocalVariableNode: (nodeId) => + this.#refreshLocalVariableNode(nodeId), + refreshCustomEventNode: (nodeId) => this.#refreshCustomEventNode(nodeId), + refreshCustomEventCallNode: (nodeId) => + this.#refreshCustomEventCallNode(nodeId), + renderConnections: () => this.#renderConnections(), + shouldHighlightPin: (nodeId, pinId, direction) => + this.#shouldHighlightHover(nodeId, pinId, direction), + beginConnection: (event, nodeId, pinId, direction) => + this.#beginConnection(event, nodeId, pinId, direction), + finalizeConnection: (nodeId, pinId, direction) => + this.#finalizeConnection(nodeId, pinId, direction), + getPinElement: (nodeId, pinId, direction) => + this.#getPinElement(nodeId, pinId, direction), + }); + + this.contextMenuManager.initialize(); + this.paletteManager.initialize(); + this.dropManager.initialize(); + this.marqueeManager.ensureElement(); + + this.navigationManager.updateZoomDisplay(); + this.navigationManager.setBackgroundOffset({ x: 0, y: 0 }); + + if (typeof this.generator.setProjectSettings === "function") { + this.generator.setProjectSettings({ ...this.projectSettings }); + } + + this.#bindGraphEvents(); + this.#bindUiEvents(); + this.#applyPaletteVisibility(this.isPaletteVisible); + + const restored = this.persistenceManager.restoreFromStorage(); + if (!restored) { + this.#renderOverview(); + this.#renderInspectorState(); + this.#refreshLuaOutput(); + this.navigationManager.updateZoomDisplay(); + } + + this.historyManager.initialize(); + this.isInitialized = true; + } + + /** + * Reverts the most recent checkpoint. + */ + undo() { + this.historyManager.undo(); + } + + /** + * Re-applies the most recently undone checkpoint. + */ + redo() { + this.historyManager.redo(); + } + + /** + * Produces Lua output using the current generator and graph state. + */ + exportLua() { + return this.#refreshLuaOutput(); + } + + /** + * Subscribes to graph lifecycle changes. + */ + #bindGraphEvents() { + this.graph.addEventListener("nodeschanged", (event) => { + const customEvent = /** @type {CustomEvent} */ (event); + const detail = customEvent.detail; + if (detail?.type === "add" && detail.node) { + if (detail.node.type === "custom_event") { + const currentName = + typeof detail.node.properties.name === "string" + ? detail.node.properties.name + : ""; + const uniqueName = this.#generateUniqueCustomEventName( + currentName || "CustomEvent", + detail.node.id + ); + if (uniqueName !== currentName) { + this.graph.setNodeProperty(detail.node.id, "name", uniqueName); + } else { + this.#refreshCustomEventNode(detail.node.id); + } + if ( + !this.pendingInspectorFocus || + this.pendingInspectorFocus.nodeId !== detail.node.id + ) { + this.pendingInspectorFocus = { + nodeId: detail.node.id, + property: "name", + }; + } + if ( + this.selectedNodeId && + this.graph.nodes.get(this.selectedNodeId)?.type === + "call_custom_event" + ) { + this.#refreshInspectorIfActive(this.selectedNodeId, "eventId"); + } + } else if (detail.node.type === "call_custom_event") { + this.#refreshCustomEventCallNode(detail.node.id); + if ( + !this.pendingInspectorFocus || + this.pendingInspectorFocus.nodeId !== detail.node.id + ) { + this.pendingInspectorFocus = { + nodeId: detail.node.id, + property: "eventId", + }; + } + } else if (detail.node.type === "sequence") { + this.#refreshSequenceNode(detail.node.id); + } + } else if (detail?.type === "remove" && detail.nodeId) { + this.#handleCustomEventRemoval(detail.nodeId); + } + + this.#renderConnections(); + this.#refreshLuaOutput(); + this.#renderOverview(); + this.nodeRenderer.refreshAllPinConnections(); + this.historyManager.registerMutation("nodeschanged"); + this.persistenceManager.schedulePersist(); + }); + + this.graph.addEventListener("nodepositionchanged", () => { + if (this.isDraggingNodes) { + this.#scheduleConnectionRefresh(); + } else { + this.#flushConnectionRefresh(); + } + this.#refreshLuaOutput(); + this.historyManager.registerMutation("nodepositionchanged"); + this.persistenceManager.schedulePersist(); + }); + + this.graph.addEventListener("nodepropertychanged", (event) => { + const customEvent = /** @type {CustomEvent} */ (event); + const detail = customEvent.detail ?? {}; + const nodeId = detail.nodeId; + const key = detail.key; + const value = detail.value; + + let connectionsMutated = false; + const nodesToRefreshConnections = new Set(); + + if (nodeId) { + this.inlineEditorManager.syncNode(nodeId); + const node = this.graph.nodes.get(nodeId); + + if (node?.type === "custom_event") { + if (key === "name") { + const input = typeof value === "string" ? value : ""; + const trimmed = input.trim(); + if (trimmed !== input) { + this.graph.setNodeProperty(nodeId, "name", trimmed); + return; + } + + const ensured = trimmed || "CustomEvent"; + const uniqueName = this.#generateUniqueCustomEventName( + ensured, + nodeId + ); + if (uniqueName !== ensured) { + this.graph.setNodeProperty(nodeId, "name", uniqueName); + return; + } + + const mutated = this.#refreshCustomEventNode(nodeId); + connectionsMutated = mutated || connectionsMutated; + this.#refreshInspectorIfActive(nodeId, "name"); + } else if (key === "parameters") { + const mutated = this.#refreshCustomEventNode(nodeId); + connectionsMutated = mutated || connectionsMutated; + this.#refreshInspectorIfActive(nodeId); + } else if (key === "parameterCounter") { + // Counter adjustments are handled indirectly by parameter updates. + } else { + const mutated = this.#refreshCustomEventNode(nodeId); + connectionsMutated = mutated || connectionsMutated; + } + } else if (node?.type === "call_custom_event") { + if (key === "eventId") { + const eventId = typeof value === "string" ? value : ""; + const eventNode = eventId ? this.graph.nodes.get(eventId) : null; + + if (eventId && (!eventNode || eventNode.type !== "custom_event")) { + if (eventId !== "") { + this.graph.setNodeProperty(nodeId, "eventId", ""); + } + return; + } + + const mutated = this.#refreshCustomEventCallNode(nodeId); + connectionsMutated = mutated || connectionsMutated; + this.#refreshInspectorIfActive(nodeId, "eventId"); + } else if (key === "arguments") { + const mutated = this.#refreshCustomEventCallNode(nodeId); + connectionsMutated = mutated || connectionsMutated; + this.#refreshInspectorIfActive(nodeId, "arguments"); + } else { + const mutated = this.#refreshCustomEventCallNode(nodeId); + connectionsMutated = mutated || connectionsMutated; + } + } else if (node?.type === "sequence") { + const mutated = this.#refreshSequenceNode(nodeId); + if (mutated) { + connectionsMutated = true; + nodesToRefreshConnections.add(nodeId); + } + if (key === "branches") { + this.#refreshInspectorIfActive(nodeId); + } + } else { + const variableMutated = this.#refreshVariableNode(nodeId); + if (variableMutated) { + connectionsMutated = true; + nodesToRefreshConnections.add(nodeId); + } + const localMutated = this.#refreshLocalVariableNode(nodeId); + if (localMutated) { + connectionsMutated = true; + nodesToRefreshConnections.add(nodeId); + } + } + } + + this.#refreshLuaOutput(); + this.#renderOverview(); + if (connectionsMutated) { + this.#renderConnections(); + nodesToRefreshConnections.forEach((id) => + this.nodeRenderer.updatePinConnectionsForNode(id) + ); + } + this.historyManager.registerMutation("nodepropertychanged"); + this.persistenceManager.schedulePersist(); + }); + + this.graph.addEventListener("connectionschanged", (event) => { + const customEvent = /** @type {CustomEvent} */ (event); + const detail = customEvent.detail; + /** @type {Set} */ + const nodesToRefresh = new Set(); + + const addNode = (nodeId) => { + if (typeof nodeId === "string" && nodeId.length) { + nodesToRefresh.add(nodeId); + } + }; + + if (detail?.connection) { + addNode(detail.connection.from.nodeId); + addNode(detail.connection.to.nodeId); + } + + if (detail?.ref) { + addNode(detail.ref.nodeId); + } + + if (Array.isArray(detail?.nodes)) { + detail.nodes.forEach(addNode); + } + + if (detail?.nodeId) { + addNode(detail.nodeId); + } + + if (nodesToRefresh.size) { + nodesToRefresh.forEach((nodeId) => { + if (this.graph.nodes.has(nodeId)) { + this.inlineEditorManager.syncNode(nodeId); + } + }); + } else { + this.inlineEditorManager.syncAll(); + this.graph.nodes.forEach((node) => nodesToRefresh.add(node.id)); + } + + this.#renderConnections(); + nodesToRefresh.forEach((nodeId) => + this.nodeRenderer.updatePinConnectionsForNode(nodeId) + ); + this.#refreshLuaOutput(); + this.historyManager.registerMutation("connectionschanged"); + this.persistenceManager.schedulePersist(); + }); + } + + /** + * Registers DOM event listeners for interactivity. + */ + #bindUiEvents() { + if (this.paletteToggleButton) { + this.paletteToggleButton.addEventListener("click", () => { + this.#togglePaletteVisibility(); + }); + } + + if (this.frameSelectionButton) { + this.frameSelectionButton.addEventListener("click", () => { + this.#frameSelection(); + }); + } + + if (this.projectSettingsButton) { + this.projectSettingsButton.addEventListener("click", () => { + const wasProjectView = this.inspectorView === "project"; + if (wasProjectView) { + this.#setInspectorView("default"); + this.clearSelection(); + this.#renderInspectorState(); + return; + } + + this.clearSelection(); + this.#setInspectorView("project"); + this.#renderInspectorState(); + }); + } + + if (this.addVariableButton) { + this.addVariableButton.addEventListener("click", () => { + this.#createGlobalVariable(); + }); + } + + this.workspaceElement.addEventListener( + "wheel", + (event) => { + if (event.defaultPrevented) { + return; + } + + const rawTarget = /** @type {EventTarget | null} */ (event.target); + if ( + rawTarget instanceof Node && + this.contextMenuManager?.isTargetInside(rawTarget) + ) { + return; + } + + if (!Number.isFinite(event.deltaY) || event.deltaY === 0) { + return; + } + + const rect = this.workspaceElement.getBoundingClientRect(); + const pointer = { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }; + + if (pointer.x < 0 || pointer.y < 0 || pointer.x > rect.width || pointer.y > rect.height) { + return; + } + + event.preventDefault(); + const direction = event.deltaY < 0 ? 1 : -1; + this.navigationManager.zoomAt(pointer, direction, { + clientX: event.clientX, + clientY: event.clientY, + }); + }, + { passive: false } + ); + + this.workspaceElement.addEventListener("pointerdown", (event) => { + const rawTarget = /** @type {EventTarget | null} */ (event.target); + if (rawTarget instanceof Element) { + const interactive = rawTarget.closest( + "input, textarea, select, button, [contenteditable='true']" + ); + if (!interactive) { + this.#focusWorkspaceSurface({ force: true }); + } + } + + if (this.contextMenuManager.isVisible()) { + if ( + !(rawTarget instanceof Node) || + !this.contextMenuManager.isTargetInside(rawTarget) + ) { + this.contextMenuManager.hide(); + } + } + + if (!(rawTarget instanceof Element)) { + return; + } + + if (!this.#isWorkspaceBackgroundTarget(rawTarget)) { + return; + } + + if (event.button === 0) { + const shortcut = this.#matchBackgroundSpawnShortcut(event); + if (shortcut) { + event.preventDefault(); + event.stopPropagation(); + const spawned = this.#spawnShortcutNodeAtPointer(shortcut, event); + if (spawned) { + this.#focusWorkspaceSurface({ force: true }); + } + return; + } + this.clearSelection(); + this.marqueeManager?.beginSelection(event); + } else if (event.button === 2) { + this.navigationManager?.beginPan(event); + } + }); + + this.workspaceElement.addEventListener("contextmenu", (event) => { + if (event.defaultPrevented) { + return; + } + if (this.navigationManager.isPanRecent(200)) { + event.preventDefault(); + return; + } + event.preventDefault(); + this.contextMenuManager.show(event.clientX, event.clientY); + }); + + this.duplicateNodeButton.addEventListener("click", () => { + if (this.selectedNodeIds.size !== 1 || !this.selectedNodeId) { + return; + } + const node = this.graph.nodes.get(this.selectedNodeId); + if (!node) { + return; + } + + const duplicated = this.registry.createNode(node.type, { + id: this.graph.createNodeId(node.type), + position: { x: node.position.x + 40, y: node.position.y + 40 }, + }); + duplicated.properties = { ...node.properties }; + this.graph.addNode(duplicated); + this.nodeRenderer.renderNode(duplicated); + this.selectNode(duplicated.id); + }); + + this.deleteNodeButton.addEventListener("click", () => { + if (!this.selectedNodeIds.size) { + return; + } + const targets = [...this.selectedNodeIds]; + targets.forEach((id) => { + this.removeNode(id); + }); + }); + + document.addEventListener("keydown", (event) => { + const key = event.key; + const normalizedKey = typeof key === "string" ? key.toLowerCase() : ""; + const isModifier = event.ctrlKey || event.metaKey; + + if (normalizedKey && normalizedKey.length === 1) { + this.activeShortcutKeys.add(normalizedKey); + } + + const target = /** @type {EventTarget | null} */ (event.target); + if (target instanceof HTMLElement) { + const tagName = target.tagName; + if ( + target.isContentEditable || + tagName === "INPUT" || + tagName === "TEXTAREA" || + tagName === "SELECT" + ) { + return; + } + } + + if (isModifier) { + const handled = this.#dispatchCommandShortcut(event, normalizedKey); + if (handled) { + return; + } + } + + if (key !== "Delete" && normalizedKey !== "q") { + return; + } + + if (key === "Delete") { + const handled = this.#dispatchCommandShortcut(event, "delete"); + if (!handled) { + if (!this.selectedNodeIds.size) { + return; + } + event.preventDefault(); + const ids = [...this.selectedNodeIds]; + ids.forEach((id) => this.removeNode(id)); + } + return; + } + + if (normalizedKey === "q") { + const connected = this.#tryAutoConnectSelectedNode(); + if (connected) { + event.preventDefault(); + } + } + }); + + document.addEventListener("keyup", (event) => { + const key = event.key; + const normalizedKey = typeof key === "string" ? key.toLowerCase() : ""; + if (normalizedKey && normalizedKey.length === 1) { + this.activeShortcutKeys.delete(normalizedKey); + } + }); + + window.addEventListener("blur", () => { + this.activeShortcutKeys.clear(); + }); + + document.addEventListener("pointerdown", (event) => { + const state = this.typeDropdown; + if (!state.container || !state.trigger) { + return; + } + + const target = /** @type {EventTarget | null} */ (event.target); + if (!(target instanceof Node)) { + this.#closeTypeDropdown(); + return; + } + + if (state.container.contains(target) || state.trigger.contains(target)) { + return; + } + + this.#closeTypeDropdown(); + }); + + const updateClipboardPointer = (event) => { + if (!this.clipboardManager) { + return; + } + const point = WorkspaceGeometry.eventToWorkspacePoint(this, event); + this.clipboardManager.updatePointerPosition(point); + }; + + this.workspaceElement.addEventListener("pointermove", updateClipboardPointer); + this.workspaceElement.addEventListener("pointerdown", updateClipboardPointer); + this.workspaceElement.addEventListener("pointerenter", updateClipboardPointer); + this.workspaceElement.addEventListener("pointerleave", () => { + this.clipboardManager?.clearPointerPosition(); + }); + + this.connectionLayer.addEventListener("pointerdown", (event) => { + this.#handleConnectionPointerDown(event); + }); + } + + /** + * Creates and renders a node of the requested definition. + * + * @param {string} definitionId Node definition identifier. + * @param {{ x: number, y: number }} [position] Optional workspace position. + * @returns {import('../core/BlueprintNode.js').BlueprintNode | null} + */ + addNode(definitionId, position) { + const definition = this.registry.get(definitionId); + if (!definition) { + return null; + } + + if (definition.unique) { + const existing = [...this.graph.nodes.values()].find( + (candidate) => candidate.type === definitionId + ); + if (existing) { + this.setSelectionState([existing.id], existing.id); + const navigator = this.navigationManager; + if (navigator) { + window.requestAnimationFrame(() => { + navigator.frameNodes([existing.id]); + }); + } + return existing; + } + } + + const hasPosition = + position && + Number.isFinite(position.x) && + Number.isFinite(position.y); + const spawnIndex = this.spawnIndex; + const spawnPosition = hasPosition + ? WorkspaceGeometry.snapPositionToGrid(this, position) + : this.#computeSpawnPosition(definitionId, spawnIndex); + + let node; + try { + node = this.registry.createNode(definitionId, { + id: this.graph.createNodeId(definitionId), + position: spawnPosition, + }); + } catch { + return null; + } + + this.spawnIndex = spawnIndex + 1; + + this.graph.addNode(node); + this.renderNode(node); + if (node.type === "sequence") { + this.#refreshSequenceNode(node.id); + } + this.setSelectionState([node.id], node.id); + return node; + } + + /** + * Renders a node within the workspace node layer. + * + * @param {import('../core/BlueprintNode.js').BlueprintNode | null | undefined} node Node to render. + */ + renderNode(node) { + if (!node) { + return; + } + this.nodeRenderer.renderNode(node); + } + + /** + * Removes a specific node from the workspace. + * + * @param {string} nodeId Target node identifier. + */ + removeNode(nodeId) { + if (this.nodeDragGhost?.nodeId === nodeId) { + this.dragManager.removeNodeGhost(); + } + const element = this.nodeElements.get(nodeId); + if (element) { + element.remove(); + this.nodeElements.delete(nodeId); + } + this.inlineEditorManager.removeNode(nodeId); + if ( + this.pendingConnection && + ((this.pendingConnection.source && + this.pendingConnection.source.nodeId === nodeId) || + (this.pendingConnection.target && + this.pendingConnection.target.nodeId === nodeId)) + ) { + this.#cancelPendingConnection(); + } + this.graph.removeNode(nodeId); + if (this.selectedNodeIds.delete(nodeId)) { + if (this.selectedNodeId === nodeId) { + const iterator = this.selectedNodeIds.values().next(); + this.selectedNodeId = iterator.done ? undefined : iterator.value; + } + this.#applySelectionState(); + } + } + + /** + * Clears the current node selection. + */ + clearSelection() { + this.#cancelVariableRename({ commit: true }); + const hadVariable = Boolean(this.selectedVariableId); + if (hadVariable) { + this.selectedVariableId = null; + } + + if (!this.selectedNodeIds.size) { + if (hadVariable) { + this.#highlightVariableSelection(); + this.#renderInspectorState(); + } + return; + } + + this.#setSelection([], undefined); + } + + /** + * Selects a node, updates UI affordances, and renders inspector controls. + * + * @param {string} nodeId Node identifier. + */ + selectNode(nodeId) { + if (!nodeId) { + this.clearSelection(); + return; + } + + if (this.selectedNodeIds.size === 1 && this.selectedNodeId === nodeId) { + return; + } + + const element = this.nodeElements.get(nodeId); + if (!element) { + this.clearSelection(); + return; + } + + this.#setSelection([nodeId], nodeId); + } + + /** + * Applies the supplied selection state and optional primary node designation. + * + * @param {Array} nodeIds Node identifiers to select. + * @param {string | undefined} primaryId Preferred primary selection identifier. + */ + setSelectionState(nodeIds, primaryId) { + this.#setSelection(Array.isArray(nodeIds) ? nodeIds : [], primaryId); + } + + /** + * Frames the active selection or all nodes when nothing is selected. + */ + #frameSelection() { + if (!this.navigationManager) { + return; + } + + const selected = [...this.selectedNodeIds]; + if (selected.length) { + this.navigationManager.frameNodes(selected); + return; + } + + const allIds = []; + this.graph.nodes.forEach((node) => { + allIds.push(node.id); + }); + + if (!allIds.length) { + return; + } + + this.navigationManager.frameNodes(allIds); + } + + /** + * Toggles the palette visibility state. + */ + #togglePaletteVisibility() { + this.#setPaletteVisibility(!this.isPaletteVisible); + } + + /** + * Updates internal palette visibility tracking and applies side effects. + * + * @param {boolean} visible Target palette visibility flag. + */ + #setPaletteVisibility(visible) { + const next = Boolean(visible); + if (this.isPaletteVisible === next) { + this.#applyPaletteVisibility(next); + return; + } + + const shouldFocus = !this.isPaletteVisible && next; + this.isPaletteVisible = next; + this.#applyPaletteVisibility(next, { focusSearch: shouldFocus }); + this.persistenceManager.schedulePersist(); + } + + /** + * Applies palette visibility-related DOM updates. + * + * @param {boolean} visible Whether the palette should be visible. + * @param {{ focusSearch?: boolean }} [options] Behaviour overrides. + */ + #applyPaletteVisibility(visible, options = {}) { + const palette = this.paletteElement; + const button = this.paletteToggleButton; + const appBody = this.appBodyElement; + const { focusSearch = false } = options; + + if (appBody) { + appBody.classList.toggle("app-body--palette-hidden", !visible); + } + + if (palette) { + palette.setAttribute("aria-hidden", visible ? "false" : "true"); + } + + if (button) { + button.classList.toggle("is-active", visible); + button.setAttribute("aria-pressed", visible ? "true" : "false"); + const label = visible ? "Hide palette" : "Show palette"; + button.setAttribute("aria-label", label); + button.title = label; + } + + if (focusSearch && this.paletteSearch) { + window.requestAnimationFrame(() => { + if (this.isPaletteVisible && this.paletteSearch) { + this.paletteSearch.focus(); + this.paletteSearch.select(); + } + }); + } + } + + /** + * Focuses the workspace surface when appropriate. + * + * @param {{ force?: boolean }} [options] Behaviour overrides. + */ + #focusWorkspaceSurface(options = {}) { + const element = this.workspaceElement; + if (!(element instanceof HTMLElement) || typeof element.focus !== "function") { + return; + } + + const { force = false } = options; + const activeElement = /** @type {Element | null} */ (document.activeElement); + if (!force && activeElement && activeElement !== document.body) { + return; + } + + try { + element.focus({ preventScroll: true }); + } catch (_error) { + element.focus(); + } + } + + /** + * Selects a global variable and opens the inspector for editing. + * + * @param {string} variableId Variable identifier. + */ + #selectVariable(variableId) { + this.#cancelVariableRename({ commit: true }); + this.#setInspectorView("default"); + if (!this.variables.has(variableId)) { + return; + } + + if (this.selectedVariableId === variableId && !this.selectedNodeIds.size) { + this.#renderInspectorState(); + return; + } + + this.selectedVariableId = variableId; + this.#setSelection([], undefined); + this.#highlightVariableSelection(); + this.#renderInspectorState(); + } + + /** + * Synchronizes inspector content and overview highlighting for variable selection. + */ + #renderInspectorState() { + if (this.inspectorView === "project") { + this.selectedVariableId = null; + this.inspectorContent.innerHTML = ""; + this.#highlightVariableSelection(); + this.#renderProjectSettingsInspector(); + return; + } + + if (this.selectedNodeIds.size) { + if (this.selectedVariableId) { + this.selectedVariableId = null; + this.#highlightVariableSelection(); + } + return; + } + + if (!this.selectedVariableId) { + this.inspectorContent.innerHTML = ""; + const empty = document.createElement("p"); + empty.className = "inspector-empty"; + empty.textContent = "Select a node or variable to edit."; + this.inspectorContent.appendChild(empty); + this.#highlightVariableSelection(); + return; + } + + const variable = this.variables.get(this.selectedVariableId); + if (!variable) { + this.selectedVariableId = null; + this.#highlightVariableSelection(); + this.inspectorContent.innerHTML = ""; + const empty = document.createElement("p"); + empty.className = "inspector-empty"; + empty.textContent = "Select a node or variable to edit."; + this.inspectorContent.appendChild(empty); + return; + } + + this.#highlightVariableSelection(); + this.#renderVariableInspector(variable); + } + + /** + * Adds viewport-aware positioning and lifecycle management to an inspector tooltip element. + * + * @param {HTMLElement} tooltipElement Tooltip root element containing trigger and content. + */ + #enhanceInspectorTooltip(tooltipElement) { + const trigger = tooltipElement.querySelector(".inspector-tooltip__trigger"); + const content = tooltipElement.querySelector(".inspector-tooltip__content"); + + if ( + !(trigger instanceof HTMLElement) || + !(content instanceof HTMLElement) + ) { + return; + } + + const margin = 12; + const offset = 12; + const arrowPadding = 14; + let rafId = null; + + const clearRaf = () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + }; + + const schedulePosition = () => { + if (rafId !== null) { + return; + } + rafId = window.requestAnimationFrame(position); + }; + + const handleScroll = () => { + if (!tooltipElement.classList.contains("is-active")) { + close(); + return; + } + schedulePosition(); + }; + + const handleResize = () => { + if (!tooltipElement.classList.contains("is-active")) { + return; + } + schedulePosition(); + }; + + const handleDocumentPointerDown = (event) => { + const target = event.target; + if (target instanceof Node && tooltipElement.contains(target)) { + return; + } + close(); + }; + + const close = () => { + if (!tooltipElement.classList.contains("is-active")) { + clearRaf(); + trigger.setAttribute("aria-expanded", "false"); + return; + } + tooltipElement.classList.remove("is-active"); + tooltipElement.dataset.placement = "bottom"; + trigger.setAttribute("aria-expanded", "false"); + clearRaf(); + window.removeEventListener("resize", handleResize); + window.removeEventListener("scroll", handleScroll, true); + document.removeEventListener( + "pointerdown", + handleDocumentPointerDown, + true + ); + }; + + const position = () => { + rafId = null; + if ( + !tooltipElement.isConnected || + !trigger.isConnected || + !content.isConnected + ) { + close(); + return; + } + + const previousTransition = content.style.transition; + const previousTransform = content.style.transform; + content.style.transition = "none"; + content.style.transform = "translate3d(0, 0, 0)"; + const contentRect = content.getBoundingClientRect(); + content.style.transition = previousTransition; + content.style.transform = previousTransform; + + const triggerRect = trigger.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const availableTop = triggerRect.top - margin; + const availableBottom = viewportHeight - triggerRect.bottom - margin; + let placement = "bottom"; + + if ( + availableBottom < contentRect.height + offset && + availableTop > availableBottom + ) { + placement = "top"; + } + + const minLeft = margin; + let maxLeft = viewportWidth - margin - contentRect.width; + if (maxLeft < minLeft) { + maxLeft = minLeft; + } + + let left = + triggerRect.left + triggerRect.width / 2 - contentRect.width / 2; + left = Math.min(Math.max(left, minLeft), maxLeft); + + const minTop = margin; + let maxTop = viewportHeight - margin - contentRect.height; + if (maxTop < minTop) { + maxTop = minTop; + } + + let top = + placement === "top" + ? triggerRect.top - offset - contentRect.height + : triggerRect.bottom + offset; + + top = Math.min(Math.max(top, minTop), maxTop); + + const triggerCenter = triggerRect.left + triggerRect.width / 2; + let arrowOffset = triggerCenter - left; + const arrowMin = arrowPadding; + const arrowMax = Math.max(arrowPadding, contentRect.width - arrowPadding); + arrowOffset = Math.min(Math.max(arrowOffset, arrowMin), arrowMax); + + content.style.setProperty("--tooltip-left", `${Math.round(left)}px`); + content.style.setProperty("--tooltip-top", `${Math.round(top)}px`); + content.style.setProperty( + "--tooltip-arrow-offset", + `${Math.round(arrowOffset)}px` + ); + tooltipElement.dataset.placement = placement; + }; + + const open = () => { + if (tooltipElement.classList.contains("is-active")) { + schedulePosition(); + return; + } + tooltipElement.classList.add("is-active"); + trigger.setAttribute("aria-expanded", "true"); + schedulePosition(); + window.addEventListener("resize", handleResize); + window.addEventListener("scroll", handleScroll, true); + document.addEventListener("pointerdown", handleDocumentPointerDown, true); + }; + + const handlePointerLeave = () => { + close(); + }; + + const handleFocusOut = (event) => { + const focusEvent = /** @type {FocusEvent} */ (event); + const next = focusEvent.relatedTarget; + if (!(next instanceof Node) || !tooltipElement.contains(next)) { + close(); + } + }; + + const handleKeyDown = (event) => { + if (!(event instanceof KeyboardEvent)) { + return; + } + if (event.key === "Escape") { + close(); + if (typeof trigger.focus === "function") { + trigger.focus(); + } + return; + } + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + if (tooltipElement.classList.contains("is-active")) { + close(); + } else { + open(); + } + } + }; + + trigger.setAttribute("aria-haspopup", "true"); + trigger.setAttribute("aria-expanded", "false"); + trigger.addEventListener("focus", open); + tooltipElement.addEventListener("pointerenter", open); + tooltipElement.addEventListener("pointerleave", handlePointerLeave); + tooltipElement.addEventListener("focusout", handleFocusOut); + tooltipElement.addEventListener("keydown", handleKeyDown); + } + + /** + * Renders the project settings inspector view. + */ + #renderProjectSettingsInspector() { + const heading = document.createElement("h3"); + heading.className = "inspector-section-title"; + heading.textContent = "Project Settings"; + this.inspectorContent.appendChild(heading); + + const fpsField = document.createElement("div"); + fpsField.className = "inspector-field inspector-field--checkbox"; + + const fpsLabel = document.createElement("label"); + const fieldId = "projectSetting_fps60"; + fpsLabel.setAttribute("for", fieldId); + + const tooltip = document.createElement("span"); + tooltip.className = "inspector-tooltip"; + + const labelText = document.createElement("span"); + labelText.className = "inspector-tooltip__trigger"; + labelText.textContent = "Run at 60 FPS (_UPDATE60)"; + labelText.setAttribute("tabindex", "0"); + labelText.setAttribute("role", "button"); + tooltip.appendChild(labelText); + + const tooltipContent = document.createElement("div"); + tooltipContent.className = "inspector-tooltip__content"; + tooltipContent.setAttribute("role", "tooltip"); + const tooltipId = `${fieldId}_tooltip`; + tooltipContent.id = tooltipId; + labelText.setAttribute("aria-describedby", tooltipId); + + const tooltipIntro = document.createElement("p"); + tooltipIntro.append("When "); + const tooltipUpdate60 = document.createElement("code"); + tooltipUpdate60.textContent = "_UPDATE60()"; + tooltipIntro.append(tooltipUpdate60, " is defined instead of "); + const tooltipUpdate = document.createElement("code"); + tooltipUpdate.textContent = "_UPDATE()"; + tooltipIntro.append(tooltipUpdate, ", PICO-8 will run in 60fps mode:"); + tooltipContent.appendChild(tooltipIntro); + + const tooltipList = document.createElement("ul"); + tooltipList.className = "inspector-tooltip__list"; + const listItemA = document.createElement("li"); + listItemA.append("both "); + const listCodeA = document.createElement("code"); + listCodeA.textContent = "_UPDATE60()"; + listItemA.append(listCodeA, " and "); + const listCodeB = document.createElement("code"); + listCodeB.textContent = "_DRAW()"; + listItemA.append(listCodeB, " are called at 60fps"); + tooltipList.appendChild(listItemA); + const listItemB = document.createElement("li"); + listItemB.textContent = + "half the PICO-8 CPU is available per frame before dropping down to 30fps"; + tooltipList.appendChild(listItemB); + tooltipContent.appendChild(tooltipList); + + const tooltipNote = document.createElement("p"); + tooltipNote.className = "inspector-tooltip__note"; + tooltipNote.append( + "Note that not all host machines are capable of running at 60fps. Older machines, and / or web versions might also request PICO-8 to run at 30 fps (or 15 fps), even when the PICO-8 CPU is not over capacity. In this case, multiple " + ); + const noteCode = document.createElement("code"); + noteCode.textContent = "_UPDATE60"; + tooltipNote.append(noteCode, " calls are made for every "); + const noteDrawCode = document.createElement("code"); + noteDrawCode.textContent = "_DRAW"; + tooltipNote.append(noteDrawCode, " call in the same way."); + tooltipContent.appendChild(tooltipNote); + + tooltip.appendChild(tooltipContent); + this.#enhanceInspectorTooltip(tooltip); + fpsLabel.appendChild(tooltip); + fpsField.appendChild(fpsLabel); + + const fpsToggle = document.createElement("input"); + fpsToggle.type = "checkbox"; + fpsToggle.id = fieldId; + fpsToggle.checked = Boolean(this.projectSettings.use60Fps); + fpsToggle.addEventListener("change", () => { + this.projectSettings.use60Fps = fpsToggle.checked; + this.#handleProjectSettingsChanged(); + }); + fpsField.appendChild(fpsToggle); + + this.inspectorContent.appendChild(fpsField); + } + + /** + * Updates the active inspector view flag. + * + * @param {InspectorView} view Target inspector view. + */ + #setInspectorView(view) { + if (this.inspectorView === view) { + return; + } + this.inspectorView = view; + this.#updateProjectSettingsToggle(); + } + + /** + * Synchronizes the project settings toggle button appearance/state. + */ + #updateProjectSettingsToggle() { + if (!this.projectSettingsButton) { + return; + } + const isActive = this.inspectorView === "project"; + this.projectSettingsButton.classList.toggle("is-active", isActive); + this.projectSettingsButton.setAttribute( + "aria-pressed", + isActive ? "true" : "false" + ); + } + + /** + * Handles updates to project-level configuration values. + */ + #handleProjectSettingsChanged() { + if (typeof this.generator.setProjectSettings === "function") { + this.generator.setProjectSettings({ ...this.projectSettings }); + } + this.#refreshLuaOutput(); + this.persistenceManager.schedulePersist(); + } + + /** + * Updates the stored default value for a variable and refreshes the inspector. + * + * @param {string} variableId Variable identifier. + * @param {unknown} nextValue Candidate default value. + */ + #setVariableDefault(variableId, nextValue) { + const variable = this.variables.get(variableId); + if (!variable) { + return; + } + + const normalized = this.#normalizeVariableDefault( + variable.type, + nextValue + ); + if (this.#areVariableDefaultsEqual(variable.defaultValue, normalized)) { + return; + } + + variable.defaultValue = normalized; + this.selectedVariableId = variableId; + this.#renderInspectorState(); + this.#refreshLuaOutput(); + this.persistenceManager.schedulePersist(); + } + + /** + * Provides the initial default value for the supplied variable type. + * + * @param {VariableType} type Variable type descriptor. + * @returns {VariableDefaultValue} + */ + #defaultVariableValue(type) { + switch (type) { + case "number": + return 0; + case "string": + return ""; + case "table": + return []; + case "boolean": + return false; + default: + return null; + } + } + + /** + * Normalizes an arbitrary value so it is valid for the requested variable type. + * + * @param {VariableType} type Variable type descriptor. + * @param {unknown} value Candidate default value. + * @returns {VariableDefaultValue} + */ + #normalizeVariableDefault(type, value) { + const numericPattern = + /^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?$/; + + switch (type) { + case "number": { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "boolean") { + return value ? 1 : 0; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) { + return 0; + } + if (numericPattern.test(trimmed)) { + return Number.parseFloat(trimmed); + } + } + return 0; + } + case "boolean": { + if (typeof value === "boolean") { + return value; + } + if (typeof value === "number") { + return value !== 0; + } + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (!normalized) { + return false; + } + if (["true", "1", "yes", "y"].includes(normalized)) { + return true; + } + if (["false", "0", "no", "n"].includes(normalized)) { + return false; + } + } + return Boolean(value); + } + case "string": { + if (value == null) { + return ""; + } + if (Array.isArray(value)) { + return value + .map((entry) => + typeof entry?.value === "string" ? entry.value : "" + ) + .filter((entry) => entry.length) + .join(", "); + } + return String(value); + } + case "table": { + const entries = []; + if (Array.isArray(value)) { + value.forEach((entry) => { + const normalized = this.#normalizeTableDefaultEntry(entry); + if (normalized) { + entries.push(normalized); + } + }); + } else if ( + value && + typeof value === "object" && + Array.isArray(/** @type {{ entries?: Array }} */ (value).entries) + ) { + const payload = /** @type {{ entries: Array }} */ (value); + payload.entries.forEach((entry) => { + const normalized = this.#normalizeTableDefaultEntry(entry); + if (normalized) { + entries.push(normalized); + } + }); + } else if (value != null && value !== "") { + const normalized = this.#normalizeTableDefaultEntry({ value }); + if (normalized) { + entries.push(normalized); + } + } + return entries; + } + default: { + if (value == null) { + return null; + } + if (Array.isArray(value)) { + return value.length ? value[0]?.value ?? null : null; + } + if (typeof value === "boolean" || typeof value === "number") { + return value; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const lowered = trimmed.toLowerCase(); + if (lowered === "true") { + return true; + } + if (lowered === "false") { + return false; + } + if (numericPattern.test(trimmed)) { + return Number.parseFloat(trimmed); + } + return trimmed; + } + return String(value); + } + } + } + + /** + * Normalizes a raw table default entry descriptor. + * + * @param {unknown} entry Candidate entry value. + * @returns {TableDefaultEntry | null} + */ + #normalizeTableDefaultEntry(entry) { + if (entry == null) { + return null; + } + + if (typeof entry === "string") { + return { + id: this.#allocateTableDefaultEntryId(), + key: "", + value: entry.trim(), + }; + } + + if (typeof entry === "number" || typeof entry === "boolean") { + return { + id: this.#allocateTableDefaultEntryId(), + key: "", + value: String(entry), + }; + } + + if (typeof entry === "object" && !Array.isArray(entry)) { + const record = /** @type {{ id?: unknown, key?: unknown, value?: unknown }} */ ( + entry + ); + const id = + typeof record.id === "string" && record.id.trim().length + ? record.id.trim() + : this.#allocateTableDefaultEntryId(); + const key = + typeof record.key === "string" ? record.key.trim() : ""; + let rawValue = record.value; + if (rawValue == null && typeof record.entry === "string") { + rawValue = record.entry; + } + const value = + rawValue == null ? "" : String(rawValue).trim(); + return { id, key, value }; + } + + return null; + } + + /** + * Allocates a unique identifier for a table default entry. + * + * @returns {string} + */ + #allocateTableDefaultEntryId() { + if ( + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function" + ) { + return `tbl_${crypto.randomUUID()}`; + } + return `tbl_${Math.random().toString(36).slice(2, 10)}`; + } + + /** + * Produces a string representation of a variable default for inspector inputs. + * + * @param {WorkspaceVariable} variable Variable descriptor. + * @returns {string} + */ + #formatVariableDefaultForDisplay(variable) { + if (variable.type === "number") { + return typeof variable.defaultValue === "number" + ? String(variable.defaultValue) + : "0"; + } + + if (variable.defaultValue == null) { + return ""; + } + + if (Array.isArray(variable.defaultValue)) { + return variable.defaultValue + .map((entry) => (typeof entry?.value === "string" ? entry.value : "")) + .filter((text) => text.length) + .join(", "); + } + + return String(variable.defaultValue); + } + + /** + * Determines whether two variable defaults are equivalent. + * + * @param {VariableDefaultValue} a Existing value. + * @param {VariableDefaultValue} b Candidate value. + * @returns {boolean} + */ + #areVariableDefaultsEqual(a, b) { + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) { + return false; + } + for (let index = 0; index < a.length; index += 1) { + const left = a[index]; + const right = b[index]; + if (!right) { + return false; + } + if (left.id !== right.id) { + return false; + } + if (left.key !== right.key) { + return false; + } + if (left.value !== right.value) { + return false; + } + } + return true; + } + return Object.is(a, b); + } + + /** + * Collects variable metadata for Lua generation. + * + * @returns {Array<{ id: string, name: string, type: VariableType, defaultValue: VariableDefaultValue }>} + */ + #getGeneratorVariableMetadata() { + return this.variableOrder + .map((variableId) => this.variables.get(variableId)) + .filter((entry) => Boolean(entry)) + .map((entry) => ({ + id: entry.id, + name: entry.name, + type: entry.type, + defaultValue: entry.defaultValue, + })); + } + + /** + * Highlights the active variable in the overview sidebar. + */ + #highlightVariableSelection() { + if (!this.variableListElement) { + return; + } + + const rows = this.variableListElement.querySelectorAll(".overview-item"); + rows.forEach((element) => { + if (!(element instanceof HTMLElement)) { + return; + } + const variableId = element.dataset.variableId ?? ""; + const isSelected = Boolean( + this.selectedVariableId && variableId === this.selectedVariableId + ); + element.classList.toggle("is-selected", isSelected); + if (isSelected) { + element.setAttribute("aria-current", "true"); + element.setAttribute("aria-pressed", "true"); + } else { + element.removeAttribute("aria-current"); + element.setAttribute("aria-pressed", "false"); + } + }); + } + + /** + * Renders inspector controls for the supplied variable entry. + * + * @param {WorkspaceVariable} variable Variable to display in the inspector. + */ + #renderVariableInspector(variable) { + this.inspectorContent.innerHTML = ""; + + const nameField = document.createElement("div"); + nameField.className = "inspector-field"; + const nameLabel = document.createElement("label"); + const nameId = `${variable.id}_name`; + nameLabel.setAttribute("for", nameId); + nameLabel.textContent = "Variable Name"; + nameField.appendChild(nameLabel); + + const nameInput = document.createElement("input"); + nameInput.type = "text"; + nameInput.id = nameId; + nameInput.value = variable.name; + nameInput.placeholder = "Variable"; + nameInput.addEventListener("change", () => { + this.#setVariableName(variable.id, nameInput.value); + }); + nameField.appendChild(nameInput); + this.inspectorContent.appendChild(nameField); + + const typeField = document.createElement("div"); + typeField.className = "inspector-field"; + const typeLabel = document.createElement("label"); + const typeId = `${variable.id}_type`; + typeLabel.setAttribute("for", typeId); + typeLabel.textContent = "Variable Type"; + typeField.appendChild(typeLabel); + + const typeSelect = document.createElement("select"); + typeSelect.id = typeId; + this.#getVariableTypeOptions().forEach((option) => { + const opt = document.createElement("option"); + opt.value = option.value; + opt.textContent = option.label; + typeSelect.appendChild(opt); + }); + typeSelect.value = variable.type; + typeSelect.addEventListener("change", () => { + const next = this.#normalizeVariableType(typeSelect.value); + this.#setVariableType(variable.id, next); + typeSelect.value = next; + }); + typeField.appendChild(typeSelect); + this.inspectorContent.appendChild(typeField); + + const defaultField = document.createElement("div"); + defaultField.className = "inspector-field"; + const defaultLabel = document.createElement("label"); + const defaultId = `${variable.id}_default`; + defaultLabel.setAttribute("for", defaultId); + defaultLabel.textContent = "Default Value"; + defaultField.appendChild(defaultLabel); + + if (variable.type === "boolean") { + const defaultToggle = document.createElement("input"); + defaultToggle.type = "checkbox"; + defaultToggle.id = defaultId; + defaultToggle.checked = Boolean(variable.defaultValue); + defaultToggle.addEventListener("change", () => { + this.#setVariableDefault(variable.id, defaultToggle.checked); + }); + defaultField.appendChild(defaultToggle); + } else if (variable.type === "number") { + const defaultInput = document.createElement("input"); + defaultInput.type = "number"; + defaultInput.id = defaultId; + defaultInput.value = this.#formatVariableDefaultForDisplay(variable); + defaultInput.addEventListener("change", () => { + this.#setVariableDefault(variable.id, defaultInput.value); + }); + defaultField.appendChild(defaultInput); + } else if (variable.type === "table") { + const editor = this.#buildTableDefaultEditor(variable); + defaultField.appendChild(editor); + } else { + const defaultInput = document.createElement("input"); + defaultInput.type = "text"; + defaultInput.id = defaultId; + defaultInput.value = this.#formatVariableDefaultForDisplay(variable); + defaultInput.placeholder = "No default"; + defaultInput.addEventListener("change", () => { + this.#setVariableDefault(variable.id, defaultInput.value); + }); + defaultField.appendChild(defaultInput); + } + + this.inspectorContent.appendChild(defaultField); + } + + /** + * Builds the editable UI for table variable defaults. + * + * @param {WorkspaceVariable} variable Variable descriptor. + * @returns {HTMLElement} + */ + #buildTableDefaultEditor(variable) { + const editor = document.createElement("div"); + editor.className = "table-default-editor"; + + const toolbar = document.createElement("div"); + toolbar.className = "table-default-toolbar"; + + const addButton = document.createElement("button"); + addButton.type = "button"; + addButton.className = "table-default-add"; + addButton.textContent = "+"; + addButton.setAttribute("aria-label", "Add entry"); + addButton.title = "Add entry"; + addButton.addEventListener("click", () => { + this.#addTableDefaultEntry(variable.id); + }); + toolbar.appendChild(addButton); + + editor.appendChild(toolbar); + + const list = document.createElement("div"); + list.className = "table-default-list"; + + const entries = Array.isArray(variable.defaultValue) + ? variable.defaultValue + : []; + + if (!entries.length) { + const empty = document.createElement("p"); + empty.className = "table-default-empty"; + empty.textContent = "No entries yet"; + list.appendChild(empty); + } + + entries.forEach((entry) => { + const row = document.createElement("div"); + row.className = "table-default-row"; + row.dataset.entryId = entry.id; + + const keyInput = document.createElement("input"); + keyInput.type = "text"; + keyInput.placeholder = "Key (optional)"; + keyInput.value = entry.key; + keyInput.dataset.field = "key"; + keyInput.addEventListener("change", () => { + this.#updateTableDefaultEntry(variable.id, entry.id, { + key: keyInput.value, + }); + }); + row.appendChild(keyInput); + + const valueInput = document.createElement("input"); + valueInput.type = "text"; + valueInput.placeholder = "Value"; + valueInput.value = entry.value; + valueInput.dataset.field = "value"; + valueInput.addEventListener("change", () => { + this.#updateTableDefaultEntry(variable.id, entry.id, { + value: valueInput.value, + }); + }); + row.appendChild(valueInput); + + const removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.className = + "overview-item-action overview-item-action--remove"; + removeButton.title = `Delete ${entry.key || entry.value || "entry"}`; + removeButton.setAttribute( + "aria-label", + `Delete ${entry.key || entry.value || "entry"}` + ); + removeButton.addEventListener("click", () => { + this.#removeTableDefaultEntry(variable.id, entry.id); + }); + removeButton.addEventListener("pointerdown", (event) => { + event.stopPropagation(); + }); + row.appendChild(removeButton); + + list.appendChild(row); + }); + + editor.appendChild(list); + + const focusRequest = this.pendingVariableInspectorFocus; + this.pendingVariableInspectorFocus = null; + if (focusRequest && focusRequest.variableId === variable.id) { + requestAnimationFrame(() => { + const selector = `.table-default-row[data-entry-id="${focusRequest.entryId}"] input[data-field="${focusRequest.target}"]`; + const target = editor.querySelector(selector); + if (target instanceof HTMLInputElement) { + target.focus(); + if (typeof target.select === "function") { + target.select(); + } + } + }); + } + + return editor; + } + + /** + * Appends a new table default entry for the supplied variable. + * + * @param {string} variableId Target variable identifier. + */ + #addTableDefaultEntry(variableId) { + const variable = this.variables.get(variableId); + if (!variable || variable.type !== "table") { + return; + } + + const existing = Array.isArray(variable.defaultValue) + ? variable.defaultValue.slice() + : []; + const entry = { + id: this.#allocateTableDefaultEntryId(), + key: "", + value: "", + }; + existing.push(entry); + + this.pendingVariableInspectorFocus = { + variableId, + entryId: entry.id, + target: "value", + }; + + this.#setVariableDefault(variableId, existing); + } + + /** + * Updates a table default entry for the supplied variable. + * + * @param {string} variableId Target variable identifier. + * @param {string} entryId Entry identifier to update. + * @param {{ key?: string, value?: string }} updates Field overrides. + */ + #updateTableDefaultEntry(variableId, entryId, updates) { + const variable = this.variables.get(variableId); + if (!variable || variable.type !== "table") { + return; + } + + const current = Array.isArray(variable.defaultValue) + ? variable.defaultValue + : []; + const next = current.map((entry) => { + if (entry.id !== entryId) { + return entry; + } + const nextKey = + updates.key !== undefined ? String(updates.key) : entry.key; + const nextValue = + updates.value !== undefined ? String(updates.value) : entry.value; + return { ...entry, key: nextKey, value: nextValue }; + }); + + this.#setVariableDefault(variableId, next); + } + + /** + * Removes a table default entry from the supplied variable. + * + * @param {string} variableId Target variable identifier. + * @param {string} entryId Entry identifier slated for removal. + */ + #removeTableDefaultEntry(variableId, entryId) { + const variable = this.variables.get(variableId); + if (!variable || variable.type !== "table") { + return; + } + + const current = Array.isArray(variable.defaultValue) + ? variable.defaultValue + : []; + const next = current.filter((entry) => entry.id !== entryId); + if (next.length === current.length) { + return; + } + + this.pendingVariableInspectorFocus = null; + this.#setVariableDefault(variableId, next); + } + + /** + * Updates the stored name for a variable and refreshes the overview. + * + * @param {string} variableId Variable identifier. + * @param {string} nextName Requested variable name. + */ + #setVariableName(variableId, nextName) { + const variable = this.variables.get(variableId); + if (!variable) { + return; + } + + const previousName = variable.name; + const trimmed = nextName.trim(); + if (variable.name === trimmed) { + return; + } + + variable.name = trimmed; + this.selectedVariableId = variableId; + + const previousNormalized = previousName.trim(); + this.graph.nodes.forEach((node) => { + if (node.type !== "set_var" && node.type !== "get_var") { + return; + } + + const nodeVariableId = + typeof node.properties.variableId === "string" + ? node.properties.variableId + : ""; + if (nodeVariableId === variableId) { + this.graph.setNodeProperty(node.id, "name", trimmed); + return; + } + + const nodeName = + typeof node.properties.name === "string" + ? node.properties.name.trim() + : ""; + if ( + !nodeVariableId && + previousNormalized && + nodeName === previousNormalized + ) { + this.graph.setNodeProperty(node.id, "name", trimmed); + this.graph.setNodeProperty(node.id, "variableId", variableId); + } + }); + + this.#renderOverview(); + this.#highlightVariableSelection(); + this.#renderInspectorState(); + this.#refreshLuaOutput(); + this.persistenceManager.schedulePersist(); + } + + /** + * Updates the stored type for a variable and refreshes dependent UI. + * + * @param {string} variableId Variable identifier. + * @param {VariableType} nextType Next variable type. + */ + #setVariableType(variableId, nextType) { + const variable = this.variables.get(variableId); + const normalizedType = this.#normalizeVariableType(nextType); + if (!variable || variable.type === normalizedType) { + return; + } + + variable.type = normalizedType; + variable.defaultValue = this.#normalizeVariableDefault( + normalizedType, + variable.defaultValue + ); + this.selectedVariableId = variableId; + this.#renderOverview(); + this.#highlightVariableSelection(); + this.#renderInspectorState(); + + const normalizedName = this.#normalizeVariableName(variable.name); + let connectionsMutated = false; + this.graph.nodes.forEach((node) => { + if (!this.#nodeReferencesVariable(node, variableId, normalizedName)) { + return; + } + const mutated = this.#refreshVariableNode(node.id); + connectionsMutated = connectionsMutated || mutated; + }); + if (connectionsMutated) { + this.#renderConnections(); + } + this.#refreshLuaOutput(); + this.persistenceManager.schedulePersist(); + } + + /** + * Removes a global variable from the workspace and clears dependent references. + * + * @param {string} variableId Variable identifier slated for removal. + */ + #deleteVariable(variableId) { + const variable = this.variables.get(variableId); + if (!variable) { + return; + } + + this.#closeTypeDropdown(); + this.#cancelVariableRename({ commit: false }); + + this.variables.delete(variableId); + const orderIndex = this.variableOrder.indexOf(variableId); + if (orderIndex !== -1) { + this.variableOrder.splice(orderIndex, 1); + } + + const fallbackName = variable.name || variable.id; + const normalizedName = this.#normalizeVariableName(variable.name); + this.graph.nodes.forEach((node) => { + if (node.type !== "set_var" && node.type !== "get_var") { + return; + } + const nodeVariableId = + typeof node.properties.variableId === "string" + ? node.properties.variableId + : ""; + if (nodeVariableId === variableId) { + this.graph.setNodeProperty(node.id, "variableId", ""); + this.graph.setNodeProperty(node.id, "name", fallbackName); + return; + } + + if (!nodeVariableId && normalizedName) { + const nodeName = + typeof node.properties.name === "string" + ? node.properties.name.trim() + : ""; + if (nodeName && nodeName === normalizedName) { + this.graph.setNodeProperty(node.id, "name", nodeName); + } + } + }); + + if (this.selectedVariableId === variableId) { + this.selectedVariableId = null; + } + + this.#renderOverview(); + this.#renderInspectorState(); + this.#refreshLuaOutput(); + this.persistenceManager.schedulePersist(); + } + + /** + * Reorders variables within the overview panel based on drag gestures. + * + * @param {string} variableId Variable identifier being repositioned. + * @param {number} targetIndex Desired zero-based insertion index. + */ + #reorderVariable(variableId, targetIndex) { + const currentIndex = this.variableOrder.indexOf(variableId); + if (currentIndex === -1) { + return; + } + + if (this.variableOrder.length <= 1) { + return; + } + + const normalizedIndex = Number.isFinite(targetIndex) + ? Math.max(0, Math.floor(targetIndex)) + : 0; + if (normalizedIndex === currentIndex) { + return; + } + + const [entry] = this.variableOrder.splice(currentIndex, 1); + const insertionIndex = Math.max( + 0, + Math.min(normalizedIndex, this.variableOrder.length) + ); + this.variableOrder.splice(insertionIndex, 0, entry); + this.#renderVariableList(); + this.#highlightVariableSelection(); + this.persistenceManager.schedulePersist(); + } + + /** + * Presents a dropdown menu for updating a variable type. + * + * @param {string} variableId Target variable identifier. + * @param {HTMLButtonElement} trigger Trigger button opening the menu. + * @param {HTMLElement} anchor Element used to anchor the dropdown position. + */ + #openVariableTypeDropdown(variableId, trigger, anchor) { + const variable = this.variables.get(variableId); + if (!variable) { + return; + } + this.#openTypeDropdown({ + context: { kind: "variable", variableId }, + trigger, + anchor, + currentType: variable.type, + onSelect: (nextType) => { + if (nextType !== variable.type) { + this.#setVariableType(variable.id, nextType); + } + }, + }); + } + + /** + * Presents the shared type dropdown with the supplied configuration. + * + * @param {{ + * context: TypeDropdownContext, + * trigger: HTMLButtonElement, + * anchor: HTMLElement, + * currentType: VariableType, + * onSelect: (type: VariableType) => void, + * options?: Array<{ value: VariableType, label: string }>, + * placement?: 'bottom' | 'top', + * }} options Dropdown configuration. + */ + #openTypeDropdown({ + context, + trigger, + anchor, + currentType, + onSelect, + options, + placement = "bottom", + }) { + this.#closeTypeDropdown(); + + const dropdown = document.createElement("div"); + dropdown.className = "variable-type-dropdown"; + dropdown.setAttribute("role", "menu"); + dropdown.tabIndex = -1; + + /** @type {Array} */ + const buttons = []; + + const entries = + Array.isArray(options) && options.length + ? options + : this.#getVariableTypeOptions(); + + entries.forEach((entry) => { + const option = document.createElement("button"); + option.type = "button"; + option.className = "variable-type-option"; + option.dataset.variableType = entry.value; + option.textContent = entry.label; + option.setAttribute("role", "menuitemradio"); + option.setAttribute( + "aria-checked", + entry.value === currentType ? "true" : "false" + ); + option.addEventListener("pointerdown", (event) => { + event.stopPropagation(); + }); + option.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + if (entry.value !== currentType) { + onSelect(entry.value); + } + this.#closeTypeDropdown(); + }); + option.addEventListener("keydown", (event) => { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + const index = buttons.indexOf(option); + if (index === -1) { + return; + } + const delta = event.key === "ArrowDown" ? 1 : -1; + const nextIndex = (index + delta + buttons.length) % buttons.length; + const next = buttons[nextIndex]; + next.focus(); + } else if (event.key === "Escape") { + event.preventDefault(); + this.#closeTypeDropdown({ restoreFocus: true }); + } + }); + buttons.push(option); + dropdown.appendChild(option); + }); + + dropdown.addEventListener("pointerdown", (event) => { + event.stopPropagation(); + }); + + if (placement === "top") { + dropdown.classList.add("is-above"); + } + + anchor.appendChild(dropdown); + trigger.setAttribute("aria-expanded", "true"); + this.typeDropdown = { container: dropdown, trigger, context, onSelect }; + + requestAnimationFrame(() => { + const target = dropdown.querySelector('button[aria-checked="true"]'); + if (target instanceof HTMLButtonElement) { + target.focus(); + } else if (buttons[0]) { + buttons[0].focus(); + } + }); + } + + /** + * Presents the type dropdown for a custom event parameter row. + * + * @param {string} nodeId Custom event node identifier. + * @param {string} parameterId Parameter identifier. + * @param {HTMLButtonElement} trigger Trigger button opening the menu. + * @param {HTMLElement} anchor Element used to anchor the dropdown position. + * @param {(type: VariableType) => void} [afterSelect] Callback fired after a selection is applied. + */ + #openCustomEventParameterTypeDropdown( + nodeId, + parameterId, + trigger, + anchor, + afterSelect + ) { + const node = this.graph.nodes.get(nodeId); + if (!node || node.type !== "custom_event") { + return; + } + const { parameters } = this.#sanitizeCustomEventParameters(node); + const parameter = parameters.find((entry) => entry.id === parameterId); + if (!parameter) { + return; + } + this.#openTypeDropdown({ + context: { kind: "custom_event_parameter", nodeId, parameterId }, + trigger, + anchor, + currentType: parameter.type, + onSelect: (nextType) => { + this.#setCustomEventParameterType(nodeId, parameterId, nextType); + afterSelect?.(nextType); + }, + }); + } + + /** + * Closes the active type dropdown if present. + * + * @param {{ restoreFocus?: boolean }} [options] Behaviour overrides. + */ + #closeTypeDropdown(options = {}) { + const state = this.typeDropdown; + const container = state.container; + const trigger = state.trigger; + if (trigger) { + trigger.setAttribute("aria-expanded", "false"); + } + if (container && container.isConnected) { + container.remove(); + } + if (options.restoreFocus && trigger) { + trigger.focus(); + } + this.typeDropdown = { + container: null, + trigger: null, + context: null, + onSelect: null, + }; + } + + /** + * Begins inline rename mode for a variable list entry. + * + * @param {string} variableId Target variable identifier. + * @param {HTMLElement} labelElement Label element that was clicked. + */ + #startVariableRename(variableId, labelElement) { + this.#closeTypeDropdown(); + const variable = this.variables.get(variableId); + if (!variable) { + return; + } + + const row = labelElement.closest(".overview-item--variable"); + if (!(row instanceof HTMLElement)) { + return; + } + + const parent = labelElement.parentElement; + if (!(parent instanceof HTMLElement)) { + return; + } + + this.#cancelVariableRename({ commit: true }); + + const wasDraggable = Boolean(row.draggable); + row.draggable = false; + + const input = document.createElement("input"); + input.type = "text"; + input.className = "overview-item-rename"; + input.value = variable.name; + input.setAttribute( + "aria-label", + `Rename variable ${variable.name || variable.id}` + ); + input.addEventListener("pointerdown", (event) => { + event.stopPropagation(); + }); + input.addEventListener("click", (event) => { + event.stopPropagation(); + }); + + const finish = (commit) => { + if ( + !this.activeVariableRename || + this.activeVariableRename.input !== input + ) { + return; + } + const value = input.value; + this.#cancelVariableRename({ commit, value }); + }; + + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + finish(true); + } else if (event.key === "Escape") { + event.preventDefault(); + finish(false); + } + }); + input.addEventListener("blur", () => { + finish(true); + }); + + labelElement.hidden = true; + parent.insertBefore(input, labelElement); + + this.activeVariableRename = { + variableId, + input, + label: labelElement, + container: row, + wasDraggable, + }; + + requestAnimationFrame(() => { + input.focus(); + input.select(); + }); + } + + /** + * Cancels the active inline variable rename, optionally committing the value. + * + * @param {{ commit?: boolean, value?: string }} [options] Behaviour overrides. + */ + #cancelVariableRename(options = {}) { + if (!this.activeVariableRename) { + return; + } + + const { input, label, variableId, container, wasDraggable } = + this.activeVariableRename; + const value = + typeof options.value === "string" ? options.value : input.value; + + if (input.isConnected) { + input.remove(); + } + label.hidden = false; + if (container.isConnected) { + container.draggable = wasDraggable; + } + + this.activeVariableRename = null; + + if (options.commit) { + this.#setVariableName(variableId, value); + } + } + + /** + * Applies the provided selection set and designates a primary node. + * + * @param {Array} nodeIds Collection of node identifiers to select. + * @param {string | undefined} primaryId Preferred primary selection identifier. + */ + #setSelection(nodeIds, primaryId) { + this.#cancelVariableRename({ commit: true }); + this.#setInspectorView("default"); + this.contextMenuManager?.hide(); + if (nodeIds.length) { + this.selectedVariableId = null; + } + + this.selectedNodeIds.clear(); + nodeIds.forEach((id) => { + if (this.graph.nodes.has(id)) { + this.selectedNodeIds.add(id); + } + }); + + if (primaryId && this.selectedNodeIds.has(primaryId)) { + this.selectedNodeId = primaryId; + } else { + const iterator = this.selectedNodeIds.values().next(); + this.selectedNodeId = iterator.done ? undefined : iterator.value; + } + + this.#applySelectionState(); + } + + /** + * Synchronizes DOM affordances and inspector content with the active selection. + */ + #applySelectionState() { + this.nodeElements.forEach((element, id) => { + const isSelected = this.selectedNodeIds.has(id); + const isPrimary = isSelected && this.selectedNodeId === id; + element.classList.toggle("is-selected", isSelected); + element.classList.toggle("is-selection-primary", isPrimary); + }); + + this.#raiseSelectedNodes(); + + const selectionCount = this.selectedNodeIds.size; + this.duplicateNodeButton.disabled = selectionCount !== 1; + this.deleteNodeButton.disabled = selectionCount === 0; + + if ( + selectionCount === 1 && + this.selectedNodeId && + this.inspectorView === "default" + ) { + this.#renderInspector(this.selectedNodeId); + } else { + this.inspectorContent.innerHTML = ""; + if (selectionCount > 1) { + const message = document.createElement("p"); + message.className = "inspector-empty"; + message.textContent = "Multiple nodes selected."; + this.inspectorContent.appendChild(message); + } + } + + this.#renderInspectorState(); + } + + /** + * Raises selected nodes above unselected nodes to ensure they render on top. + * + * @returns {void} + */ + #raiseSelectedNodes() { + if (!(this.nodeLayer instanceof HTMLElement)) { + return; + } + + if (!this.selectedNodeIds.size) { + return; + } + + const primaryId = this.selectedNodeId; + + this.selectedNodeIds.forEach((id) => { + if (id === primaryId) { + return; + } + const element = this.nodeElements.get(id); + if ( + element && + element.parentElement === this.nodeLayer && + element.isConnected + ) { + this.nodeLayer.appendChild(element); + } + }); + + if (primaryId && this.selectedNodeIds.has(primaryId)) { + const primaryElement = this.nodeElements.get(primaryId); + if ( + primaryElement && + primaryElement.parentElement === this.nodeLayer && + primaryElement.isConnected + ) { + this.nodeLayer.appendChild(primaryElement); + } + } + } + + /** + * Determines whether the supplied target represents empty workspace space. + * + * @param {Element} target Candidate event target. + * @returns {boolean} + */ + #isWorkspaceBackgroundTarget(target) { + if ( + target === this.workspaceElement || + target === this.nodeLayer || + target === this.connectionLayer || + this.marqueeManager?.isMarqueeElement(target) + ) { + return true; + } + + if (target.closest(".blueprint-node")) { + return false; + } + + if (this.contextMenuManager?.isTargetInside(target)) { + return false; + } + + return this.workspaceElement.contains(target); + } + + /** + * Attempts to match a configured background spawn shortcut for the current pointer event. + * + * @param {PointerEvent} event Pointer event payload. + * @returns {NormalizedSpawnShortcut | null} + */ + #matchBackgroundSpawnShortcut(event) { + const shortcuts = this.spawnShortcutLookup.get("background"); + if (!shortcuts || !shortcuts.length) { + return null; + } + + for (const shortcut of shortcuts) { + if (event.button !== shortcut.button) { + continue; + } + + if (!this.activeShortcutKeys.has(shortcut.key)) { + continue; + } + + if (!this.#validateShortcutModifiers(shortcut.modifiers, event)) { + continue; + } + + return shortcut; + } + + return null; + } + + /** + * Spawns a node tied to a shortcut at the pointer location. + * + * @param {NormalizedSpawnShortcut} shortcut Normalized shortcut configuration. + * @param {PointerEvent} event Pointer event payload. + * @returns {import('../core/BlueprintNode.js').BlueprintNode | null} + */ + #spawnShortcutNodeAtPointer(shortcut, event) { + const workspaceElement = this.workspaceElement; + if (!(workspaceElement instanceof HTMLElement)) { + return null; + } + + const navigator = this.navigationManager; + if (!navigator) { + return null; + } + + const rect = workspaceElement.getBoundingClientRect(); + const screenPoint = { + x: Math.max(0, Math.min(rect.width, event.clientX - rect.left)), + y: Math.max(0, Math.min(rect.height, event.clientY - rect.top)), + }; + const worldPoint = navigator.screenPointToWorld(screenPoint); + return this.addNode(shortcut.definitionId, worldPoint); + } + + /** + * Normalizes configured spawn shortcuts into an indexed map keyed by context. + * + * @param {Array} shortcuts Raw workspace spawn shortcut configuration entries. + * @returns {Map<'background', Array>} + */ + #normalizeSpawnShortcuts(shortcuts) { + /** @type {Map<'background', Array>} */ + const lookup = new Map(); + const allowedModifiers = new Set([ + "shift", + "ctrl", + "alt", + "meta", + ]); + + shortcuts.forEach((entry) => { + if (!entry || typeof entry !== "object") { + return; + } + + const rawKey = + typeof entry.key === "string" ? entry.key.trim().toLowerCase() : ""; + if (!rawKey) { + return; + } + + const context = entry.context || "background"; + if (context !== "background") { + return; + } + + const definitionId = + typeof entry.definitionId === "string" + ? entry.definitionId.trim() + : ""; + if (!definitionId) { + return; + } + + const button = + typeof entry.button === "number" && Number.isFinite(entry.button) + ? entry.button + : 0; + + /** @type {Set} */ + const modifiers = new Set(); + if (Array.isArray(entry.modifiers)) { + entry.modifiers.forEach((modifier) => { + if (typeof modifier !== "string") { + return; + } + const normalizedModifier = modifier.trim().toLowerCase(); + if (allowedModifiers.has(normalizedModifier)) { + modifiers.add( + /** @type {WorkspaceShortcutModifier} */ (normalizedModifier) + ); + } + }); + } + + const shortcut = { + key: rawKey, + definitionId, + button, + modifiers, + }; + const bucket = lookup.get("background"); + if (bucket) { + bucket.push(shortcut); + } else { + lookup.set("background", [shortcut]); + } + }); + + return lookup; + } + + /** + * Checks whether the supplied pointer event satisfies the expected modifier state. + * + * @param {Set} required Required modifier keys. + * @param {PointerEvent} event Pointer event payload. + * @returns {boolean} + */ + #validateShortcutModifiers(required, event) { + return this.#validateKeyboardModifiers(required, event); + } + + /** + * Attempts to execute a command shortcut bound to the provided key event. + * + * @param {KeyboardEvent} event Keyboard event payload. + * @param {string} normalizedKey Lowercase key identifier from the event. + * @returns {boolean} + */ + #dispatchCommandShortcut(event, normalizedKey) { + if (!this.commandShortcuts.length) { + return false; + } + + const handlers = { + copy: () => this.clipboardManager.copySelection(), + cut: () => this.clipboardManager.cutSelection(), + paste: () => { + this.clipboardManager.paste(); + return true; + }, + undo: () => { + this.undo(); + return true; + }, + redo: () => { + this.redo(); + return true; + }, + delete: () => { + if (!this.selectedNodeIds.size) { + return false; + } + const ids = [...this.selectedNodeIds]; + ids.forEach((id) => this.removeNode(id)); + return true; + }, + }; + + for (const shortcut of this.commandShortcuts) { + if (shortcut.key !== normalizedKey) { + continue; + } + + if (!this.#validateKeyboardModifiers(shortcut.modifiers, event)) { + continue; + } + + const handler = handlers[shortcut.command]; + if (!handler) { + continue; + } + + const handled = handler(); + if (handled) { + event.preventDefault(); + return true; + } + } + + return false; + } + + /** + * Normalizes command shortcuts from configuration. + * + * @param {Array} shortcuts Raw shortcut configuration entries. + * @returns {Array} + */ + #normalizeCommandShortcuts(shortcuts) { + /** @type {Array} */ + const result = []; + const allowedCommands = new Set([ + "copy", + "cut", + "paste", + "undo", + "redo", + "delete", + ]); + + shortcuts.forEach((entry) => { + if (!entry || typeof entry !== "object") { + return; + } + + if (entry.action !== "command") { + return; + } + + const rawKey = + typeof entry.key === "string" ? entry.key.trim().toLowerCase() : ""; + if (!rawKey) { + return; + } + + const context = entry.context || "workspace"; + if (context !== "workspace") { + return; + } + + const command = + typeof entry.command === "string" + ? entry.command.trim().toLowerCase() + : ""; + if (!command || !allowedCommands.has(command)) { + return; + } + + /** @type {Set} */ + const modifiers = new Set(); + if (Array.isArray(entry.modifiers)) { + entry.modifiers.forEach((modifier) => { + if (typeof modifier !== "string") { + return; + } + const normalizedModifier = modifier.trim().toLowerCase(); + switch (normalizedModifier) { + case "shift": + case "ctrl": + case "alt": + case "meta": + modifiers.add( + /** @type {WorkspaceShortcutModifier} */ (normalizedModifier) + ); + break; + default: + break; + } + }); + } + + result.push({ + key: rawKey, + command, + modifiers, + }); + }); + + return result; + } + + /** + * Validates modifier keys for keyboard-driven shortcuts. + * + * @param {Set} required Required modifier keys. + * @param {KeyboardEvent | PointerEvent} event Native event payload. + * @returns {boolean} + */ + #validateKeyboardModifiers(required, event) { + const modifierState = { + shift: Boolean(event.shiftKey), + ctrl: Boolean(event.ctrlKey), + alt: Boolean(event.altKey), + meta: Boolean(event.metaKey), + }; + + /** @type {Array} */ + const modifierKeys = ["shift", "ctrl", "alt", "meta"]; + + for (const modifier of modifierKeys) { + const expected = required.has(modifier); + const actual = modifierState[modifier]; + if (expected !== actual) { + return false; + } + } + + return true; + } + + /** + * Renders the overview sidebar lists for events and variables. + */ + #renderOverview() { + this.#renderEventList(); + this.#renderVariableList(); + if (this.paletteManager) { + this.paletteManager.render(this.paletteSearch.value); + } + } + + /** + * Creates a new global variable node and focuses it. + */ + #createGlobalVariable() { + const variable = this.#registerVariable(); + this.selectedVariableId = variable.id; + this.#renderOverview(); + this.#highlightVariableSelection(); + this.#renderInspectorState(); + this.#refreshLuaOutput(); + + if (this.variableListElement) { + const target = this.variableListElement.querySelector( + `button.overview-item[data-variable-id="${variable.id}"]` + ); + if (target instanceof HTMLButtonElement) { + target.focus(); + } + } + } + + /** + * Registers a new global variable entry. + * + * @param {{ name?: string, type?: VariableType }} [options] Optional variable overrides. + * @returns {WorkspaceVariable} + */ + #registerVariable(options = {}) { + const providedName = + typeof options.name === "string" ? options.name.trim() : ""; + const name = providedName || this.#suggestVariableName(); + const type = this.#normalizeVariableType(options.type ?? "any"); + this.variableCounter += 1; + const id = `var_${this.variableCounter}`; + const defaultValue = this.#defaultVariableValue(type); + const entry = { id, name, type, defaultValue }; + this.variables.set(id, entry); + this.variableOrder.push(id); + this.persistenceManager.schedulePersist(); + return entry; + } + + /** + * Suggests a unique variable name. + * + * @returns {string} + */ + #suggestVariableName() { + const base = "var"; + const existing = new Set(); + this.variableOrder.forEach((variableId) => { + const variable = this.variables.get(variableId); + if (!variable) { + return; + } + const raw = variable.name.trim(); + if (raw) { + existing.add(raw.toLowerCase()); + } + }); + this.graph.nodes.forEach((node) => { + if (node.type === "set_var" || node.type === "get_var") { + const raw = + typeof node.properties.name === "string" + ? node.properties.name.trim() + : ""; + if (raw) { + existing.add(raw.toLowerCase()); + } + } + }); + + if (!existing.has(base)) { + return base; + } + + let index = 1; + while (existing.has(`${base}${index}`)) { + index += 1; + } + return `${base}${index}`; + } + + /** + * Normalizes a variable name for lookups. + * + * @param {string} name Raw variable name. + * @returns {string} + */ + #normalizeVariableName(name) { + return name.trim().toLowerCase(); + } + + /** + * Converts a variable type to a human-readable label. + * + * @param {VariableType} type Variable type identifier. + * @returns {string} + */ + #formatVariableType(type) { + const entry = VARIABLE_TYPE_DEFINITIONS.find( + (definition) => definition.value === type + ); + return entry ? entry.label : "Any"; + } + + /** + * Normalizes a raw variable type value to a supported type identifier. + * + * @param {unknown} value Candidate type value. + * @returns {VariableType} + */ + #normalizeVariableType(value) { + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if ( + SUPPORTED_VARIABLE_TYPES.has( + /** @type {VariableType} */ (normalized) + ) + ) { + return /** @type {VariableType} */ (normalized); + } + } + return "any"; + } + + /** + * Returns the available variable type dropdown entries. + * + * @returns {Array<{ value: VariableType, label: string }>} + */ + #getVariableTypeOptions() { + return VARIABLE_TYPE_DEFINITIONS.map((entry) => ({ ...entry })); + } + + /** + * Determines whether the shared type dropdown is open for the supplied context. + * + * @param {TypeDropdownContext} context Target dropdown context. + * @returns {boolean} + */ + #isTypeDropdownOpen(context) { + const state = this.typeDropdown; + if (!state.context) { + return false; + } + if (state.context.kind !== context.kind) { + return false; + } + if (context.kind === "variable") { + return ( + state.context.kind === "variable" && + state.context.variableId === context.variableId + ); + } + if (context.kind === "local_variable") { + return ( + state.context.kind === "local_variable" && + state.context.nodeId === context.nodeId + ); + } + return ( + state.context.kind === "custom_event_parameter" && + state.context.nodeId === context.nodeId && + state.context.parameterId === context.parameterId + ); + } + + /** + * Locates a workspace variable by name using case-insensitive matching. + * + * @param {string} name Candidate variable name. + * @returns {WorkspaceVariable | null} + */ + #findVariableByName(name) { + const normalized = this.#normalizeVariableName(name); + if (!normalized) { + return null; + } + + for (const variableId of this.variableOrder) { + const variable = this.variables.get(variableId); + if (!variable) { + continue; + } + if (this.#normalizeVariableName(variable.name) === normalized) { + return variable; + } + } + + return null; + } + + /** + * Determines whether the supplied node references the provided workspace variable. + * + * @param {BlueprintNode} node Node to inspect. + * @param {string} variableId Variable identifier. + * @param {string} normalizedName Normalized variable name used for fallback matching. + * @returns {boolean} + */ + #nodeReferencesVariable(node, variableId, normalizedName) { + if (node.type !== "set_var" && node.type !== "get_var") { + return false; + } + + const nodeVariableId = + typeof node.properties.variableId === "string" + ? node.properties.variableId + : ""; + if (nodeVariableId && nodeVariableId === variableId) { + return true; + } + + if (!normalizedName) { + return false; + } + + const nodeName = + typeof node.properties.name === "string" ? node.properties.name : ""; + if (!nodeName) { + return false; + } + + return this.#normalizeVariableName(nodeName) === normalizedName; + } + + /** + * Derives binding metadata for a variable node, including display name and type. + * + * @param {BlueprintNode} node Variable node reference. + * @returns {{ displayName: string, type: VariableType, variableId: string | null }} + */ + #resolveVariableBinding(node) { + const rawName = + typeof node.properties.name === "string" + ? node.properties.name.trim() + : ""; + const storedVariableId = + typeof node.properties.variableId === "string" && + node.properties.variableId + ? node.properties.variableId + : ""; + + let variable = storedVariableId + ? this.variables.get(storedVariableId) ?? null + : null; + if (!variable && rawName) { + variable = this.#findVariableByName(rawName); + } + + const displayName = variable?.name?.trim() || rawName || "Variable"; + const type = variable?.type ?? "any"; + const resolvedVariableId = variable?.id ?? (storedVariableId || null); + + return { displayName, type, variableId: resolvedVariableId }; + } + + /** + * Updates a variable node's DOM representation to match the active binding. + * + * @param {string} nodeId Node identifier to refresh. + * @returns {boolean} Whether any connection kinds were mutated. + */ + #refreshVariableNode(nodeId) { + const node = this.graph.nodes.get(nodeId); + if (!node || (node.type !== "set_var" && node.type !== "get_var")) { + return false; + } + + const article = this.nodeElements.get(nodeId); + if (!article) { + return false; + } + + const binding = this.#resolveVariableBinding(node); + this.#applyVariableNodeTitle(article, node, binding); + return this.#applyVariableNodePins(node, binding.type); + } + + /** + * Updates a local variable node's DOM representation to match its properties. + * + * @param {string} nodeId Node identifier to refresh. + * @returns {boolean} Whether any connection kinds were mutated. + */ + #refreshLocalVariableNode(nodeId) { + const node = this.graph.nodes.get(nodeId); + if ( + !node || + (node.type !== "set_local_var" && node.type !== "get_local_var") + ) { + return false; + } + + const article = this.nodeElements.get(nodeId); + if (!article) { + return false; + } + + const rawName = + typeof node.properties.name === "string" + ? node.properties.name.trim() + : ""; + const displayName = rawName || "localVar"; + const normalizedType = this.#normalizeVariableType( + node.properties.variableType + ); + const type = normalizedType === "any" ? "number" : normalizedType; + + this.#applyVariableNodeTitle(article, node, { displayName, type }); + return this.#applyVariableNodePins(node, type); + } + + /** + * Applies variable-specific title styling to the supplied node header. + * + * @param {HTMLElement} article Node root element. + * @param {BlueprintNode} node Node instance. + * @param {{ displayName: string, type: VariableType }} binding Binding metadata. + */ + #applyVariableNodeTitle(article, node, binding) { + const header = article.querySelector(".node-header"); + const title = article.querySelector(".node-title"); + if (!(header instanceof HTMLElement) || !(title instanceof HTMLElement)) { + return; + } + + const isSetter = + node.type === "set_var" || node.type === "set_local_var"; + const role = isSetter ? "set" : "get"; + const isLocal = + node.type === "set_local_var" || node.type === "get_local_var"; + article.classList.add("blueprint-node--variable"); + article.dataset.variableType = binding.type; + article.dataset.variableRole = role; + article.dataset.variableScope = isLocal ? "local" : "global"; + + header.classList.add("node-header--variable"); + header.setAttribute("data-variable-role", role); + header.setAttribute("data-variable-type", binding.type); + header.setAttribute("data-variable-scope", isLocal ? "local" : "global"); + const prefixBase = role === "set" ? "Set" : "Get"; + const prefix = isLocal ? `${prefixBase} Local` : prefixBase; + const titleText = `${prefix} ${binding.displayName}`; + header.title = titleText; + + title.classList.add("node-title--variable"); + title.textContent = ""; + + const label = document.createElement("span"); + label.className = "node-title-label"; + label.textContent = titleText; + title.appendChild(label); + + node.title = titleText; + } + + /** + * Synchronizes a variable node's data pin kinds with the supplied type. + * + * @param {BlueprintNode} node Node instance. + * @param {VariableType} type Target variable type. + * @returns {boolean} Whether any connection kinds were mutated. + */ + #applyVariableNodePins(node, type) { + const targetKind = type ?? "any"; + + /** @type {Array<{ pinId: string, direction: 'input'|'output', kind: VariableType }>} */ + const changedPins = []; + + const updatePin = (pin, direction) => { + if (!pin) { + return; + } + + if (pin.kind !== targetKind) { + pin.kind = targetKind; + changedPins.push({ pinId: pin.id, direction, kind: targetKind }); + } + + const element = this.#getPinElement(node.id, pin.id, direction); + if (element) { + element.dataset.type = targetKind; + } + }; + + if (node.type === "set_var" || node.type === "set_local_var") { + const valuePin = node.inputs.find((pin) => pin.id === "value"); + updatePin(valuePin, "input"); + } else if (node.type === "get_var" || node.type === "get_local_var") { + const valuePin = node.outputs.find((pin) => pin.id === "value"); + updatePin(valuePin, "output"); + } + + if (!changedPins.length) { + return false; + } + + const connections = this.graph.getConnections(); + let connectionsMutated = false; + const toRemove = new Set(); + + changedPins.forEach((update) => { + connections.forEach((connection) => { + const isSource = + connection.from.nodeId === node.id && + connection.from.pinId === update.pinId; + const isTarget = + connection.to.nodeId === node.id && + connection.to.pinId === update.pinId; + if (!isSource && !isTarget) { + return; + } + + const otherNodeId = isSource + ? connection.to.nodeId + : connection.from.nodeId; + const otherPinId = isSource + ? connection.to.pinId + : connection.from.pinId; + const otherNode = this.graph.nodes.get(otherNodeId); + const otherPin = otherNode?.getPin(otherPinId); + if (!otherNode || !otherPin) { + return; + } + + const outputKind = isSource ? update.kind : otherPin.kind; + const inputKind = isSource ? otherPin.kind : update.kind; + if (!this.#arePinKindsCompatible(outputKind, inputKind)) { + toRemove.add(connection.id); + return; + } + + if (connection.kind !== update.kind) { + connection.kind = update.kind; + connectionsMutated = true; + } + }); + }); + + if (toRemove.size) { + toRemove.forEach((connectionId) => + this.graph.removeConnection(connectionId) + ); + connectionsMutated = true; + } + + return connectionsMutated; + } + + /** + * Synchronizes sequence node outputs with configured branches. + * + * @param {string} nodeId Target node identifier. + * @returns {boolean} Whether any connections were mutated. + */ + #refreshSequenceNode(nodeId) { + const node = this.graph.nodes.get(nodeId); + if (!node || node.type !== "sequence") { + return false; + } + + const { branches } = this.#ensureSequenceBranches(node); + const result = this.#applySequenceOutputs(node, branches); + if (this.nodeRenderer) { + this.nodeRenderer.rebuildNodePins(node, { inputs: false, outputs: true }); + this.nodeRenderer.updatePinConnectionsForNode(node.id); + } + if (this.navigationManager) { + this.navigationManager.updateZoomDisplay(); + } else { + this.scheduleConnectionRefresh(); + } + return result.connectionsMutated; + } + + /** + * Ensures sequence branch metadata is present and unique. + * + * @param {import('../core/BlueprintNode.js').BlueprintNode} node Sequence node reference. + * @returns {{ branches: Array<{ id: string }>, mutated: boolean }} + */ + #ensureSequenceBranches(node) { + if (!Array.isArray(node.properties.branches)) { + node.properties.branches = []; + } + + if (!Number.isFinite(node.properties.branchCounter)) { + node.properties.branchCounter = 0; + } + + const existing = Array.isArray(node.properties.branches) + ? /** @type {Array<{ id?: string }>} */ (node.properties.branches) + : []; + const sanitized = []; + const usedIds = new Set(); + let mutated = !Array.isArray(node.properties.branches); + + existing.forEach((entry) => { + let id = typeof entry?.id === "string" ? entry.id.trim() : ""; + if (!id || usedIds.has(id)) { + id = this.#allocateSequenceBranchId(node); + mutated = true; + } + usedIds.add(id); + sanitized.push({ id }); + }); + + if (!sanitized.length) { + node.outputs + .filter((pin) => pin.direction === "output") + .forEach((pin) => { + const pinId = typeof pin.id === "string" ? pin.id : ""; + if (!pinId || usedIds.has(pinId)) { + const generated = this.#allocateSequenceBranchId(node); + sanitized.push({ id: generated }); + usedIds.add(generated); + mutated = true; + } else { + sanitized.push({ id: pinId }); + usedIds.add(pinId); + } + }); + + if (!sanitized.length) { + const count = Math.max(DEFAULT_SEQUENCE_BRANCHES, 1); + for (let index = 0; index < count; index += 1) { + const generated = this.#allocateSequenceBranchId(node); + sanitized.push({ id: generated }); + usedIds.add(generated); + } + mutated = true; + } + } + + let maxCounter = Number.isFinite(node.properties.branchCounter) + ? Number(node.properties.branchCounter) + : 0; + sanitized.forEach((entry) => { + const match = /_(\d+)$/.exec(entry.id); + if (match) { + const numeric = Number.parseInt(match[1], 10); + if (Number.isFinite(numeric)) { + maxCounter = Math.max(maxCounter, numeric); + } + } + }); + node.properties.branchCounter = maxCounter; + + node.properties.branches = sanitized.map((entry) => ({ id: entry.id })); + return { + branches: node.properties.branches.map((entry) => ({ id: entry.id })), + mutated, + }; + } + + /** + * Allocates a unique branch identifier for a sequence node. + * + * @param {import('../core/BlueprintNode.js').BlueprintNode} node Sequence node reference. + * @returns {string} + */ + #allocateSequenceBranchId(node) { + if (!Number.isFinite(node.properties.branchCounter)) { + node.properties.branchCounter = 0; + } + node.properties.branchCounter += 1; + return `branch_${String(node.properties.branchCounter).padStart(2, "0")}`; + } + + /** + * Derives a human-friendly label for a sequence branch index. + * + * @param {number} index Branch index starting at zero. + * @returns {string} + */ + #formatSequenceBranchLabel(index) { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + let value = Math.max(0, Math.floor(index)); + let label = ""; + do { + label = alphabet[value % 26] + label; + value = Math.floor(value / 26) - 1; + } while (value >= 0); + return label; + } + + /** + * Applies sequence branch descriptors to the node outputs. + * + * @param {import('../core/BlueprintNode.js').BlueprintNode} node Sequence node reference. + * @param {Array<{ id: string }>} branches Normalized branch descriptors. + * @returns {{ pinsChanged: boolean, connectionsMutated: boolean }} + */ + #applySequenceOutputs(node, branches) { + const existing = new Map(node.outputs.map((pin) => [pin.id, pin])); + let pinsChanged = false; + let connectionsMutated = false; + const desired = []; + + branches.forEach((branch, index) => { + const pinId = branch.id; + const label = this.#formatSequenceBranchLabel(index); + let descriptor = existing.get(pinId); + if (!descriptor) { + descriptor = { + id: pinId, + name: label, + direction: "output", + kind: "exec", + }; + pinsChanged = true; + } else { + if (descriptor.name !== label) { + descriptor.name = label; + pinsChanged = true; + } + if (descriptor.direction !== "output") { + descriptor.direction = "output"; + pinsChanged = true; + } + if (descriptor.kind !== "exec") { + descriptor.kind = "exec"; + pinsChanged = true; + } + } + desired.push(descriptor); + }); + + node.outputs.forEach((pin) => { + const keep = branches.some((branch) => branch.id === pin.id); + if (!keep) { + this.graph.removeConnectionsForPin({ + nodeId: node.id, + pinId: pin.id, + }); + connectionsMutated = true; + } + }); + + if (pinsChanged || node.outputs.length !== desired.length) { + node.outputs = desired; + } + + return { pinsChanged, connectionsMutated }; + } + + /** Adds an execution branch to a sequence node. */ + #addSequenceBranch(nodeId) { + const node = this.graph.nodes.get(nodeId); + if (!node || node.type !== "sequence") { + return; + } + const { branches } = this.#ensureSequenceBranches(node); + const nextBranches = branches.map((entry) => ({ id: entry.id })); + const branchId = this.#allocateSequenceBranchId(node); + nextBranches.push({ id: branchId }); + this.graph.setNodeProperty(nodeId, "branches", nextBranches); + } + + /** Removes an execution branch from a sequence node when possible. */ + #removeSequenceBranch(nodeId, branchId) { + const node = this.graph.nodes.get(nodeId); + if (!node || node.type !== "sequence") { + return; + } + const { branches } = this.#ensureSequenceBranches(node); + if (branches.length <= 1) { + return; + } + const nextBranches = branches + .filter((entry) => entry.id !== branchId) + .map((entry) => ({ id: entry.id })); + if (nextBranches.length === branches.length) { + return; + } + this.graph.setNodeProperty(nodeId, "branches", nextBranches); + } + + /** + * Enumerates custom event nodes within the active graph. + * + * @returns {Array} + */ + #getCustomEventNodes() { + return [...this.graph.nodes.values()].filter( + (node) => node.type === "custom_event" + ); + } + + /** + * Generates a unique custom event name based on the provided base string. + * + * @param {string} baseName Desired base name. + * @param {string} [excludeNodeId] Optional node identifier to exclude from duplicate checks. + * @returns {string} + */ + #generateUniqueCustomEventName(baseName, excludeNodeId) { + const trimmedBase = baseName.trim(); + const base = trimmedBase || "CustomEvent"; + const normalizedBase = base.toLowerCase(); + const existing = new Set(); + + this.graph.nodes.forEach((candidate) => { + if (candidate.type !== "custom_event") { + return; + } + if (excludeNodeId && candidate.id === excludeNodeId) { + return; + } + const rawName = + typeof candidate.properties.name === "string" + ? candidate.properties.name.trim() + : ""; + if (rawName) { + existing.add(rawName.toLowerCase()); + } + }); + + if (!existing.has(normalizedBase)) { + return base; + } + + let suffix = 1; + while (existing.has(`${base}${suffix}`.toLowerCase())) { + suffix += 1; + } + return `${base}${suffix}`; + } + + /** + * Derives the user-facing label for a custom event node. + * + * @param {import('../core/BlueprintNode.js').BlueprintNode} node Custom event node reference. + * @returns {string} + */ + #resolveCustomEventLabel(node) { + const raw = + typeof node.properties.name === "string" + ? node.properties.name.trim() + : ""; + return raw || "CustomEvent"; + } + + /** + * Produces a stable output pin identifier for the supplied parameter. + * + * @param {string} parameterId Custom event parameter identifier. + * @returns {string} + */ + #customEventOutputPinId(parameterId) { + return `param_${parameterId}`; + } + + /** + * Produces a stable input pin identifier for call nodes invoking the supplied parameter. + * + * @param {string} parameterId Custom event parameter identifier. + * @returns {string} + */ + #callCustomEventInputPinId(parameterId) { + return `arg_${parameterId}`; + } + + /** + * Allocates a unique custom event parameter identifier for the provided node. + * + * @param {import('../core/BlueprintNode.js').BlueprintNode} node Target custom event node. + * @returns {string} + */ + #allocateCustomEventParameterId(node) { + let counter = Number.isFinite(node.properties.parameterCounter) + ? Number(node.properties.parameterCounter) + : 0; + counter += 1; + node.properties.parameterCounter = counter; + return `param_${String(counter).padStart(2, "0")}`; + } + + /** + * Ensures the custom event parameter collection is valid and normalized. + * + * @param {import('../core/BlueprintNode.js').BlueprintNode} node Target custom event node. + * @returns {{ parameters: Array, mutated: boolean }} + */ + #sanitizeCustomEventParameters(node) { + const existing = Array.isArray(node.properties.parameters) + ? /** @type {Array} */ (node.properties.parameters) + : []; + const normalizeOptional = (value) => { + if (typeof value === "boolean") { + return value; + } + if (typeof value === "string") { + const trimmed = value.trim().toLowerCase(); + if (trimmed === "true") { + return true; + } + if (!trimmed || trimmed === "false") { + return false; + } + } + return Boolean(value); + }; + + const sanitized = []; + const usedIds = new Set(); + const usedNames = new Set(); + let mutated = !Array.isArray(node.properties.parameters); + + let maxCounter = Number.isFinite(node.properties.parameterCounter) + ? Number(node.properties.parameterCounter) + : 0; + + existing.forEach((entry, index) => { + const rawName = typeof entry?.name === "string" ? entry.name.trim() : ""; + const baseName = rawName || `Param${index + 1}`; + if (!rawName) { + mutated = true; + } + + let candidateName = baseName; + let suffix = 2; + while (usedNames.has(candidateName.toLowerCase())) { + candidateName = `${baseName}${suffix}`; + suffix += 1; + mutated = true; + } + usedNames.add(candidateName.toLowerCase()); + + const rawType = typeof entry?.type === "string" ? entry.type : ""; + const type = this.#normalizeVariableType(rawType); + if (type !== rawType) { + mutated = true; + } + + const optional = normalizeOptional(entry?.optional); + + let identifier = typeof entry?.id === "string" ? entry.id.trim() : ""; + if (!identifier || usedIds.has(identifier)) { + identifier = this.#allocateCustomEventParameterId(node); + mutated = true; + } + usedIds.add(identifier); + + const numericMatch = /_(\d+)$/.exec(identifier); + if (numericMatch) { + const numeric = Number.parseInt(numericMatch[1], 10); + if (Number.isFinite(numeric)) { + maxCounter = Math.max(maxCounter, numeric); + } + } + + sanitized.push({ id: identifier, name: candidateName, type, optional }); + }); + + if (sanitized.length !== existing.length) { + mutated = true; + } else if (!mutated) { + for (let index = 0; index < sanitized.length; index += 1) { + const current = existing[index]; + const next = sanitized[index]; + if ( + !current || + current.id !== next.id || + current.name !== next.name || + current.type !== next.type || + normalizeOptional(current?.optional) !== next.optional + ) { + mutated = true; + break; + } + } + } + + if (mutated) { + node.properties.parameters = sanitized.map((parameter) => ({ + ...parameter, + })); + } + + if ( + !Number.isFinite(node.properties.parameterCounter) || + node.properties.parameterCounter < maxCounter + ) { + node.properties.parameterCounter = maxCounter; + mutated = true; + } + + const stored = /** @type {Array} */ ( + Array.isArray(node.properties.parameters) + ? node.properties.parameters + : [] + ); + + return { + parameters: stored.map((parameter) => ({ ...parameter })), + mutated, + }; + } + + /** + * Aligns the node output pins with the supplied custom event parameters. + * + * @param {import('../core/BlueprintNode.js').BlueprintNode} node Custom event node reference. + * @param {Array} parameters Normalized parameter list. + * @returns {{ pinsChanged: boolean, connectionsMutated: boolean }} + */ + #applyCustomEventNodePins(node, parameters) { + const existingOutputs = new Map(node.outputs.map((pin) => [pin.id, pin])); + + let pinsChanged = false; + let connectionsMutated = false; + + const desiredIds = new Set(["exec_out"]); + const desiredOutputs = []; + + const execPin = existingOutputs.get("exec_out") ?? { + id: "exec_out", + name: "Exec", + direction: "output", + kind: "exec", + }; + execPin.name = "Exec"; + execPin.direction = "output"; + execPin.kind = "exec"; + desiredOutputs.push(execPin); + + parameters.forEach((parameter) => { + const pinId = this.#customEventOutputPinId(parameter.id); + desiredIds.add(pinId); + let descriptor = existingOutputs.get(pinId); + if (!descriptor) { + descriptor = { + id: pinId, + name: parameter.name, + direction: "output", + kind: parameter.type, + }; + pinsChanged = true; + } else { + if (descriptor.name !== parameter.name) { + descriptor.name = parameter.name; + pinsChanged = true; + } + if (descriptor.kind !== parameter.type) { + descriptor.kind = parameter.type; + pinsChanged = true; + } + descriptor.direction = "output"; + } + desiredOutputs.push(descriptor); + }); + + node.outputs + .filter((pin) => !desiredIds.has(pin.id)) + .forEach((pin) => { + this.graph.removeConnectionsForPin({ nodeId: node.id, pinId: pin.id }); + connectionsMutated = true; + pinsChanged = true; + }); + + if (pinsChanged || node.outputs.length !== desiredOutputs.length) { + node.outputs = desiredOutputs; + } + + parameters.forEach((parameter) => { + const pinId = this.#customEventOutputPinId(parameter.id); + if (this.#ensureConnectionsReflectPinKinds(node.id, pinId)) { + connectionsMutated = true; + } + }); + + return { pinsChanged, connectionsMutated }; + } + + /** + * Aligns a call node's input pins with its target custom event parameters. + * + * @param {import('../core/BlueprintNode.js').BlueprintNode} node Call custom event node. + * @param {Array} parameters Target parameter list. + * @returns {{ pinsChanged: boolean, connectionsMutated: boolean }} + */ + #applyCallCustomEventNodePins(node, parameters) { + const existingInputs = new Map(node.inputs.map((pin) => [pin.id, pin])); + + let pinsChanged = false; + let connectionsMutated = false; + + const desiredIds = new Set(["exec_in"]); + const desiredInputs = []; + + const execPin = existingInputs.get("exec_in") ?? { + id: "exec_in", + name: "Exec", + direction: "input", + kind: "exec", + }; + execPin.name = "Exec"; + execPin.direction = "input"; + execPin.kind = "exec"; + desiredInputs.push(execPin); + + parameters.forEach((parameter) => { + const pinId = this.#callCustomEventInputPinId(parameter.id); + desiredIds.add(pinId); + let descriptor = existingInputs.get(pinId); + if (!descriptor) { + descriptor = { + id: pinId, + name: parameter.name, + direction: "input", + kind: parameter.type, + }; + pinsChanged = true; + } else { + if (descriptor.name !== parameter.name) { + descriptor.name = parameter.name; + pinsChanged = true; + } + if (descriptor.kind !== parameter.type) { + descriptor.kind = parameter.type; + pinsChanged = true; + } + descriptor.direction = "input"; + } + desiredInputs.push(descriptor); + }); + + node.inputs + .filter((pin) => !desiredIds.has(pin.id)) + .forEach((pin) => { + this.graph.removeConnectionsForPin({ nodeId: node.id, pinId: pin.id }); + connectionsMutated = true; + pinsChanged = true; + }); + + if (pinsChanged || node.inputs.length !== desiredInputs.length) { + node.inputs = desiredInputs; + } + + parameters.forEach((parameter) => { + const pinId = this.#callCustomEventInputPinId(parameter.id); + if (this.#ensureConnectionsReflectPinKinds(node.id, pinId)) { + connectionsMutated = true; + } + }); + + return { pinsChanged, connectionsMutated }; + } + + /** + * Guarantees connection compatibility after a pin kind update. + * + * @param {string} nodeId Node identifier. + * @param {string} pinId Pin identifier to reconcile. + * @returns {boolean} Whether any connections were mutated. + */ + #ensureConnectionsReflectPinKinds(nodeId, pinId) { + const node = this.graph.nodes.get(nodeId); + const pin = node?.getPin(pinId); + if (!node || !pin) { + return false; + } + + const connections = this.graph.getConnectionsForNode(nodeId, pinId); + let mutated = false; + connections.forEach((connection) => { + const fromNode = this.graph.nodes.get(connection.from.nodeId); + const toNode = this.graph.nodes.get(connection.to.nodeId); + const fromPin = fromNode?.getPin(connection.from.pinId); + const toPin = toNode?.getPin(connection.to.pinId); + if (!fromNode || !toNode || !fromPin || !toPin) { + this.graph.removeConnection(connection.id); + mutated = true; + return; + } + if (!this.#arePinKindsCompatible(fromPin.kind, toPin.kind)) { + this.graph.removeConnection(connection.id); + mutated = true; + return; + } + const expectedKind = toPin.kind === "any" ? fromPin.kind : toPin.kind; + if (connection.kind !== expectedKind) { + connection.kind = expectedKind; + mutated = true; + } + }); + return mutated; + } + + /** + * Ensures the call node arguments map contains entries for each parameter. + * + * @param {import('../core/BlueprintNode.js').BlueprintNode} node Call node reference. + * @param {Array} parameters Parameter metadata. + */ + #syncCallCustomEventArguments(node, parameters) { + const current = node.properties.arguments; + const record = + current && typeof current === "object" && !Array.isArray(current) + ? /** @type {Record} */ (current) + : {}; + + const validIds = new Set(parameters.map((parameter) => parameter.id)); + + Object.keys(record).forEach((key) => { + if (!validIds.has(key)) { + delete record[key]; + } + }); + + parameters.forEach((parameter) => { + if (!(parameter.id in record)) { + record[parameter.id] = null; + } + }); + + node.properties.arguments = record; + } + + /** + * Appends a new parameter to the provided custom event node. + * + * @param {string} nodeId Target custom event node identifier. + * @param {{ name?: string, type?: VariableType }} [options] Optional overrides. + * @returns {CustomEventParameter | null} + */ + #addCustomEventParameter(nodeId, options = {}) { + const node = this.graph.nodes.get(nodeId); + if (!node || node.type !== "custom_event") { + return null; + } + + const { parameters } = this.#sanitizeCustomEventParameters(node); + const identifier = this.#allocateCustomEventParameterId(node); + const baseName = + typeof options.name === "string" ? options.name.trim() : ""; + const parameter = { + id: identifier, + name: baseName || `Param${parameters.length + 1}`, + type: this.#normalizeVariableType(options.type ?? "any"), + optional: Boolean(options.optional), + }; + + const nextParameters = parameters.concat(parameter); + this.graph.setNodeProperty(nodeId, "parameters", nextParameters); + return parameter; + } + + /** + * Removes a parameter from a custom event node if present. + * + * @param {string} nodeId Target custom event node identifier. + * @param {string} parameterId Parameter identifier to remove. + * @returns {boolean} Whether a parameter was removed. + */ + #removeCustomEventParameter(nodeId, parameterId) { + const node = this.graph.nodes.get(nodeId); + if (!node || node.type !== "custom_event") { + return false; + } + const { parameters } = this.#sanitizeCustomEventParameters(node); + const nextParameters = parameters.filter( + (parameter) => parameter.id !== parameterId + ); + if (nextParameters.length === parameters.length) { + return false; + } + this.graph.setNodeProperty(nodeId, "parameters", nextParameters); + return true; + } + + /** + * Updates the display name for a custom event parameter. + * + * @param {string} nodeId Target custom event node identifier. + * @param {string} parameterId Parameter identifier to rename. + * @param {string} name Desired display name. + */ + #renameCustomEventParameter(nodeId, parameterId, name) { + const node = this.graph.nodes.get(nodeId); + if (!node || node.type !== "custom_event") { + return; + } + const trimmed = typeof name === "string" ? name.trim() : ""; + const { parameters } = this.#sanitizeCustomEventParameters(node); + const nextParameters = parameters.map((parameter) => + parameter.id === parameterId + ? { ...parameter, name: trimmed || parameter.name } + : parameter + ); + this.graph.setNodeProperty(nodeId, "parameters", nextParameters); + } + + /** + * Adjusts the type for a custom event parameter. + * + * @param {string} nodeId Target custom event node identifier. + * @param {string} parameterId Parameter identifier to modify. + * @param {VariableType} nextType Desired parameter type. + */ + #setCustomEventParameterType(nodeId, parameterId, nextType) { + const node = this.graph.nodes.get(nodeId); + if (!node || node.type !== "custom_event") { + return; + } + const resolvedType = this.#normalizeVariableType(nextType); + const { parameters } = this.#sanitizeCustomEventParameters(node); + const nextParameters = parameters.map((parameter) => + parameter.id === parameterId + ? { ...parameter, type: resolvedType } + : parameter + ); + this.graph.setNodeProperty(nodeId, "parameters", nextParameters); + } + + /** + * Toggles whether a custom event parameter is optional. + * + * @param {string} nodeId Target custom event node identifier. + * @param {string} parameterId Parameter identifier to update. + * @param {boolean} isOptional Desired optional state. + */ + #setCustomEventParameterOptional(nodeId, parameterId, isOptional) { + const node = this.graph.nodes.get(nodeId); + if (!node || node.type !== "custom_event") { + return; + } + const flag = Boolean(isOptional); + const { parameters } = this.#sanitizeCustomEventParameters(node); + const nextParameters = parameters.map((parameter) => + parameter.id === parameterId + ? { ...parameter, optional: flag } + : parameter + ); + this.graph.setNodeProperty(nodeId, "parameters", nextParameters); + } + + /** + * Guarantees the custom event structure matches its parameter list without touching call nodes. + * + * @param {import('../core/BlueprintNode.js').BlueprintNode} node Custom event node reference. + * @returns {{ parameters: Array, connectionsMutated: boolean }} + */ + #ensureCustomEventStructure(node) { + const { parameters } = this.#sanitizeCustomEventParameters(node); + const pinResult = this.#applyCustomEventNodePins(node, parameters); + if (pinResult.pinsChanged) { + this.nodeRenderer.rebuildNodePins(node, { inputs: false, outputs: true }); + } + return { + parameters, + connectionsMutated: pinResult.connectionsMutated, + }; + } + + /** + * Refreshes the DOM representation of a custom event node and synchronizes call references. + * + * @param {string} nodeId Target node identifier. + * @returns {boolean} Whether any connections were mutated. + */ + #refreshCustomEventNode(nodeId) { + const node = this.graph.nodes.get(nodeId); + if (!node || node.type !== "custom_event") { + return false; + } + + const { parameters, connectionsMutated: eventConnectionsMutated } = + this.#ensureCustomEventStructure(node); + + const label = this.#resolveCustomEventLabel(node); + const titleText = `Custom Event: ${label}`; + node.title = titleText; + + const article = this.nodeElements.get(nodeId); + if (article) { + article.classList.add("blueprint-node--custom-event"); + article.dataset.customEventName = label; + article.dataset.customEventParamCount = String(parameters.length); + const titleElement = article.querySelector(".node-title"); + if (titleElement instanceof HTMLElement) { + titleElement.textContent = titleText; + } + } + + const callConnectionsMutated = this.#syncCallNodesForEvent( + nodeId, + parameters + ); + return eventConnectionsMutated || callConnectionsMutated; + } + + /** + * Synchronizes all call nodes targeting the specified custom event. + * + * @param {string} eventNodeId Custom event identifier. + * @param {Array} parameters Normalized parameter definitions. + * @returns {boolean} Whether any connections were mutated. + */ + #syncCallNodesForEvent(eventNodeId, parameters) { + const eventNode = this.graph.nodes.get(eventNodeId); + let connectionsMutated = false; + this.graph.nodes.forEach((candidate) => { + if (candidate.type !== "call_custom_event") { + return; + } + const targetId = + typeof candidate.properties.eventId === "string" + ? candidate.properties.eventId + : ""; + if (targetId !== eventNodeId) { + return; + } + const mutated = this.#refreshCustomEventCallNode(candidate.id, { + eventNode: + eventNode && eventNode.type === "custom_event" ? eventNode : null, + parameters, + }); + connectionsMutated = mutated || connectionsMutated; + }); + return connectionsMutated; + } + + /** + * Refreshes the display metadata and pins for a call custom event node. + * + * @param {string} nodeId Target node identifier. + * @param {{ eventNode?: import('../core/BlueprintNode.js').BlueprintNode | null, parameters?: Array }} [options] + * @returns {boolean} Whether any connections were mutated. + */ + #refreshCustomEventCallNode(nodeId, options = {}) { + const node = this.graph.nodes.get(nodeId); + if (!node || node.type !== "call_custom_event") { + return false; + } + + const eventId = + typeof node.properties.eventId === "string" + ? node.properties.eventId + : ""; + let eventNode = options.eventNode ?? null; + if (!eventNode && eventId) { + const fetched = this.graph.nodes.get(eventId); + if (fetched && fetched.type === "custom_event") { + eventNode = fetched; + } + } + + let parameters = Array.isArray(options.parameters) + ? options.parameters.map((parameter) => ({ ...parameter })) + : []; + if (eventNode && eventNode.type === "custom_event" && !options.parameters) { + const structure = this.#ensureCustomEventStructure(eventNode); + parameters = structure.parameters.map((parameter) => ({ ...parameter })); + } + + const pinResult = this.#applyCallCustomEventNodePins(node, parameters); + if (pinResult.pinsChanged) { + this.nodeRenderer.rebuildNodePins(node, { inputs: true, outputs: false }); + } + this.#syncCallCustomEventArguments(node, parameters); + + let label = ""; + if (eventNode && eventNode.type === "custom_event") { + label = this.#resolveCustomEventLabel(eventNode); + } + + const titleText = label ? `Call ${label}` : "Call Custom Event"; + node.title = titleText; + + const article = this.nodeElements.get(nodeId); + if (article) { + article.classList.add("blueprint-node--custom-event-call"); + if (label && eventId) { + article.dataset.targetEventId = eventId; + article.dataset.targetEventName = label; + article.dataset.targetEventParamCount = String(parameters.length); + } else { + delete article.dataset.targetEventId; + delete article.dataset.targetEventName; + delete article.dataset.targetEventParamCount; + } + const titleElement = article.querySelector(".node-title"); + if (titleElement instanceof HTMLElement) { + titleElement.textContent = titleText; + } + } + + return pinResult.connectionsMutated; + } + + /** + * Re-renders the inspector panel when the supplied node is the active selection. + * + * @param {string} nodeId Node identifier to check. + */ + #refreshInspectorIfActive(nodeId, propertyKey) { + if ( + this.selectedNodeIds.size === 1 && + this.selectedNodeId === nodeId && + this.inspectorView === "default" + ) { + if (propertyKey) { + this.pendingInspectorFocus = { nodeId, property: propertyKey }; + } + this.#renderInspector(nodeId); + } + } + + /** + * Updates every call node referencing the specified custom event. + * + * @param {string} eventNodeId Custom event node identifier. + */ + #refreshCallNodesForEvent(eventNodeId) { + const eventNode = this.graph.nodes.get(eventNodeId); + const structure = + eventNode && eventNode.type === "custom_event" + ? this.#ensureCustomEventStructure(eventNode) + : { parameters: [], connectionsMutated: false }; + this.#syncCallNodesForEvent(eventNodeId, structure.parameters); + } + + /** + * Clears references to a custom event that has been removed from the graph. + * + * @param {string} eventNodeId Identifier for the removed event node. + */ + #handleCustomEventRemoval(eventNodeId) { + this.graph.nodes.forEach((node) => { + if (node.type !== "call_custom_event") { + return; + } + const targetId = + typeof node.properties.eventId === "string" + ? node.properties.eventId + : ""; + if (targetId !== eventNodeId) { + return; + } + this.graph.setNodeProperty(node.id, "eventId", ""); + }); + } + + /** + * Renders the event list summarizing entry nodes. + */ + #renderEventList() { + renderEventList({ + element: this.eventListElement, + registry: this.registry, + graph: this.graph, + focusNode: (nodeId) => { + this.navigationManager.focusNode(nodeId); + }, + renderEmpty: (message) => { + if (this.eventListElement) { + this.#renderOverviewEmpty(this.eventListElement, message); + } + }, + isEventNode: (node, entryTypes) => this.#isEventNode(node, entryTypes), + }); + } + + /** + * Renders the variable list summarizing set/get nodes. + */ + #renderVariableList() { + renderVariableList({ + element: this.variableListElement, + variableOrder: this.variableOrder, + variables: this.variables, + graph: this.graph, + cancelVariableRename: () => this.#cancelVariableRename(), + closeVariableTypeDropdown: () => this.#closeTypeDropdown(), + renderEmpty: (message) => { + if (this.variableListElement) { + this.#renderOverviewEmpty(this.variableListElement, message); + } + }, + normalizeVariableName: (name) => this.#normalizeVariableName(name), + formatVariableType: (type) => this.#formatVariableType(type), + selectVariable: (variableId) => this.#selectVariable(variableId), + handleVariableDragStart: (event, variableId) => + this.dropManager.handleVariableDragStart(event, variableId), + handleVariableDragEnd: () => this.dropManager.handleVariableDragEnd(), + reorderVariable: (variableId, targetIndex) => + this.#reorderVariable(variableId, targetIndex), + openVariableTypeDropdown: (variableId, button, anchor) => + this.#openVariableTypeDropdown(variableId, button, anchor), + deleteVariable: (variableId) => this.#deleteVariable(variableId), + startVariableRename: (variableId, label) => + this.#startVariableRename(variableId, label), + highlightVariableSelection: () => this.#highlightVariableSelection(), + isVariableDropdownOpen: (variableId) => + this.#isTypeDropdownOpen({ kind: "variable", variableId }), + }); + } + + /** + * Appends an empty-state message to the provided overview list. + * + * @param {HTMLUListElement} list Target list element. + * @param {string} message Empty-state message. + */ + #renderOverviewEmpty(list, message) { + const item = document.createElement("li"); + const placeholder = document.createElement("p"); + placeholder.className = "overview-empty"; + placeholder.textContent = message; + item.appendChild(placeholder); + list.appendChild(item); + } + + /** + * Determines whether a node qualifies as an event for the overview list. + * + * @param {BlueprintNode} node Node to inspect. + * @param {Set} entryTypes Node types designated as entry points. + * @returns {boolean} + */ + #isEventNode(node, entryTypes) { + const definition = this.registry.get(node.type); + if (!definition) { + return false; + } + + if (entryTypes.has(node.type) && node.type !== "call_custom_event") { + return true; + } + + if (node.type === "custom_event") { + return true; + } + + return false; + } + + /** + * Prepares drag metadata for variable overview interactions. + * + * @param {DragEvent} event Drag event payload. + * @param {string} variableId Variable identifier being dragged. + */ + /** + * Handles pointer interactions with rendered connection paths, enabling quick removal. + * + * @param {PointerEvent} event Pointer interaction payload. + */ + #handleConnectionPointerDown(event) { + if (!(event.target instanceof SVGPathElement)) { + return; + } + + if (event.button !== 0 || !event.altKey) { + return; + } + + const path = /** @type {SVGPathElement} */ (event.target); + const connectionId = path.dataset.connectionId; + if (!connectionId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + this.graph.removeConnection(connectionId); + } + + /** + * Creates a node instance from a registry definition or shortcut entry. + * + * @param {PaletteNodeDefinition} definition Definition describing the node to spawn. + * @param {{ position?: { x: number, y: number } }} [options] Spawn options. + * @returns {import('../core/BlueprintNode.js').BlueprintNode | null} + */ + createNodeFromDefinition(definition, options = {}) { + if (!definition) { + return null; + } + + const shortcut = definition.shortcut; + if (shortcut?.type === "variable") { + const targetId = shortcut.action === "set" ? "set_var" : "get_var"; + const node = this.addNode(targetId, options.position); + if (!node) { + return null; + } + + const variable = this.variables.get(shortcut.variableId); + const rawName = variable?.name ?? ""; + const label = rawName.trim() || variable?.id || shortcut.variableId; + this.graph.setNodeProperty(node.id, "variableId", shortcut.variableId); + this.graph.setNodeProperty(node.id, "name", label); + this.#refreshVariableNode(node.id); + return node; + } + + let node = null; + if (shortcut?.type === "custom_event_call") { + node = this.addNode("call_custom_event", options.position); + if (!node) { + return null; + } + this.graph.setNodeProperty(node.id, "eventId", shortcut.eventNodeId); + } else { + node = this.addNode(definition.id, options.position); + if (!node) { + return null; + } + } + + if (node.type === "custom_event") { + const currentName = + typeof node.properties.name === "string" ? node.properties.name : ""; + const uniqueName = this.#generateUniqueCustomEventName( + currentName || "CustomEvent", + node.id + ); + if (uniqueName !== currentName) { + this.graph.setNodeProperty(node.id, "name", uniqueName); + } else { + this.#refreshCustomEventNode(node.id); + } + this.pendingInspectorFocus = { nodeId: node.id, property: "name" }; + } else if (node.type === "call_custom_event") { + if (!shortcut || shortcut.type !== "custom_event_call") { + this.pendingInspectorFocus = { nodeId: node.id, property: "eventId" }; + } + this.#refreshCustomEventCallNode(node.id); + } + + return node; + } + + /** + * Spawns a node at the requested context menu position. + * + * @param {PaletteNodeDefinition} definition Node definition to instantiate. + * @param {{ x: number, y: number }} spawnPosition Workspace-relative spawn coordinates. + * @returns {import('../core/BlueprintNode.js').BlueprintNode | null} + */ + spawnNodeFromContextMenu(definition, spawnPosition) { + const clamped = { + x: Math.max(0, spawnPosition.x), + y: Math.max(0, spawnPosition.y), + }; + const worldSpawn = this.navigationManager.screenPointToWorld(clamped); + const spawn = WorkspaceGeometry.snapPositionToGrid(this, worldSpawn); + const pendingConnectionSnapshot = this.pendingConnectionSpawn + ? { + startRef: { + nodeId: this.pendingConnectionSpawn.startRef.nodeId, + pinId: this.pendingConnectionSpawn.startRef.pinId, + }, + direction: this.pendingConnectionSpawn.direction, + kind: this.pendingConnectionSpawn.kind, + } + : null; + this.pendingConnectionSpawn = null; + const node = this.createNodeFromDefinition(definition, { position: spawn }); + if ( + node && + this.pendingVariableSpawn && + (definition.id === "set_var" || definition.id === "get_var") + ) { + const variable = this.variables.get(this.pendingVariableSpawn.variableId); + if (variable) { + const nodeName = variable.name || variable.id; + this.graph.setNodeProperty(node.id, "name", nodeName); + this.graph.setNodeProperty(node.id, "variableId", variable.id); + } + this.pendingVariableSpawn = null; + } + if (node && pendingConnectionSnapshot) { + this.#connectSpawnedNodeToPending(node, pendingConnectionSnapshot); + } + return node; + } + + /** + * Computes a spawn position based on the workspace size and spawn index. + * + * @param {string} definitionId Node definition identifier. + * @param {number} index Spawn index. + * @returns {{x:number,y:number}} + */ + #computeSpawnPosition(definitionId, index) { + const bounds = this.workspaceElement.getBoundingClientRect(); + const zoom = this.zoomLevel || 1; + const width = bounds.width / zoom; + const height = bounds.height / zoom; + const offset = (index % 5) * 30; + const baseX = width * 0.3; + const baseY = height * 0.3; + const jitterX = (index * 37) % 120; + const jitterY = (index * 53) % 120; + const categoryOffset = + this.registry.get(definitionId)?.category.length ?? 0; + return WorkspaceGeometry.snapPositionToGrid(this, { + x: Math.max(40, baseX + offset + jitterX + categoryOffset), + y: Math.max(40, baseY + offset + jitterY), + }); + } + + /** + * Initiates a node drag operation. + * + * @param {PointerEvent} event Pointer event. + * @param {string} nodeId Dragged node identifier. + */ + #startDraggingNode(event, nodeId) { + if (!this.dragManager) { + return; + } + this.dragManager.startNodeDrag(event, nodeId); + } + + /** + * Attempts to connect the selected node to the nearest compatible pin. + * + * @returns {boolean} + */ + #tryAutoConnectSelectedNode() { + if (this.selectedNodeIds.size !== 1 || !this.selectedNodeId) { + return false; + } + + const node = this.graph.nodes.get(this.selectedNodeId); + if (!node) { + return false; + } + + let connected = false; + + const pins = [ + ...node.outputs.filter((pin) => + this.#isAutoConnectStartPinAvailable(node.id, pin) + ), + ...node.inputs.filter((pin) => + this.#isAutoConnectStartPinAvailable(node.id, pin) + ), + ]; + + for (const pin of pins) { + const candidates = this.#findAutoConnectTargets(node, pin); + for (const candidate of candidates) { + if (candidate.pruneExistingConnections) { + this.graph.removeConnectionsForPin({ + nodeId: candidate.nodeId, + pinId: candidate.pinId, + }); + } + const success = + pin.direction === "output" + ? this.graph.connect( + { nodeId: node.id, pinId: pin.id }, + { nodeId: candidate.nodeId, pinId: candidate.pinId } + ) + : this.graph.connect( + { nodeId: candidate.nodeId, pinId: candidate.pinId }, + { nodeId: node.id, pinId: pin.id } + ); + if (success) { + connected = true; + break; + } + } + } + + return connected; + } + + /** + * Evaluates if a pin is eligible to initiate an auto-connect attempt. + * + * @param {string} nodeId Node identifier for the pin owner. + * @param {import('../core/BlueprintNode.js').PinDescriptor} pin Pin descriptor to inspect. + * @returns {boolean} + */ + #isAutoConnectStartPinAvailable(nodeId, pin) { + const connections = this.graph.getConnectionsForNode(nodeId, pin.id); + return connections.length === 0; + } + + /** + * Gathers compatible target pins ordered by proximity to the starting pin. + * + * @param {import('../core/BlueprintNode.js').BlueprintNode} startNode Node containing the starting pin. + * @param {import('../core/BlueprintNode.js').PinDescriptor} startPin Starting pin descriptor. + * @returns {Array<{ nodeId: string, pinId: string, distance: number }>} + */ + #findAutoConnectTargets(startNode, startPin) { + const startCenter = this.#getPinHandleCenter( + startNode.id, + startPin.id, + startPin.direction + ); + if (!startCenter) { + return []; + } + + const expectedDirection = + startPin.direction === "output" ? "input" : "output"; + /** @type {Array<{ nodeId: string, distance: number, pins: Array<{ pinId: string, pruneExistingConnections: boolean }> }> } */ + const nodes = []; + + this.graph.nodes.forEach((candidateNode) => { + if (candidateNode.id === startNode.id) { + return; + } + + const pins = + expectedDirection === "input" + ? candidateNode.inputs + : candidateNode.outputs; + /** @type {Array<{ pinId: string, pruneExistingConnections: boolean }>} */ + const compatiblePins = []; + let nodeDistance = Infinity; + + pins.forEach((candidatePin) => { + if ( + !this.#canPinsConnect( + startNode, + startPin, + startPin.direction, + candidateNode, + candidatePin + ) + ) { + return; + } + + const candidateConnections = this.graph.getConnectionsForNode( + candidateNode.id, + candidatePin.id + ); + + let pruneExistingConnections = false; + + if (startPin.direction === "output") { + if (candidateConnections.length > 0) { + if (candidatePin.kind === "exec") { + pruneExistingConnections = true; + } else { + return; + } + } + } else if ( + candidatePin.kind !== "exec" && + candidateConnections.length > 0 + ) { + return; + } + + const candidateCenter = this.#getPinHandleCenter( + candidateNode.id, + candidatePin.id, + candidatePin.direction + ); + if (!candidateCenter) { + return; + } + + const distance = Math.hypot( + candidateCenter.x - startCenter.x, + candidateCenter.y - startCenter.y + ); + if (distance > AUTO_CONNECT_DISTANCE) { + return; + } + + if (distance < nodeDistance) { + nodeDistance = distance; + } + + compatiblePins.push({ + pinId: candidatePin.id, + pruneExistingConnections, + }); + }); + + if (compatiblePins.length) { + nodes.push({ + nodeId: candidateNode.id, + distance: nodeDistance, + pins: compatiblePins, + }); + } + }); + /** @type {Array<{ nodeId: string, pinId: string, distance: number, pruneExistingConnections: boolean }> } */ + nodes.sort((a, b) => a.distance - b.distance); + + /** @type {Array<{ nodeId: string, pinId: string, distance: number }> } */ + const ordered = []; + nodes.forEach((entry) => { + entry.pins.forEach((pin) => { + ordered.push({ + pruneExistingConnections: pin.pruneExistingConnections, + nodeId: entry.nodeId, + pinId: pin.pinId, + distance: entry.distance, + }); + }); + }); + + return ordered; + } + + /** + * Begins a connection gesture from the provided pin. + * + * @param {PointerEvent} event Pointer event reference. + * @param {string} nodeId Node identifier. + * @param {string} pinId Pin identifier. + * @param {'input'|'output'} direction Pin direction. + */ + #beginConnection(event, nodeId, pinId, direction) { + const node = this.graph.nodes.get(nodeId); + if (!node) { + return; + } + + const pin = node.getPin(pinId); + if (!pin) { + return; + } + + event.stopPropagation(); + this.pendingConnectionSpawn = null; + const path = this.#createConnectionPath(pin.kind); + this.pendingConnection = { + direction, + source: direction === "output" ? { nodeId, pinId } : undefined, + target: direction === "input" ? { nodeId, pinId } : undefined, + path, + }; + + this.#highlightPinsForPending(nodeId, pinId, direction); + + const handlePointerMove = (moveEvent) => { + this.#updatePendingConnectionPath( + moveEvent.clientX, + moveEvent.clientY, + nodeId, + pinId, + direction + ); + }; + + const handlePointerUp = (upEvent) => { + if (upEvent.pointerId !== event.pointerId) { + return; + } + + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + + const openedMenu = this.#tryOpenPendingConnectionMenu(upEvent); + if (!openedMenu) { + this.#cancelPendingConnection(); + } + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + } + + /** + * Updates the temporary connection path during a drag gesture. + * + * @param {number} clientX Pointer X coordinate. + * @param {number} clientY Pointer Y coordinate. + * @param {string} nodeId Node identifier. + * @param {string} pinId Pin identifier. + * @param {'input'|'output'} direction Pin direction. + */ + #updatePendingConnectionPath(clientX, clientY, nodeId, pinId, direction) { + if (!this.pendingConnection) { + return; + } + + const startHandle = this.#getPinHandle(nodeId, pinId, direction); + if (!startHandle) { + return; + } + + const workspaceRect = this.workspaceElement.getBoundingClientRect(); + const startRect = startHandle.getBoundingClientRect(); + const zoom = this.zoomLevel || 1; + const anchor = { + x: + (startRect.left - workspaceRect.left + startRect.width / 2) / + zoom, + y: + (startRect.top - workspaceRect.top + startRect.height / 2) / + zoom, + }; + const pointer = { + x: (clientX - workspaceRect.left) / zoom, + y: (clientY - workspaceRect.top) / zoom, + }; + + const start = direction === "output" ? anchor : pointer; + const end = direction === "output" ? pointer : anchor; + + const controlOffset = Math.max(60 / zoom, Math.abs(end.x - start.x) * 0.5); + const d = `M ${start.x} ${start.y} C ${start.x + controlOffset} ${ + start.y + } ${end.x - controlOffset} ${end.y} ${end.x} ${end.y}`; + this.pendingConnection.path.dataset.active = "true"; + this.pendingConnection.path.setAttribute("d", d); + } + + /** + * Finalizes a pending connection if the gesture ends on a compatible pin. + * + * @param {string} nodeId Node identifier for the target pin. + * @param {string} pinId Pin identifier for the target pin. + * @param {'input'|'output'} direction Pin direction. + */ + #finalizeConnection(nodeId, pinId, direction) { + if (!this.pendingConnection) { + return; + } + + const { source, target } = this.pendingConnection; + if (direction === "input" && source) { + this.graph.connect(source, { nodeId, pinId }); + } else if (direction === "output" && target) { + this.graph.connect({ nodeId, pinId }, target); + } + + this.#cancelPendingConnection(); + } + + /** + * Cancels the current pending connection gesture. + */ + #cancelPendingConnection() { + if (this.pendingConnection) { + this.pendingConnection.path.remove(); + this.pendingConnection = null; + } + this.#clearPinHighlights(); + } + + /** + * Opens the context menu using compatible node definitions when a connection drag ends without a target. + * + * @param {PointerEvent} upEvent Pointer event containing the release coordinates. + * @returns {boolean} Whether a menu was shown. + */ + #tryOpenPendingConnectionMenu(upEvent) { + if (!this.pendingConnection) { + return false; + } + + const startRef = + this.pendingConnection.source ?? this.pendingConnection.target; + if (!startRef) { + return false; + } + + const startNode = this.graph.nodes.get(startRef.nodeId); + if (!startNode) { + return false; + } + + const startPin = startNode.getPin(startRef.pinId); + if (!startPin) { + return false; + } + + const compatibleDefinitions = this.#getCompatibleDefinitionsForPending( + startPin, + this.pendingConnection.direction + ); + if (!compatibleDefinitions.length) { + return false; + } + + this.pendingConnectionSpawn = { + startRef: { nodeId: startRef.nodeId, pinId: startRef.pinId }, + direction: this.pendingConnection.direction, + kind: startPin.kind, + }; + + const kindLabel = typeof startPin.kind === "string" ? startPin.kind : ""; + const friendlyKind = kindLabel + ? kindLabel.charAt(0).toUpperCase() + kindLabel.slice(1) + : ""; + const placeholder = friendlyKind + ? `Compatible ${friendlyKind} nodes` + : "Search nodes"; + + this.#cancelPendingConnection(); + this.contextMenuManager.show(upEvent.clientX, upEvent.clientY, { + definitions: compatibleDefinitions, + query: kindLabel, + mode: "connection", + placeholder, + }); + + return true; + } + + /** + * Identifies node definitions that can connect to the active pending connection. + * + * @param {import('../core/BlueprintNode.js').PinDescriptor} startPin Starting pin descriptor. + * @param {'input'|'output'} startDirection Starting pin direction. + * @returns {Array} + */ + #getCompatibleDefinitionsForPending(startPin, startDirection) { + const expectedDirection = startDirection === "output" ? "input" : "output"; + return this.registry.list().filter((definition) => { + const pins = + expectedDirection === "input" ? definition.inputs : definition.outputs; + return pins.some((candidatePin) => { + if (startDirection === "output") { + return this.#arePinKindsCompatible(startPin.kind, candidatePin.kind); + } + return this.#arePinKindsCompatible(candidatePin.kind, startPin.kind); + }); + }); + } + + /** + * Connects a newly spawned node to the pin that initiated the spawn request. + * + * @param {BlueprintNode} node Newly created node. + * @param {{ startRef: { nodeId: string, pinId: string }, direction: 'input'|'output', kind: string }} spawnInfo Connection spawn metadata. + */ + #connectSpawnedNodeToPending(node, spawnInfo) { + if (!spawnInfo) { + return; + } + + const { startRef, direction } = spawnInfo; + const startNode = this.graph.nodes.get(startRef.nodeId); + if (!startNode) { + return; + } + + const startPin = startNode.getPin(startRef.pinId); + if (!startPin) { + return; + } + + const candidatePins = direction === "output" ? node.inputs : node.outputs; + const compatiblePin = candidatePins.find((pin) => { + if (direction === "output") { + return this.#arePinKindsCompatible(startPin.kind, pin.kind); + } + return this.#arePinKindsCompatible(pin.kind, startPin.kind); + }); + + if (!compatiblePin) { + return; + } + + if (direction === "output") { + this.graph.connect( + { nodeId: startRef.nodeId, pinId: startRef.pinId }, + { nodeId: node.id, pinId: compatiblePin.id } + ); + } else { + this.graph.connect( + { nodeId: node.id, pinId: compatiblePin.id }, + { nodeId: startRef.nodeId, pinId: startRef.pinId } + ); + } + } + + /** + * Creates a connection path element styled for the given kind. + * + * @param {string} kind Pin kind. + * @returns {SVGPathElement} + */ + #createConnectionPath(kind) { + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.classList.add("connection-path"); + path.dataset.type = kind; + this.connectionLayer.appendChild(path); + return path; + } + + /** + * Requests a connection re-render on the next animation frame. + */ + scheduleConnectionRefresh() { + this.#scheduleConnectionRefresh(); + } + + /** + * Cancels any pending connection refresh. + */ + cancelConnectionRefresh() { + this.#cancelConnectionRefresh(); + } + + /** + * Forces a connection refresh immediately. + */ + flushConnectionRefresh() { + this.#flushConnectionRefresh(); + } + + /** + * Requests a connection re-render on the next animation frame. + */ + #scheduleConnectionRefresh() { + if (this.connectionRefreshFrame !== null) { + return; + } + + this.connectionRefreshFrame = window.requestAnimationFrame(() => { + this.connectionRefreshFrame = null; + this.#renderConnections(); + }); + } + + /** + * Cancels a pending connection refresh request. + */ + #cancelConnectionRefresh() { + if (this.connectionRefreshFrame !== null) { + window.cancelAnimationFrame(this.connectionRefreshFrame); + this.connectionRefreshFrame = null; + } + } + + /** + * Executes any pending connection refresh immediately. + */ + #flushConnectionRefresh() { + if (this.connectionRefreshFrame !== null) { + window.cancelAnimationFrame(this.connectionRefreshFrame); + this.connectionRefreshFrame = null; + } + this.#renderConnections(); + } + + /** + * Re-renders all connection paths to match their origin nodes. + */ + #renderConnections() { + const zoom = this.zoomLevel || 1; + const workspaceRect = this.workspaceElement.getBoundingClientRect(); + if (this.connectionLayer instanceof SVGElement) { + const targetWidth = Math.max(1, workspaceRect.width / zoom); + const targetHeight = Math.max(1, workspaceRect.height / zoom); + this.connectionLayer.style.width = `${targetWidth}px`; + this.connectionLayer.style.height = `${targetHeight}px`; + if (this.connectionLayer.hasAttribute("viewBox")) { + this.connectionLayer.removeAttribute("viewBox"); + } + if (this.connectionLayer.hasAttribute("preserveAspectRatio")) { + this.connectionLayer.removeAttribute("preserveAspectRatio"); + } + } + const activeIds = new Set(); + this.graph.getConnections().forEach((connection) => { + activeIds.add(connection.id); + let path = this.connectionElements.get(connection.id); + if (!path) { + path = this.#createConnectionPath(connection.kind); + this.connectionElements.set(connection.id, path); + } + + path.dataset.type = connection.kind; + path.dataset.connectionId = connection.id; + const geometry = this.#computeConnectionGeometry( + connection.from, + connection.to + ); + if (!geometry) { + return; + } + + const { start, end } = geometry; + const controlOffset = Math.max(60 / zoom, Math.abs(end.x - start.x) * 0.5); + const d = `M ${start.x} ${start.y} C ${start.x + controlOffset} ${ + start.y + } ${end.x - controlOffset} ${end.y} ${end.x} ${end.y}`; + path.dataset.active = "false"; + path.setAttribute("d", d); + }); + + this.connectionElements.forEach((path, id) => { + if (!activeIds.has(id)) { + path.remove(); + this.connectionElements.delete(id); + } + }); + } + + /** + * Computes the screen-space geometry for a connection between two pins. + * + * @param {{nodeId:string,pinId:string}} from Origin pin reference. + * @param {{nodeId:string,pinId:string}} to Target pin reference. + * @returns {{start:{x:number,y:number},end:{x:number,y:number}} | undefined} + */ + #computeConnectionGeometry(from, to) { + const startHandle = this.#getPinHandle(from.nodeId, from.pinId, "output"); + const endHandle = this.#getPinHandle(to.nodeId, to.pinId, "input"); + if (!startHandle || !endHandle) { + return undefined; + } + + const workspaceRect = this.workspaceElement.getBoundingClientRect(); + const zoom = this.zoomLevel || 1; + const startRect = startHandle.getBoundingClientRect(); + const endRect = endHandle.getBoundingClientRect(); + return { + start: { + x: + (startRect.left - workspaceRect.left + startRect.width / 2) / + zoom, + y: + (startRect.top - workspaceRect.top + startRect.height / 2) / + zoom, + }, + end: { + x: + (endRect.left - workspaceRect.left + endRect.width / 2) / zoom, + y: + (endRect.top - workspaceRect.top + endRect.height / 2) / zoom, + }, + }; + } + + /** + * Resolves a pin handle element given node and pin identifiers. + * + * @param {string} nodeId Node identifier. + * @param {string} pinId Pin identifier. + * @param {'input'|'output'} direction Pin direction. + * @returns {HTMLElement | null} + */ + #getPinHandle(nodeId, pinId, direction) { + const element = this.nodeElements.get(nodeId); + if (!element) { + return null; + } + + return element.querySelector( + `.pin[data-pin-id="${pinId}"][data-direction="${direction}"] .pin-handle` + ); + } + + /** + * Resolves the workspace-relative center point for a pin handle. + * + * @param {string} nodeId Node identifier. + * @param {string} pinId Pin identifier. + * @param {'input'|'output'} direction Pin direction. + * @returns {{x:number,y:number} | null} + */ + #getPinHandleCenter(nodeId, pinId, direction) { + const handle = this.#getPinHandle(nodeId, pinId, direction); + if (!handle) { + return null; + } + + const rect = handle.getBoundingClientRect(); + const workspaceRect = this.workspaceElement.getBoundingClientRect(); + const zoom = this.zoomLevel || 1; + + return { + x: + (rect.left - workspaceRect.left + rect.width / 2) / + zoom, + y: + (rect.top - workspaceRect.top + rect.height / 2) / + zoom, + }; + } + + /** + * Retrieves the pin container element for a given node and pin. + * + * @param {string} nodeId Node identifier. + * @param {string} pinId Pin identifier. + * @param {'input'|'output'} direction Pin direction. + * @returns {HTMLElement | null} + */ + #getPinElement(nodeId, pinId, direction) { + const element = this.nodeElements.get(nodeId); + if (!element) { + return null; + } + + return element.querySelector( + `.pin[data-pin-id="${pinId}"][data-direction="${direction}"]` + ); + } + + /** + * Highlights valid drop targets for the active pending connection. + * + * @param {string} nodeId Starting node identifier. + * @param {string} pinId Starting pin identifier. + * @param {'input'|'output'} direction Pin direction used to initiate the drag. + */ + #highlightPinsForPending(nodeId, pinId, direction) { + this.#clearPinHighlights(); + + const startNode = this.graph.nodes.get(nodeId); + if (!startNode) { + return; + } + + const startPin = startNode.getPin(pinId); + if (!startPin) { + return; + } + + const startElement = this.#getPinElement(nodeId, pinId, direction); + if (startElement) { + startElement.classList.add("is-drag-source"); + this.highlightedPins.add(startElement); + } + + const expectedDirection = direction === "output" ? "input" : "output"; + this.graph.nodes.forEach((candidateNode) => { + if (candidateNode.id === nodeId) { + return; + } + + const pins = + expectedDirection === "input" + ? candidateNode.inputs + : candidateNode.outputs; + pins.forEach((candidatePin) => { + if ( + this.#canPinsConnect( + startNode, + startPin, + direction, + candidateNode, + candidatePin + ) + ) { + const element = this.#getPinElement( + candidateNode.id, + candidatePin.id, + candidatePin.direction + ); + if (element) { + element.classList.add("is-drop-target"); + this.highlightedPins.add(element); + } + } + }); + }); + } + + /** + * Determines whether a pin should display hover styling as a drop target. + * + * @param {string} nodeId Node identifier. + * @param {string} pinId Pin identifier. + * @param {'input'|'output'} direction Pin direction. + * @returns {boolean} + */ + #shouldHighlightHover(nodeId, pinId, direction) { + return this.#isPinCompatibleWithPending(nodeId, pinId, direction); + } + + /** + * Checks if a pin is compatible with the current pending connection. + * + * @param {string} nodeId Candidate node identifier. + * @param {string} pinId Candidate pin identifier. + * @param {'input'|'output'} direction Candidate pin direction. + * @returns {boolean} + */ + #isPinCompatibleWithPending(nodeId, pinId, direction) { + if (!this.pendingConnection) { + return false; + } + + const startRef = + this.pendingConnection.source ?? this.pendingConnection.target; + if (!startRef) { + return false; + } + + if (startRef.nodeId === nodeId && startRef.pinId === pinId) { + return false; + } + + const startNode = this.graph.nodes.get(startRef.nodeId); + const candidateNode = this.graph.nodes.get(nodeId); + if (!startNode || !candidateNode) { + return false; + } + + const startPin = startNode.getPin(startRef.pinId); + const candidatePin = candidateNode.getPin(pinId); + if (!startPin || !candidatePin) { + return false; + } + + return this.#canPinsConnect( + startNode, + startPin, + this.pendingConnection.direction, + candidateNode, + candidatePin + ); + } + + /** + * Removes highlight classes from every tracked pin element. + */ + #clearPinHighlights() { + this.highlightedPins.forEach((element) => { + element.classList.remove( + "is-drag-source", + "is-drop-target", + "is-drop-hover" + ); + }); + this.highlightedPins.clear(); + } + + /** + * Determines if two pins can connect based on direction and kind. + * + * @param {BlueprintNode} startNode Starting node. + * @param {import('../core/BlueprintNode.js').PinDescriptor} startPin Starting pin descriptor. + * @param {'input'|'output'} startDirection Direction of the starting pin. + * @param {BlueprintNode} candidateNode Candidate node. + * @param {import('../core/BlueprintNode.js').PinDescriptor} candidatePin Candidate pin descriptor. + * @returns {boolean} + */ + #canPinsConnect( + startNode, + startPin, + startDirection, + candidateNode, + candidatePin + ) { + if (startNode.id === candidateNode.id) { + return false; + } + + const expectedDirection = startDirection === "output" ? "input" : "output"; + if (candidatePin.direction !== expectedDirection) { + return false; + } + + let outputKind; + let inputKind; + if (startDirection === "output") { + outputKind = startPin.kind; + inputKind = candidatePin.kind; + } else { + outputKind = candidatePin.kind; + inputKind = startPin.kind; + } + + return this.#arePinKindsCompatible(outputKind, inputKind); + } + + /** + * Determines if the output and input kinds are compatible under blueprint rules. + * + * @param {string} outputKind Output pin kind. + * @param {string} inputKind Input pin kind. + * @returns {boolean} + */ + #arePinKindsCompatible(outputKind, inputKind) { + if (inputKind === "any" || outputKind === "any") { + return true; + } + return outputKind === inputKind; + } + + /** + * Renders inspector controls for the selected node. + * + * @param {string} nodeId Node identifier. + */ + #renderInspector(nodeId) { + const node = this.graph.nodes.get(nodeId); + if (!node) { + this.inspectorContent.innerHTML = ""; + return; + } + + const definition = this.registry.get(node.type); + if (!definition) { + return; + } + + this.inspectorContent.innerHTML = ""; + + if (node.type === "custom_event") { + this.#renderCustomEventInspector(node); + return; + } + + if (node.type === "sequence") { + this.#renderSequenceInspector(node); + return; + } + + if (node.type === "call_custom_event") { + this.#renderCallCustomEventInspector(node); + return; + } + + if (!definition.properties.length) { + const empty = document.createElement("p"); + empty.textContent = "No editable properties."; + empty.className = "inspector-empty"; + this.inspectorContent.appendChild(empty); + return; + } + + definition.properties.forEach((schema) => { + const field = document.createElement("div"); + field.className = "inspector-field"; + const label = document.createElement("label"); + label.textContent = schema.label; + label.setAttribute("for", `${nodeId}_${schema.key}`); + field.appendChild(label); + + let control; + switch (schema.type) { + case "string": + control = document.createElement("input"); + control.type = "text"; + control.value = String( + node.properties[schema.key] ?? schema.defaultValue ?? "" + ); + if (schema.placeholder) { + control.placeholder = schema.placeholder; + } + break; + case "number": + control = document.createElement("input"); + control.type = "number"; + control.value = String( + node.properties[schema.key] ?? schema.defaultValue ?? 0 + ); + break; + case "boolean": + control = document.createElement("input"); + control.type = "checkbox"; + control.checked = Boolean(node.properties[schema.key]); + break; + case "enum": + control = document.createElement("select"); + (schema.options ?? []).forEach((option) => { + const opt = document.createElement("option"); + opt.value = option.value; + opt.textContent = option.label; + control.appendChild(opt); + }); + control.value = String( + node.properties[schema.key] ?? schema.defaultValue ?? "" + ); + break; + case "multiline": + control = document.createElement("textarea"); + control.rows = 3; + control.value = String( + node.properties[schema.key] ?? schema.defaultValue ?? "" + ); + break; + default: + control = document.createElement("input"); + control.type = "text"; + control.value = String(node.properties[schema.key] ?? ""); + break; + } + + control.id = `${nodeId}_${schema.key}`; + field.appendChild(control); + this.inspectorContent.appendChild(field); + + control.addEventListener("change", () => { + let value = control.value; + if (schema.type === "number") { + value = Number(control.value); + } else if (schema.type === "boolean") { + value = control.checked; + } + this.graph.setNodeProperty(nodeId, schema.key, value); + definition.onPropertiesChanged?.(node.properties); + if (schema.type === "enum") { + window.requestAnimationFrame(() => { + if (document.activeElement === control && typeof control.blur === "function") { + control.blur(); + } + this.#focusWorkspaceSurface({ force: true }); + }); + } + }); + + if (schema.type === "enum") { + control.addEventListener("blur", () => { + this.#focusWorkspaceSurface(); + }); + } + + const shouldFocus = + this.pendingInspectorFocus && + this.pendingInspectorFocus.nodeId === nodeId && + this.pendingInspectorFocus.property === schema.key; + if (shouldFocus && typeof control.focus === "function") { + requestAnimationFrame(() => { + control.focus(); + if ("select" in control && typeof control.select === "function") { + control.select(); + } + }); + this.pendingInspectorFocus = null; + } + }); + } + + /** + * Renders inspector controls for sequence nodes. + * + * @param {import('../core/BlueprintNode.js').BlueprintNode} node Sequence node reference. + */ + #renderSequenceInspector(node) { + this.inspectorContent.innerHTML = ""; + + const { branches } = this.#ensureSequenceBranches(node); + + const header = document.createElement("div"); + header.className = "sequence-branch-header"; + + const title = document.createElement("div"); + title.className = "inspector-section-title"; + title.textContent = "Execution Outputs"; + header.appendChild(title); + + const addButton = document.createElement("button"); + addButton.type = "button"; + addButton.className = "sequence-branch-add"; + addButton.textContent = "Add Output"; + addButton.addEventListener("click", () => { + this.#addSequenceBranch(node.id); + }); + header.appendChild(addButton); + + this.inspectorContent.appendChild(header); + + const list = document.createElement("div"); + list.className = "sequence-branch-list"; + + if (!branches.length) { + const empty = document.createElement("p"); + empty.className = "inspector-empty"; + empty.textContent = "No outputs configured."; + list.appendChild(empty); + } else { + branches.forEach((branch, index) => { + const row = document.createElement("div"); + row.className = "sequence-branch-row"; + row.dataset.branchId = branch.id; + + const label = document.createElement("span"); + label.className = "sequence-branch-label"; + label.textContent = this.#formatSequenceBranchLabel(index); + row.appendChild(label); + + const removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.className = + "overview-item-action overview-item-action--remove sequence-branch-remove"; + removeButton.title = `Delete output ${label.textContent}`; + removeButton.setAttribute( + "aria-label", + `Delete output ${label.textContent}` + ); + removeButton.disabled = branches.length <= 1; + removeButton.addEventListener("click", () => { + this.#removeSequenceBranch(node.id, branch.id); + }); + removeButton.addEventListener("pointerdown", (event) => { + event.stopPropagation(); + }); + row.appendChild(removeButton); + + list.appendChild(row); + }); + } + + this.inspectorContent.appendChild(list); + } + + /** + * Renders inspector controls for a custom event node. + * + * @param {import('../core/BlueprintNode.js').BlueprintNode} node Node instance to inspect. + */ + #renderCustomEventInspector(node) { + this.inspectorContent.innerHTML = ""; + + const pendingFocus = + this.pendingInspectorFocus && + this.pendingInspectorFocus.nodeId === node.id + ? this.pendingInspectorFocus.property + : null; + + const nameField = document.createElement("div"); + nameField.className = "inspector-field"; + + const nameLabel = document.createElement("label"); + nameLabel.setAttribute("for", `${node.id}_eventName`); + nameLabel.textContent = "Event Name"; + nameField.appendChild(nameLabel); + + const nameInput = document.createElement("input"); + nameInput.type = "text"; + nameInput.id = `${node.id}_eventName`; + nameInput.placeholder = "Enter event name"; + nameInput.value = + typeof node.properties.name === "string" ? node.properties.name : ""; + nameInput.addEventListener("change", () => { + this.graph.setNodeProperty(node.id, "name", nameInput.value); + }); + nameField.appendChild(nameInput); + + this.inspectorContent.appendChild(nameField); + + if (pendingFocus === "name" && typeof nameInput.focus === "function") { + requestAnimationFrame(() => { + nameInput.focus(); + if (typeof nameInput.select === "function") { + nameInput.select(); + } + }); + this.pendingInspectorFocus = null; + } + + const header = document.createElement("div"); + header.className = "custom-event-parameter-header"; + + const title = document.createElement("div"); + title.className = "inspector-section-title"; + title.textContent = "Parameters"; + header.appendChild(title); + + const addButton = document.createElement("button"); + addButton.type = "button"; + addButton.className = "custom-event-parameter-add"; + addButton.textContent = "Add Parameter"; + addButton.addEventListener("click", () => { + const created = this.#addCustomEventParameter(node.id); + if (created) { + this.pendingInspectorFocus = { + nodeId: node.id, + property: `parameter:${created.id}`, + }; + } + }); + header.appendChild(addButton); + + this.inspectorContent.appendChild(header); + + const list = document.createElement("div"); + list.className = "custom-event-parameter-list"; + + const { parameters } = this.#sanitizeCustomEventParameters(node); + + if (!parameters.length) { + const empty = document.createElement("p"); + empty.className = "inspector-empty"; + empty.textContent = "No parameters defined."; + list.appendChild(empty); + } + + parameters.forEach((parameter) => { + const row = document.createElement("div"); + row.className = "custom-event-parameter-row"; + row.dataset.parameterId = parameter.id; + row.dataset.parameterType = parameter.type; + + const typeButton = document.createElement("button"); + typeButton.type = "button"; + typeButton.className = + "variable-type-button custom-event-parameter-type-button"; + typeButton.dataset.variableType = parameter.type; + typeButton.setAttribute("aria-haspopup", "menu"); + typeButton.setAttribute("aria-expanded", "false"); + row.appendChild(typeButton); + + const nameInputElement = document.createElement("input"); + nameInputElement.type = "text"; + nameInputElement.value = parameter.name; + nameInputElement.placeholder = "Parameter name"; + row.appendChild(nameInputElement); + + const refreshTypeLabel = (rawType) => { + const normalized = this.#normalizeVariableType(rawType); + const display = this.#formatVariableType(normalized); + const referenceName = nameInputElement.value.trim() || parameter.id; + row.dataset.parameterType = normalized; + typeButton.dataset.variableType = normalized; + typeButton.title = `Type: ${display}`; + typeButton.setAttribute( + "aria-label", + `Change type for ${referenceName} (currently ${display})` + ); + }; + + refreshTypeLabel(parameter.type); + + const dropdownContext = { + kind: "custom_event_parameter", + nodeId: node.id, + parameterId: parameter.id, + }; + + const openDropdown = () => { + this.#openCustomEventParameterTypeDropdown( + node.id, + parameter.id, + typeButton, + row, + (nextType) => { + refreshTypeLabel(nextType); + } + ); + }; + + typeButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + const isOpen = this.#isTypeDropdownOpen(dropdownContext); + if (isOpen) { + this.#closeTypeDropdown(); + } else { + openDropdown(); + } + }); + typeButton.addEventListener("pointerdown", (event) => { + event.stopPropagation(); + }); + typeButton.addEventListener("keydown", (event) => { + const isToggleKey = + event.key === "Enter" || event.key === " " || event.key === "Spacebar"; + if (isToggleKey || event.key === "ArrowDown") { + event.preventDefault(); + openDropdown(); + } else if (event.key === "Escape") { + event.preventDefault(); + this.#closeTypeDropdown(); + } + }); + + const optionalLabel = document.createElement("label"); + optionalLabel.className = "custom-event-parameter-optional"; + optionalLabel.title = "Optional"; + optionalLabel.setAttribute( + "aria-label", + `Toggle optional state for ${parameter.name || parameter.id}` + ); + + const optionalCheckbox = document.createElement("input"); + optionalCheckbox.type = "checkbox"; + optionalCheckbox.checked = Boolean(parameter.optional); + optionalCheckbox.setAttribute( + "aria-label", + `Optional parameter ${parameter.name || parameter.id}` + ); + optionalCheckbox.title = "Optional"; + optionalCheckbox.addEventListener("change", () => { + this.#setCustomEventParameterOptional( + node.id, + parameter.id, + optionalCheckbox.checked + ); + }); + optionalLabel.appendChild(optionalCheckbox); + row.appendChild(optionalLabel); + + const removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.className = + "overview-item-action overview-item-action--remove custom-event-parameter-action custom-event-parameter-action--remove"; + removeButton.setAttribute( + "aria-label", + `Delete parameter ${parameter.name || parameter.id}` + ); + removeButton.title = `Delete ${parameter.name || parameter.id}`; + removeButton.addEventListener("click", () => { + this.#closeTypeDropdown(); + this.#removeCustomEventParameter(node.id, parameter.id); + }); + removeButton.addEventListener("pointerdown", (event) => { + event.stopPropagation(); + }); + row.appendChild(removeButton); + + nameInputElement.addEventListener("change", () => { + this.#renameCustomEventParameter( + node.id, + parameter.id, + nameInputElement.value + ); + refreshTypeLabel(row.dataset.parameterType ?? parameter.type); + const trimmed = nameInputElement.value.trim(); + const labelText = trimmed || parameter.id; + removeButton.setAttribute( + "aria-label", + `Delete parameter ${labelText}` + ); + removeButton.title = `Delete ${labelText}`; + optionalLabel.setAttribute( + "aria-label", + `Toggle optional state for ${labelText}` + ); + optionalCheckbox.setAttribute( + "aria-label", + `Optional parameter ${labelText}` + ); + }); + + if ( + pendingFocus === `parameter:${parameter.id}` && + typeof nameInputElement.focus === "function" + ) { + requestAnimationFrame(() => { + nameInputElement.focus(); + if (typeof nameInputElement.select === "function") { + nameInputElement.select(); + } + }); + this.pendingInspectorFocus = null; + } + + list.appendChild(row); + }); + + this.inspectorContent.appendChild(list); + } + + /** + * Renders inspector controls for a call custom event node. + * + * @param {import('../core/BlueprintNode.js').BlueprintNode} node Node instance to inspect. + */ + #renderCallCustomEventInspector(node) { + const field = document.createElement("div"); + field.className = "inspector-field"; + const controlId = `${node.id}_eventId`; + + const label = document.createElement("label"); + label.setAttribute("for", controlId); + label.textContent = "Target Event"; + field.appendChild(label); + + const select = document.createElement("select"); + select.id = controlId; + + const events = this.#getCustomEventNodes() + .map((eventNode) => ({ + id: eventNode.id, + label: this.#resolveCustomEventLabel(eventNode), + })) + .sort((a, b) => a.label.localeCompare(b.label)); + + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = events.length + ? "Select event" + : "No custom events available"; + select.appendChild(placeholder); + + events.forEach((entry) => { + const option = document.createElement("option"); + option.value = entry.id; + option.textContent = entry.label; + select.appendChild(option); + }); + + const storedEventId = + typeof node.properties.eventId === "string" + ? node.properties.eventId + : ""; + if (storedEventId && events.some((entry) => entry.id === storedEventId)) { + select.value = storedEventId; + } else { + select.value = ""; + } + + select.disabled = events.length === 0; + select.addEventListener("change", () => { + const next = select.value; + const current = + typeof node.properties.eventId === "string" + ? node.properties.eventId + : ""; + if (next === current) { + return; + } + this.graph.setNodeProperty(node.id, "eventId", next); + }); + + field.appendChild(select); + this.inspectorContent.appendChild(field); + + if (!events.length) { + const helper = document.createElement("p"); + helper.className = "inspector-empty"; + helper.textContent = "Add a Custom Event node to call it here."; + this.inspectorContent.appendChild(helper); + } + + const shouldFocus = + this.pendingInspectorFocus && + this.pendingInspectorFocus.nodeId === node.id && + this.pendingInspectorFocus.property === "eventId"; + if (shouldFocus) { + requestAnimationFrame(() => { + select.focus(); + }); + this.pendingInspectorFocus = null; + } + } + + /** + * Regenerates and caches the Lua output for the current graph state. + * + * @returns {string} + */ + #refreshLuaOutput() { + try { + this.luaOutput = this.generator.generate(this.graph, { + variables: this.#getGeneratorVariableMetadata(), + }); + } catch (error) { + console.error("Failed to generate Lua", error); + this.luaOutput = "-- generation error"; + } + return this.luaOutput; + } + + /** + * Creates a serializable snapshot of the current workspace. + * + * @returns {SerializedWorkspace} + */ + serializeWorkspace() { + return this.persistenceManager.serializeWorkspace(); + } + + /** + * Applies a persisted workspace payload to the live workspace. + * + * @param {SerializedWorkspace} payload Persisted workspace data. + */ + applySerializedWorkspace(payload) { + this.isRestoring = true; + try { + this.persistenceManager.cancelScheduledPersist(); + + this.contextMenuManager.hide(); + this.#closeTypeDropdown(); + this.dragManager.removePaletteGhost(); + this.dragManager.removeNodeGhost(); + + this.nodeElements.forEach((element) => element.remove()); + this.connectionElements.forEach((path) => path.remove()); + this.nodeElements.clear(); + this.connectionElements.clear(); + this.inlineEditorManager.clear(); + this.nodeDragRotation.clear(); + this.dragManager.stopNodeRotationDecay(); + this.marqueeManager?.clear(); + this.#cancelConnectionRefresh(); + this.isDraggingNodes = false; + this.highlightedPins.clear(); + + this.pendingConnection = null; + this.pendingConnectionSpawn = null; + this.pendingVariableSpawn = null; + this.selectedNodeIds.clear(); + this.selectedNodeId = undefined; + this.selectedVariableId = null; + this.activeVariableRename = null; + + this.variables.clear(); + this.variableOrder = []; + this.variableCounter = 0; + + const variablesPayload = + payload && typeof payload.variables === "object" + ? payload.variables + : null; + if (variablesPayload && Array.isArray(variablesPayload.entries)) { + variablesPayload.entries.forEach((entry) => { + if (!entry || typeof entry !== "object") { + return; + } + const id = typeof entry.id === "string" ? entry.id : ""; + if (!id) { + return; + } + const name = + typeof entry.name === "string" && entry.name.trim() + ? entry.name + : id; + const type = + entry.type === "number" || + entry.type === "string" || + entry.type === "boolean" || + entry.type === "any" || + entry.type === "table" + ? entry.type + : "any"; + const defaultValue = this.#normalizeVariableDefault( + type, + entry.defaultValue + ); + this.variables.set(id, { id, name, type, defaultValue }); + this.variableOrder.push(id); + }); + const storedCounter = + typeof variablesPayload.counter === "number" && + Number.isFinite(variablesPayload.counter) + ? variablesPayload.counter + : this.variableOrder.length; + this.variableCounter = Math.max( + storedCounter, + this.variableOrder.length + ); + } + + const graphPayload = + payload && typeof payload.graph === "object" + ? payload.graph + : { nodes: [], connections: [] }; + this.graph = NodeGraph.fromJSON(graphPayload); + this.#bindGraphEvents(); + + this.spawnIndex = + typeof payload?.spawnIndex === "number" && + Number.isFinite(payload.spawnIndex) + ? payload.spawnIndex + : this.graph.nodes.size; + + this.#rebuildNodeCounters(); + + this.graph.nodes.forEach((node) => { + this.nodeRenderer.renderNode(node); + if (node.type === "sequence") { + this.#refreshSequenceNode(node.id); + } else if (node.type === "custom_event") { + this.#refreshCustomEventNode(node.id); + } else if (node.type === "call_custom_event") { + this.#refreshCustomEventCallNode(node.id); + } + }); + this.#renderConnections(); + + const settings = + payload && typeof payload.projectSettings === "object" + ? payload.projectSettings + : null; + if (settings) { + this.projectSettings = { + use60Fps: Boolean(settings.use60Fps), + }; + } + if (typeof this.generator.setProjectSettings === "function") { + this.generator.setProjectSettings({ ...this.projectSettings }); + } + + const uiSettings = + payload && typeof payload.ui === "object" ? payload.ui : null; + const paletteVisible = + typeof uiSettings?.paletteVisible === "boolean" + ? uiSettings.paletteVisible + : true; + this.isPaletteVisible = paletteVisible; + this.#applyPaletteVisibility(this.isPaletteVisible); + + this.navigationManager.setBackgroundOffset({ x: 0, y: 0 }); + this.#updateProjectSettingsToggle(); + this.#renderOverview(); + this.#setInspectorView("default"); + this.#applySelectionState(); + this.nodeRenderer.refreshAllPinConnections(); + this.#refreshLuaOutput(); + } finally { + this.isRestoring = false; + } + } + + /** + * Restores camera/view-related state from a history snapshot. + * + * @param {{ backgroundOffset: { x: number, y: number }, zoom: number }} view Snapshot view payload. + */ + applyHistoryView(view) { + if (!view) { + return; + } + + const normalizedZoom = Number.isFinite(view.zoom) ? view.zoom : this.zoomLevel; + const normalizedOffset = { + x: Number.isFinite(view.backgroundOffset?.x) + ? view.backgroundOffset.x + : this.workspaceBackgroundOffset?.x ?? 0, + y: Number.isFinite(view.backgroundOffset?.y) + ? view.backgroundOffset.y + : this.workspaceBackgroundOffset?.y ?? 0, + }; + + if (this.navigationManager) { + this.navigationManager.setZoomLevel(normalizedZoom, { silent: false }); + this.navigationManager.setBackgroundOffset(normalizedOffset); + this.navigationManager.updateZoomDisplay(); + } else { + this.zoomLevel = normalizedZoom; + this.workspaceBackgroundOffset = normalizedOffset; + } + } + + /** + * Restores selection state from a history snapshot. + * + * @param {{ nodes: Array, primary: string | null, variable: string | null }} selection Snapshot selection payload. + */ + applyHistorySelection(selection) { + if (!selection) { + return; + } + + const nodes = Array.isArray(selection.nodes) ? selection.nodes : []; + const primary = typeof selection.primary === "string" ? selection.primary : undefined; + const variable = typeof selection.variable === "string" ? selection.variable : null; + + if (variable) { + const exists = this.variables.has(variable); + this.selectedVariableId = exists ? variable : null; + } else { + this.selectedVariableId = null; + } + + this.setSelectionState(nodes, primary); + } + + /** + * Rebuilds node identifier counters from the current graph state. + */ + #rebuildNodeCounters() { + const counters = new Map(); + this.graph.nodes.forEach((node) => { + const match = /_(\d+)$/.exec(node.id); + const numeric = match ? Number.parseInt(match[1], 10) : 0; + const current = counters.get(node.type) ?? 0; + counters.set(node.type, Math.max(current, numeric)); + }); + this.graph.nodeCounters = counters; + } +} diff --git a/scripts/ui/BlueprintWorkspaceConstants.js b/scripts/ui/BlueprintWorkspaceConstants.js new file mode 100644 index 0000000..f1c3147 --- /dev/null +++ b/scripts/ui/BlueprintWorkspaceConstants.js @@ -0,0 +1,5 @@ +export const DEFAULT_GRID_SIZE = 20; +export const PALETTE_DRAG_MIME = "application/x-blueprint-node"; +export const VARIABLE_DRAG_MIME = "application/x-blueprint-variable"; +export const VARIABLE_SORT_MIME = "application/x-blueprint-variable-sort"; +export const WORKSPACE_STORAGE_KEY = "picograph:workspace"; diff --git a/scripts/ui/WorkspaceSpawnShortcuts.js b/scripts/ui/WorkspaceSpawnShortcuts.js new file mode 100644 index 0000000..14c262d --- /dev/null +++ b/scripts/ui/WorkspaceSpawnShortcuts.js @@ -0,0 +1,72 @@ +/** + * @typedef {'background'|'workspace'} WorkspaceShortcutContext + * @typedef {'shift'|'ctrl'|'alt'|'meta'} WorkspaceShortcutModifier + * @typedef {'spawnNode'|'command'} WorkspaceShortcutAction + * + * @typedef {Object} WorkspaceShortcut + * @property {string} key Primary keyboard key that must be held to activate the shortcut. + * @property {WorkspaceShortcutContext} context Interaction context in which the shortcut is valid. + * @property {WorkspaceShortcutAction} action Action the shortcut executes. + * @property {string} [definitionId] Node definition identifier (required when {@link action} is `spawnNode`). + * @property {string} [command] Workspace command identifier (required when {@link action} is `command`). + * @property {number} [button] Pointer button required for pointer-based shortcuts. Defaults to the primary button. + * @property {Array} [modifiers] Modifier keys that must be held alongside the primary key. + */ + +/** @type {Array} */ +export const WORKSPACE_SHORTCUTS = [ + { + key: "s", + context: "background", + action: "spawnNode", + definitionId: "sequence", + }, + { + key: "c", + context: "workspace", + action: "command", + command: "copy", + modifiers: ["ctrl"], + }, + { + key: "x", + context: "workspace", + action: "command", + command: "cut", + modifiers: ["ctrl"], + }, + { + key: "v", + context: "workspace", + action: "command", + command: "paste", + modifiers: ["ctrl"], + }, + { + key: "z", + context: "workspace", + action: "command", + command: "undo", + modifiers: ["ctrl"], + }, + { + key: "z", + context: "workspace", + action: "command", + command: "redo", + modifiers: ["ctrl", "shift"], + }, + { + key: "y", + context: "workspace", + action: "command", + command: "redo", + modifiers: ["ctrl"], + }, + { + key: "delete", + context: "workspace", + action: "command", + command: "delete", + }, +]; diff --git a/scripts/ui/workspace/WorkspaceClipboardManager.js b/scripts/ui/workspace/WorkspaceClipboardManager.js new file mode 100644 index 0000000..b400fd2 --- /dev/null +++ b/scripts/ui/workspace/WorkspaceClipboardManager.js @@ -0,0 +1,289 @@ +/** + * @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace + * @typedef {import('./WorkspaceHistoryManager.js').WorkspaceHistoryManager} WorkspaceHistoryManager + * @typedef {ReturnType} GraphSnapshot + */ + +const CLIPBOARD_OFFSET_STEP = 32; + +/** + * Manages copy and paste operations for selected nodes. + */ +export class WorkspaceClipboardManager { + #workspace; + #history; + /** @type {{ nodes: Array, connections: Array, pasteCount: number, bounds: { minX: number, maxX: number, minY: number, maxY: number } | null } | null} */ + #payload; + /** @type {{ x: number, y: number } | null} */ + #lastPointerPosition; + + /** + * @param {BlueprintWorkspace} workspace Owning workspace instance. + * @param {WorkspaceHistoryManager} history History manager for undo integration. + */ + constructor(workspace, history) { + this.#workspace = workspace; + this.#history = history; + this.#payload = null; + /** @type {{ x: number, y: number } | null} */ + this.#lastPointerPosition = null; + } + + /** + * Clears the clipboard payload. + */ + reset() { + this.#payload = null; + this.#lastPointerPosition = null; + } + + /** + * Copies the current node selection into the clipboard payload. + * + * @returns {boolean} Whether a payload was captured. + */ + copySelection() { + if (!this.#workspace.selectedNodeIds.size) { + return false; + } + + /** @type {Array} */ + const nodes = []; + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + let hasBounds = false; + + this.#workspace.selectedNodeIds.forEach((id) => { + const node = this.#workspace.graph.nodes.get(id); + if (!node) { + return; + } + + nodes.push(node.toJSON()); + + const pos = node.position; + if (!Number.isFinite(pos.x) || !Number.isFinite(pos.y)) { + return; + } + + const element = this.#workspace.nodeElements.get(id) ?? null; + const size = this.#measureNodeSize(element); + hasBounds = true; + minX = Math.min(minX, pos.x); + minY = Math.min(minY, pos.y); + maxX = Math.max(maxX, pos.x + size.width); + maxY = Math.max(maxY, pos.y + size.height); + }); + + if (!nodes.length) { + return false; + } + + const selectedIds = new Set(nodes.map((node) => node.id)); + const connections = this.#workspace.graph + .getConnections() + .filter( + (connection) => + selectedIds.has(connection.from.nodeId) && + selectedIds.has(connection.to.nodeId) + ) + .map((connection) => connection.toJSON()); + + const bounds = hasBounds ? { minX, maxX, minY, maxY } : null; + + this.#payload = { nodes, connections, pasteCount: 0, bounds }; + return true; + } + + /** + * Captures the latest pointer position within the workspace. + * + * @param {{ x: number, y: number } | null} position Workspace-space pointer coordinates. + */ + updatePointerPosition(position) { + if (!position || !Number.isFinite(position.x) || !Number.isFinite(position.y)) { + this.#lastPointerPosition = null; + return; + } + + this.#lastPointerPosition = { x: position.x, y: position.y }; + } + + /** + * Clears any stored pointer position hints. + */ + clearPointerPosition() { + this.#lastPointerPosition = null; + } + + /** + * Cuts the current node selection: copies it to the clipboard and removes the originals. + * + * @returns {boolean} Whether any nodes were cut. + */ + cutSelection() { + const copied = this.copySelection(); + if (!copied) { + return false; + } + + const targets = Array.from(this.#workspace.selectedNodeIds); + if (!targets.length) { + return false; + } + + this.#history.withSuspended( + () => { + targets.forEach((id) => this.#workspace.removeNode(id)); + }, + { suppressAutoCommit: true } + ); + + this.#history.commitCheckpoint("cut"); + return true; + } + + /** + * Pastes the clipboard payload, centering at the pointer when available. + */ + paste() { + if (!this.#payload || !this.#payload.nodes.length) { + return; + } + + const clipboard = this.#payload; + const bounds = clipboard.bounds ?? this.#calculatePayloadBounds(clipboard.nodes); + const anchor = this.#lastPointerPosition; + let delta; + + if (anchor && bounds) { + const center = { + x: (bounds.minX + bounds.maxX) / 2, + y: (bounds.minY + bounds.maxY) / 2, + }; + delta = { + x: anchor.x - center.x, + y: anchor.y - center.y, + }; + } else { + const offsetMultiplier = CLIPBOARD_OFFSET_STEP * (clipboard.pasteCount + 1); + delta = { x: offsetMultiplier, y: offsetMultiplier }; + } + + /** @type {Array} */ + const newNodeIds = []; + const idMap = new Map(); + + this.#history.withSuspended( + () => { + clipboard.nodes.forEach((nodeData) => { + const definition = this.#workspace.registry.get(nodeData.type); + if (!definition) { + return; + } + + const newId = this.#workspace.graph.createNodeId(nodeData.type); + const position = { + x: nodeData.position.x + delta.x, + y: nodeData.position.y + delta.y, + }; + + const node = this.#workspace.registry.createNode(nodeData.type, { + id: newId, + position, + }); + node.title = nodeData.title; + node.properties = { ...nodeData.properties }; + node.inputs = nodeData.inputs.map((pin) => ({ ...pin })); + node.outputs = nodeData.outputs.map((pin) => ({ ...pin })); + + this.#workspace.graph.addNode(node); + this.#workspace.renderNode(node); + idMap.set(nodeData.id, newId); + newNodeIds.push(newId); + }); + + clipboard.connections.forEach((connection) => { + const fromId = idMap.get(connection.from.nodeId); + const toId = idMap.get(connection.to.nodeId); + if (!fromId || !toId) { + return; + } + this.#workspace.graph.connect( + { nodeId: fromId, pinId: connection.from.pinId }, + { nodeId: toId, pinId: connection.to.pinId } + ); + }); + }, + { suppressAutoCommit: true } + ); + + if (newNodeIds.length) { + clipboard.pasteCount += 1; + this.#workspace.setSelectionState(newNodeIds, newNodeIds[0]); + this.#history.commitCheckpoint("paste"); + } + } + + /** + * Computes the bounding extents of nodes stored in the clipboard payload. + * + * @param {Array} nodes Clipboard node entries. + * @returns {{ minX: number, maxX: number, minY: number, maxY: number } | null} + */ + #calculatePayloadBounds(nodes) { + if (!nodes.length) { + return null; + } + + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + let hasValid = false; + + nodes.forEach((node) => { + const pos = node?.position; + if (!pos || !Number.isFinite(pos.x) || !Number.isFinite(pos.y)) { + return; + } + hasValid = true; + minX = Math.min(minX, pos.x); + minY = Math.min(minY, pos.y); + maxX = Math.max(maxX, pos.x); + maxY = Math.max(maxY, pos.y); + }); + + if (!hasValid) { + return null; + } + + return { minX, maxX, minY, maxY }; + } + + /** + * Measures a node element's size in workspace units. + * + * @param {HTMLElement | null} element Node DOM element reference. + * @returns {{ width: number, height: number }} + */ + #measureNodeSize(element) { + if (!element) { + return { width: 1, height: 1 }; + } + + const zoom = this.#workspace.zoomLevel || 1; + const rect = element.getBoundingClientRect(); + const width = rect.width / zoom; + const height = rect.height / zoom; + const fallbackWidth = element.offsetWidth || width || 1; + const fallbackHeight = element.offsetHeight || height || 1; + + return { + width: Number.isFinite(width) && width > 0 ? width : Math.max(1, fallbackWidth), + height: Number.isFinite(height) && height > 0 ? height : Math.max(1, fallbackHeight), + }; + } +} diff --git a/scripts/ui/workspace/WorkspaceContextMenuManager.js b/scripts/ui/workspace/WorkspaceContextMenuManager.js new file mode 100644 index 0000000..c31a76f --- /dev/null +++ b/scripts/ui/workspace/WorkspaceContextMenuManager.js @@ -0,0 +1,620 @@ +/** + * @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace + * @typedef {import('../../nodes/nodeTypes.js').NodeDefinition} NodeDefinition + * @typedef {import('../../nodes/nodeTypes.js').NodeDefinition & { + * shortcut?: ( + * { type: 'variable', action: 'get'|'set', variableId: string } | + * { type: 'custom_event_call', eventNodeId: string } + * ) + * }} PaletteNodeDefinition + */ + +const DEFAULT_PLACEHOLDER = "Search nodes"; + +/** + * Handles DOM management and interactions for the workspace context menu. + */ +export class WorkspaceContextMenuManager { + #workspace; + /** @type {HTMLDivElement | null} */ + #container; + /** @type {HTMLInputElement | null} */ + #searchInput; + /** @type {HTMLDivElement | null} */ + #list; + /** @type {boolean} */ + #isVisible; + /** @type {{ x: number, y: number }} */ + #spawnPosition; + /** @type {Array} */ + #results; + /** @type {Array | null} */ + #customResults; + /** @type {string} */ + #preSearchNormalized; + /** @type {'default'|'connection'|'variable'} */ + #mode; + /** @type {number} */ + #selectedIndex; + /** @type {string | null} */ + #selectedDefinitionId; + /** @type {Array} */ + #itemElements; + /** @type {string} */ + #lastNormalizedQuery; + + /** + * @param {BlueprintWorkspace} workspace Owning workspace instance. + */ + constructor(workspace) { + this.#workspace = workspace; + this.#container = null; + this.#searchInput = null; + this.#list = null; + this.#isVisible = false; + this.#spawnPosition = { x: 0, y: 0 }; + this.#results = []; + this.#customResults = null; + this.#preSearchNormalized = ""; + this.#mode = "default"; + this.#selectedIndex = -1; + this.#selectedDefinitionId = null; + this.#itemElements = []; + this.#lastNormalizedQuery = ""; + } + + /** + * Builds the context menu DOM if it does not already exist. + */ + initialize() { + if (this.#container) { + return; + } + + const container = document.createElement("div"); + container.className = "workspace-context-menu"; + container.setAttribute("role", "dialog"); + container.setAttribute("aria-label", "Add node"); + + const search = document.createElement("input"); + search.type = "search"; + search.className = "context-menu-search"; + search.placeholder = DEFAULT_PLACEHOLDER; + search.setAttribute("aria-label", "Search nodes"); + container.appendChild(search); + + const list = document.createElement("div"); + list.className = "context-menu-list"; + list.setAttribute("role", "listbox"); + container.appendChild(list); + + this.#workspace.workspaceElement.appendChild(container); + + search.addEventListener("input", () => { + this.#renderResults(search.value); + }); + + search.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + event.stopPropagation(); + this.hide(); + return; + } + if (event.key === "ArrowDown") { + event.preventDefault(); + this.#moveSelection(1); + this.#focusSelection(); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + this.#moveSelection(-1); + this.#focusSelection(); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + this.#selectSelection(); + } + }); + + document.addEventListener("pointerdown", (event) => { + if (!this.#isVisible) { + return; + } + if (!(event.target instanceof Node)) { + this.hide(); + return; + } + if (!container.contains(event.target)) { + this.hide(); + } + }); + + document.addEventListener("keydown", (event) => { + if (!this.#isVisible) { + return; + } + if (event.key === "Escape") { + event.preventDefault(); + this.hide(); + } + }); + + this.#container = container; + this.#searchInput = search; + this.#list = list; + } + + /** + * Determines whether the context menu is currently displayed. + * + * @returns {boolean} + */ + isVisible() { + return this.#isVisible; + } + + /** + * Checks whether the provided target node resides inside the context menu. + * + * @param {Node | null} target Target node. + * @returns {boolean} + */ + isTargetInside(target) { + return Boolean( + this.#container && target && this.#container.contains(target) + ); + } + + /** + * Displays the context menu at the given viewport coordinates. + * + * @param {number} clientX Viewport X coordinate. + * @param {number} clientY Viewport Y coordinate. + * @param {{ definitions?: Array, placeholder?: string, query?: string, mode?: 'default'|'connection'|'variable' }} [options] + */ + show(clientX, clientY, options = {}) { + this.initialize(); + + if (!this.#container || !this.#searchInput) { + return; + } + + const workspaceRect = + this.#workspace.workspaceElement.getBoundingClientRect(); + const relativeX = Math.max(0, clientX - workspaceRect.left); + const relativeY = Math.max(0, clientY - workspaceRect.top); + + this.#spawnPosition = { x: relativeX, y: relativeY }; + this.#customResults = options.definitions ?? null; + this.#preSearchNormalized = (options.query ?? "").trim().toLowerCase(); + this.#mode = options.mode ?? "default"; + this.#selectedIndex = -1; + this.#selectedDefinitionId = null; + + if (this.#mode !== "connection") { + this.#workspace.pendingConnectionSpawn = null; + } + if (this.#mode !== "variable") { + this.#workspace.pendingVariableSpawn = null; + } + + const placeholder = options.placeholder ?? DEFAULT_PLACEHOLDER; + if (this.#searchInput.placeholder !== placeholder) { + this.#searchInput.placeholder = placeholder; + } + + this.#searchInput.value = options.query ?? ""; + this.#renderResults(this.#searchInput.value); + + this.#container.classList.add("is-visible"); + this.#container.style.left = `${relativeX}px`; + this.#container.style.top = `${relativeY}px`; + this.#isVisible = true; + + const menuRect = this.#container.getBoundingClientRect(); + let adjustedLeft = relativeX; + let adjustedTop = relativeY; + + if (menuRect.right > workspaceRect.right) { + adjustedLeft -= menuRect.right - workspaceRect.right; + } + if (menuRect.bottom > workspaceRect.bottom) { + adjustedTop -= menuRect.bottom - workspaceRect.bottom; + } + + adjustedLeft = Math.max(0, adjustedLeft); + adjustedTop = Math.max(0, adjustedTop); + + this.#container.style.left = `${adjustedLeft}px`; + this.#container.style.top = `${adjustedTop}px`; + + requestAnimationFrame(() => { + this.#searchInput?.focus(); + this.#searchInput?.select(); + }); + } + + /** + * Conceals the context menu and clears its state. + */ + hide() { + if (!this.#container || !this.#isVisible) { + return; + } + + this.#container.classList.remove("is-visible"); + this.#isVisible = false; + this.#customResults = null; + this.#mode = "default"; + this.#preSearchNormalized = ""; + this.#selectedIndex = -1; + this.#selectedDefinitionId = null; + this.#itemElements = []; + this.#results = []; + this.#spawnPosition = { x: 0, y: 0 }; + if (this.#searchInput) { + this.#searchInput.placeholder = DEFAULT_PLACEHOLDER; + this.#searchInput.value = ""; + } + this.#workspace.pendingConnectionSpawn = null; + } + + /** + * Renders context menu results using the supplied query. + * + * @param {string} query Search query. + */ + #renderResults(query) { + if (!this.#list) { + return; + } + + let effectiveQuery = query ?? ""; + const normalizedQuery = effectiveQuery.trim().toLowerCase(); + const queryChanged = normalizedQuery !== this.#lastNormalizedQuery; + if ( + this.#customResults && + this.#preSearchNormalized && + normalizedQuery === this.#preSearchNormalized + ) { + effectiveQuery = ""; + this.#preSearchNormalized = ""; + } + + const results = this.#computeDefinitions(effectiveQuery); + this.#results = results; + this.#list.innerHTML = ""; + this.#list.scrollTop = 0; + this.#itemElements = []; + if (queryChanged) { + this.#selectedDefinitionId = null; + } + this.#lastNormalizedQuery = normalizedQuery; + + if (!results.length) { + this.#selectedIndex = -1; + this.#selectedDefinitionId = null; + const empty = document.createElement("p"); + empty.className = "context-menu-empty"; + empty.textContent = "No matching nodes"; + this.#list.appendChild(empty); + return; + } + + const section = document.createElement("div"); + section.className = "context-menu-section"; + + const body = document.createElement("div"); + body.className = "context-menu-section-body"; + section.appendChild(body); + + results.forEach((definition) => { + const index = this.#itemElements.length; + const item = this.#createDefinitionButton(definition, () => { + this.#updateSelection(index, { scrollIntoView: false }); + this.#selectSelection(); + }); + + item.addEventListener("pointerenter", () => { + this.#updateSelection(index, { scrollIntoView: false }); + }); + item.addEventListener("pointerdown", () => { + this.#updateSelection(index, { scrollIntoView: false }); + }); + item.addEventListener("focus", () => { + this.#updateSelection(index, { scrollIntoView: false }); + }); + item.addEventListener("keydown", (event) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + this.#moveSelection(1); + this.#focusSelection(); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + this.#moveSelection(-1); + this.#focusSelection(); + } + }); + + body.appendChild(item); + this.#itemElements.push(item); + }); + + this.#list.appendChild(section); + + if (this.#selectedDefinitionId) { + const index = results.findIndex( + (definition) => definition.id === this.#selectedDefinitionId + ); + if (index >= 0) { + this.#updateSelection(index, { scrollIntoView: false }); + return; + } + } + + this.#updateSelection(results.length ? 0 : -1, { scrollIntoView: false }); + } + + /** + * Computes palette definitions for the provided query, including shortcuts. + * + * @param {string} query Search query. + * @returns {Array} + */ + #computeDefinitions(query) { + const hasQuery = query.trim().length > 0; + if (this.#customResults) { + if (!hasQuery) { + return this.#customResults.slice(); + } + return this.#customResults.filter((definition) => + this.#workspace.registry.matchesDefinition(definition, query) + ); + } + + const registryMatches = this.#workspace.registry.search(query); + const filteredBase = registryMatches.filter( + (definition) => definition.id !== "get_var" && definition.id !== "set_var" + ); + const customEventShortcuts = this.#buildCustomEventShortcuts(query); + const variableShortcuts = this.#buildVariableShortcuts(query); + + return filteredBase.concat(customEventShortcuts, variableShortcuts); + } + + /** + * Moves the highlighted selection by the provided offset. + * + * @param {number} offset Offset delta. + */ + #moveSelection(offset) { + const total = this.#results.length; + if (!total || !Number.isFinite(offset) || offset === 0) { + return; + } + + let index = this.#selectedIndex; + if (index < 0) { + index = offset > 0 ? 0 : total - 1; + } else { + index = (index + offset + total) % total; + } + + this.#updateSelection(index); + } + + /** + * Applies visual selection state for the supplied menu index. + * + * @param {number} index Target index. + * @param {{ scrollIntoView?: boolean }} [options] + */ + #updateSelection(index, options = {}) { + const { scrollIntoView = true } = options; + const previousIndex = this.#selectedIndex; + const items = this.#itemElements; + + if (previousIndex >= 0 && items[previousIndex]) { + items[previousIndex].classList.remove("is-selected"); + items[previousIndex].setAttribute("aria-selected", "false"); + } + + if ( + typeof index !== "number" || + index < 0 || + index >= this.#results.length + ) { + this.#selectedIndex = -1; + this.#selectedDefinitionId = null; + return; + } + + this.#selectedIndex = index; + this.#selectedDefinitionId = this.#results[index]?.id ?? null; + const element = items[index]; + if (element) { + element.classList.add("is-selected"); + element.setAttribute("aria-selected", "true"); + if (scrollIntoView) { + element.scrollIntoView({ block: "nearest" }); + } + } + } + + /** + * Attempts to focus the currently highlighted menu element. + */ + #focusSelection() { + const element = this.#itemElements[this.#selectedIndex]; + if (!element || typeof element.focus !== "function") { + return; + } + try { + element.focus({ preventScroll: true }); + } catch { + element.focus(); + } + } + + /** + * Spawns a node for the currently highlighted context menu result. + */ + #selectSelection() { + if ( + this.#selectedIndex < 0 || + this.#selectedIndex >= this.#results.length + ) { + return; + } + + const definition = this.#results[this.#selectedIndex]; + const spawn = { ...this.#spawnPosition }; + const node = this.#workspace.spawnNodeFromContextMenu(definition, spawn); + if (node) { + this.hide(); + } + } + + /** + * Builds buttons for context menu rows with an attached selection handler. + * + * @param {PaletteNodeDefinition} definition Palette definition entry. + * @param {() => void} onSelect Selection callback. + * @returns {HTMLButtonElement} + */ + #createDefinitionButton(definition, onSelect) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "context-menu-item"; + button.dataset.definitionId = definition.id; + button.setAttribute("role", "option"); + button.setAttribute("aria-selected", "false"); + + const title = document.createElement("span"); + title.className = "context-menu-item-name"; + title.textContent = definition.title; + button.appendChild(title); + + button.addEventListener("click", () => { + onSelect(); + }); + + return button; + } + + /** + * Generates variable shortcut entries matching the provided query. + * + * @param {string} query Search query. + * @returns {Array} + */ + #buildVariableShortcuts(query) { + const normalizedQuery = (query ?? "").trim().toLowerCase(); + const includeAll = normalizedQuery.length === 0; + const entries = []; + + this.#workspace.variableOrder.forEach((variableId) => { + const variable = this.#workspace.variables.get(variableId); + if (!variable) { + return; + } + + const displayName = (variable.name ?? "").trim() || variable.id; + const normalizedName = displayName.toLowerCase(); + const tokens = [ + normalizedName, + variableId.toLowerCase(), + `get ${normalizedName}`, + `set ${normalizedName}`, + ]; + + if ( + !includeAll && + !tokens.some((token) => token.includes(normalizedQuery)) + ) { + return; + } + + entries.push({ + id: `variable_get_${variableId}`, + title: `Get ${displayName}`, + category: "Variables", + description: `Get ${displayName}`, + shortcut: { type: "variable", action: "get", variableId }, + }); + + entries.push({ + id: `variable_set_${variableId}`, + title: `Set ${displayName}`, + category: "Variables", + description: `Set ${displayName}`, + shortcut: { type: "variable", action: "set", variableId }, + }); + }); + + return entries; + } + + /** + * Generates custom event shortcut entries matching the provided query. + * + * @param {string} query Search query. + * @returns {Array} + */ + #buildCustomEventShortcuts(query) { + const normalizedQuery = (query ?? "").trim().toLowerCase(); + const includeAll = normalizedQuery.length === 0; + + const events = [...this.#workspace.graph.nodes.values()] + .filter((node) => node.type === "custom_event") + .sort((a, b) => + this.#resolveCustomEventLabel(a).localeCompare( + this.#resolveCustomEventLabel(b) + ) + ); + + const entries = []; + events.forEach((eventNode) => { + const label = this.#resolveCustomEventLabel(eventNode); + const normalizedName = label.toLowerCase(); + const tokens = [ + normalizedName, + eventNode.id.toLowerCase(), + `call ${normalizedName}`, + ]; + + if ( + !includeAll && + !tokens.some((token) => token.includes(normalizedQuery)) + ) { + return; + } + + entries.push({ + id: `custom_event_call_${eventNode.id}`, + title: `Call ${label}`, + category: "Custom Events", + description: `Call ${label}`, + shortcut: { type: "custom_event_call", eventNodeId: eventNode.id }, + }); + }); + + return entries; + } + + /** + * Resolves a display label for the provided custom event node. + * + * @param {import('../../core/BlueprintNode.js').BlueprintNode} node Custom event node. + * @returns {string} + */ + #resolveCustomEventLabel(node) { + const raw = + typeof node.properties.name === "string" + ? node.properties.name.trim() + : ""; + return raw || "CustomEvent"; + } +} diff --git a/scripts/ui/workspace/WorkspaceDragManager.js b/scripts/ui/workspace/WorkspaceDragManager.js new file mode 100644 index 0000000..280cd1d --- /dev/null +++ b/scripts/ui/workspace/WorkspaceDragManager.js @@ -0,0 +1,652 @@ +import { WorkspaceGeometry } from "./WorkspaceGeometry.js"; + +/** + * Handles drag interactions, ghost previews, and drag-induced transforms. + */ +export class WorkspaceDragManager { + /** + * @param {import('../BlueprintWorkspace.js').BlueprintWorkspace} workspace Owning workspace instance. + */ + constructor(workspace) { + this.workspace = workspace; + } + + /** + * Ensures a ghost preview element exists for palette drags. + * + * @param {string | null} definitionId Dragged node definition identifier. + */ + ensurePaletteGhost(definitionId) { + const { workspace } = this; + const existing = workspace.paletteDragGhost; + if (existing && existing.element.isConnected) { + if (existing.definitionId === definitionId) { + return; + } + this.removePaletteGhost(); + } + + const element = document.createElement("div"); + element.className = "workspace-drop-ghost"; + element.setAttribute("role", "presentation"); + + const label = document.createElement("span"); + label.className = "workspace-drop-ghost__label"; + const definition = definitionId + ? workspace.registry.get(definitionId) + : null; + label.textContent = definition?.title ?? "Node Preview"; + element.appendChild(label); + + workspace.nodeLayer.appendChild(element); + workspace.paletteDragGhost = { + element, + width: element.offsetWidth, + height: element.offsetHeight, + definitionId: definitionId ?? null, + }; + } + + /** + * Updates the palette ghost transform to match the supplied position. + * + * @param {{ x: number, y: number }} position Snapped workspace position. + */ + updatePaletteGhost(position) { + const { paletteDragGhost } = this.workspace; + if (!paletteDragGhost) { + return; + } + paletteDragGhost.element.style.transform = + WorkspaceGeometry.positionToTransform(position); + } + + /** + * Removes the active palette drag ghost. + */ + removePaletteGhost() { + const { workspace } = this; + if ( + workspace.paletteDragGhost?.element && + workspace.paletteDragGhost.element.isConnected + ) { + workspace.paletteDragGhost.element.remove(); + } + workspace.paletteDragGhost = null; + } + + /** + * Ensures a ghost preview exists for node drag operations. + * + * @param {string} nodeId Active node identifier. + */ + ensureNodeGhost(nodeId) { + const { workspace } = this; + const nodeElement = workspace.nodeElements.get(nodeId); + if (!nodeElement) { + return; + } + + const existing = workspace.nodeDragGhost; + if ( + existing && + existing.nodeId === nodeId && + existing.element.isConnected + ) { + return; + } + + this.removeNodeGhost(); + + const ghost = document.createElement("div"); + ghost.className = "workspace-node-ghost"; + const zoom = this.workspace.zoomLevel || 1; + const rect = nodeElement.getBoundingClientRect(); + const unscaledWidth = rect.width || nodeElement.offsetWidth; + const unscaledHeight = rect.height || nodeElement.offsetHeight; + const width = Math.max(1, unscaledWidth / zoom); + const height = Math.max(1, unscaledHeight / zoom); + ghost.style.width = `${width}px`; + ghost.style.height = `${height}px`; + ghost.setAttribute("role", "presentation"); + + if (nodeElement.parentElement) { + nodeElement.parentElement.insertBefore(ghost, nodeElement); + } else { + workspace.nodeLayer.appendChild(ghost); + } + + workspace.nodeDragGhost = { + element: ghost, + width, + height, + nodeId, + }; + } + + /** + * Updates the node drag ghost to the provided position. + * + * @param {{ x: number, y: number }} position Snapped workspace position. + */ + updateNodeGhost(position) { + const ghost = this.workspace.nodeDragGhost; + if (!ghost) { + return; + } + ghost.element.style.transform = + WorkspaceGeometry.positionToTransform(position); + } + + /** + * Removes the node drag ghost preview if active. + */ + removeNodeGhost() { + const { workspace } = this; + if ( + workspace.nodeDragGhost?.element && + workspace.nodeDragGhost.element.isConnected + ) { + workspace.nodeDragGhost.element.remove(); + } + workspace.nodeDragGhost = null; + } + + /** + * Adjusts active drag offsets when the workspace shifts. + * + * @param {{ x: number, y: number }} delta Workspace-space translation applied externally. + */ + applyActiveDragShift(delta) { + if (!delta || (!delta.x && !delta.y)) { + return; + } + + const context = this.workspace.activeNodeDragContext; + if (!context) { + return; + } + + if (context.lastPointerWorld) { + context.lastPointerWorld = { + x: context.lastPointerWorld.x + delta.x, + y: context.lastPointerWorld.y + delta.y, + }; + } + + if (!context.multiDrag) { + const ghost = this.workspace.nodeDragGhost; + const pointer = context.lastPointerWorld; + if (ghost?.nodeId && pointer) { + const anchor = context.anchors.get(ghost.nodeId); + if (anchor) { + this.updateNodeGhost({ + x: pointer.x - anchor.ratioX * anchor.width, + y: pointer.y - anchor.ratioY * anchor.height, + }); + } + } + } + } + + /** + * Updates the cached pointer state used during drag operations. + * + * @param {{ clientX?: number, clientY?: number } | null | undefined} pointer Pointer-like payload. + */ + rebaseActiveDragPointer(pointer) { + if (!pointer) { + return; + } + + const context = this.workspace.activeNodeDragContext; + if (!context) { + return; + } + + const hasClientX = Number.isFinite(pointer.clientX); + const hasClientY = Number.isFinite(pointer.clientY); + if (!hasClientX && !hasClientY) { + return; + } + + const pointerWorld = this.#clientToWorkspace( + hasClientX ? pointer.clientX : context.previousClientX, + hasClientY ? pointer.clientY : context.previousClientY + ); + context.lastPointerWorld = pointerWorld; + if (hasClientX) { + context.previousClientX = pointer.clientX; + } + if (hasClientY) { + context.previousClientY = pointer.clientY; + } + + if (!context.multiDrag) { + const ghost = this.workspace.nodeDragGhost; + if (ghost?.nodeId) { + const anchor = context.anchors.get(ghost.nodeId); + if (anchor) { + this.updateNodeGhost({ + x: pointerWorld.x - anchor.ratioX * anchor.width, + y: pointerWorld.y - anchor.ratioY * anchor.height, + }); + } + } + } + } + + /** + * Applies a snapped position to a node with a subtle easing transition. + * + * @param {string} nodeId Node identifier. + * @param {{ x: number, y: number }} position Target snapped position. + */ + applyNodeSnapPosition(nodeId, position) { + const { workspace } = this; + const element = workspace.nodeElements.get(nodeId); + if (element) { + element.style.transition = + "transform 140ms cubic-bezier(0.22, 0.61, 0.36, 1)"; + const handleTransitionEnd = () => { + element.style.transition = ""; + workspace.flushConnectionRefresh(); + if (workspace.nodeDragGhost?.nodeId === nodeId) { + this.removeNodeGhost(); + } + }; + element.addEventListener("transitionend", handleTransitionEnd, { + once: true, + }); + window.setTimeout(() => { + element.removeEventListener("transitionend", handleTransitionEnd); + element.style.transition = ""; + workspace.flushConnectionRefresh(); + if (workspace.nodeDragGhost?.nodeId === nodeId) { + this.removeNodeGhost(); + } + }, 200); + } + + this.updateNodeDomPosition(nodeId, position); + workspace.graph.setNodePosition(nodeId, position); + workspace.scheduleConnectionRefresh(); + } + + /** + * Updates the DOM transform for a node element. + * + * @param {string} nodeId Node identifier. + * @param {{x:number,y:number}} position Coordinates. + */ + updateNodeDomPosition(nodeId, position) { + const { workspace } = this; + const element = workspace.nodeElements.get(nodeId); + if (!element) { + return; + } + const rotation = workspace.nodeDragRotation.get(nodeId) ?? 0; + element.style.transform = WorkspaceGeometry.positionToTransform( + position, + rotation + ); + } + + /** + * Initiates a node drag operation. + * + * @param {PointerEvent} event Pointer event. + * @param {string} nodeId Dragged node identifier. + */ + startNodeDrag(event, nodeId) { + const { workspace } = this; + const node = workspace.graph.nodes.get(nodeId); + if (!node) { + return; + } + + const isMouse = event.pointerType === "mouse" || event.pointerType === ""; + if (isMouse && event.button !== 0) { + return; + } + if ( + !isMouse && + event.pointerType !== "touch" && + event.pointerType !== "pen" + ) { + return; + } + + event.stopPropagation(); + event.preventDefault(); + const selectedIds = workspace.selectedNodeIds.has(nodeId) + ? [...workspace.selectedNodeIds] + : [nodeId]; + + const pointerWorld = this.#eventToWorkspacePoint(event); + const anchors = new Map(); + selectedIds.forEach((id) => { + const target = workspace.graph.nodes.get(id); + if (!target) { + return; + } + const element = workspace.nodeElements.get(id) ?? null; + const { width, height } = this.#measureNodeSize(element); + const ratioX = width > 0 ? (pointerWorld.x - target.position.x) / width : 0; + const ratioY = height > 0 ? (pointerWorld.y - target.position.y) / height : 0; + anchors.set(id, { + ratioX, + ratioY, + width, + height, + }); + }); + + if (!anchors.has(nodeId)) { + const element = workspace.nodeElements.get(nodeId) ?? null; + const { width, height } = this.#measureNodeSize(element); + const ratioX = width > 0 ? (pointerWorld.x - node.position.x) / width : 0; + const ratioY = height > 0 ? (pointerWorld.y - node.position.y) / height : 0; + anchors.set(nodeId, { + ratioX, + ratioY, + width, + height, + }); + } + + const multiDrag = anchors.size > 1; + workspace.draggingNode = { + nodeId, + pointerId: event.pointerId, + selection: new Set(anchors.keys()), + }; + workspace.isDraggingNodes = true; + workspace.activeNodeDragContext = { + anchors, + multiDrag, + lastPointerWorld: pointerWorld, + previousClientX: event.clientX, + previousClientY: event.clientY, + }; + + if (multiDrag) { + this.removeNodeGhost(); + } else { + this.ensureNodeGhost(nodeId); + const anchor = anchors.get(nodeId); + if (anchor) { + const initialPosition = { + x: pointerWorld.x - anchor.ratioX * anchor.width, + y: pointerWorld.y - anchor.ratioY * anchor.height, + }; + this.updateNodeGhost(WorkspaceGeometry.snapPositionToGrid(workspace, initialPosition)); + } + } + + const draggedElement = workspace.nodeElements.get(nodeId); + if (draggedElement) { + draggedElement.style.transition = ""; + } + + const handlePointerMove = (moveEvent) => { + if (moveEvent.pointerId !== event.pointerId) { + return; + } + const context = workspace.activeNodeDragContext; + if (!context) { + return; + } + const pointer = this.#eventToWorkspacePoint(moveEvent); + context.lastPointerWorld = pointer; + + const pendingPositions = new Map(); + context.anchors.forEach((anchor, id) => { + const width = anchor.width || 1; + const height = anchor.height || 1; + const updated = { + x: pointer.x - anchor.ratioX * width, + y: pointer.y - anchor.ratioY * height, + }; + this.updateNodeDomPosition(id, updated); + pendingPositions.set(id, updated); + if (!context.multiDrag && id === nodeId) { + this.updateNodeGhost(WorkspaceGeometry.snapPositionToGrid(workspace, updated)); + } + }); + + pendingPositions.forEach((position, id) => { + workspace.graph.setNodePosition(id, position); + }); + + const screenDeltaX = Number.isFinite(moveEvent.movementX) + ? moveEvent.movementX + : moveEvent.clientX - context.previousClientX; + const velocityX = Number.isFinite(moveEvent.movementX) + ? moveEvent.movementX + : screenDeltaX; + pendingPositions.forEach((_, id) => { + this.nudgeNodeDragRotation(id, velocityX); + }); + + context.previousClientX = moveEvent.clientX; + context.previousClientY = moveEvent.clientY; + }; + + const finalizeSelection = (pointer) => { + const context = workspace.activeNodeDragContext; + if (!context) { + return; + } + const targetPointer = pointer ?? context.lastPointerWorld; + if (!targetPointer) { + return; + } + + context.anchors.forEach((anchor, id) => { + const width = anchor.width || 1; + const height = anchor.height || 1; + const raw = { + x: targetPointer.x - anchor.ratioX * width, + y: targetPointer.y - anchor.ratioY * height, + }; + const snapped = WorkspaceGeometry.snapPositionToGrid(workspace, raw); + this.applyNodeSnapPosition(id, snapped); + this.setNodeDragRotation(id, 0); + if (!context.multiDrag && id === nodeId) { + this.updateNodeGhost(snapped); + } + }); + + if (context.multiDrag) { + this.removeNodeGhost(); + } else { + window.setTimeout(() => { + this.removeNodeGhost(); + }, 160); + } + + workspace.isDraggingNodes = false; + workspace.activeNodeDragContext = null; + workspace.flushConnectionRefresh(); + }; + + const handlePointerFinish = (finishEvent) => { + if (finishEvent.pointerId !== event.pointerId) { + return; + } + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerFinish); + window.removeEventListener("pointercancel", handlePointerFinish); + + const context = workspace.activeNodeDragContext; + let pointer = null; + if (context) { + const hasClient = Number.isFinite(finishEvent.clientX); + const hasClientY = Number.isFinite(finishEvent.clientY); + if (hasClient || hasClientY) { + pointer = this.#eventToWorkspacePoint(finishEvent); + } + } + + finalizeSelection(pointer); + + workspace.draggingNode = null; + workspace.activeNodeDragContext = null; + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerFinish); + window.addEventListener("pointercancel", handlePointerFinish); + } + + /** + * Applies a momentum impulse into the current drag rotation state. + * + * @param {string} nodeId Node identifier. + * @param {number} horizontalVelocity Horizontal pointer velocity. + */ + nudgeNodeDragRotation(nodeId, horizontalVelocity) { + if (!Number.isFinite(horizontalVelocity)) { + return; + } + + const { workspace } = this; + const existing = workspace.nodeDragRotation.get(nodeId) ?? 0; + const impulse = horizontalVelocity * 0.9; + const blended = existing * 0.75 + impulse; + const clamped = Math.max(-12, Math.min(12, blended)); + this.setNodeDragRotation(nodeId, clamped); + } + + /** + * Converts pointer event coordinates into workspace-space values without clamping. + * + * @param {PointerEvent} event Pointer event reference. + * @returns {{ x: number, y: number }} + */ + #eventToWorkspacePoint(event) { + return this.#clientToWorkspace(event.clientX, event.clientY); + } + + /** + * Measures a node element and returns its workspace-space dimensions. + * + * @param {HTMLElement | null} element Node DOM element. + * @returns {{ width: number, height: number }} + */ + #measureNodeSize(element) { + const zoom = this.workspace.zoomLevel || 1; + if (!element) { + return { width: 1, height: 1 }; + } + + const rect = element.getBoundingClientRect(); + const rawWidth = rect.width / zoom; + const rawHeight = rect.height / zoom; + const fallbackWidth = element.offsetWidth || rawWidth; + const fallbackHeight = element.offsetHeight || rawHeight; + + return { + width: Math.max(1, Number.isFinite(rawWidth) ? rawWidth : fallbackWidth || 1), + height: Math.max(1, Number.isFinite(rawHeight) ? rawHeight : fallbackHeight || 1), + }; + } + + /** + * Converts client coordinates to workspace-space units. + * + * @param {number | undefined} clientX Viewport X coordinate. + * @param {number | undefined} clientY Viewport Y coordinate. + * @returns {{ x: number, y: number }} + */ + #clientToWorkspace(clientX, clientY) { + const rect = this.workspace.workspaceElement.getBoundingClientRect(); + const zoom = this.workspace.zoomLevel || 1; + const safeX = Number.isFinite(clientX) ? clientX : rect.left; + const safeY = Number.isFinite(clientY) ? clientY : rect.top; + return { + x: (safeX - rect.left) / zoom, + y: (safeY - rect.top) / zoom, + }; + } + + /** + * Applies a temporary rotation used during drag interactions. + * + * @param {string} nodeId Node identifier. + * @param {number} angle Rotation in degrees. + */ + setNodeDragRotation(nodeId, angle) { + const { workspace } = this; + const sanitized = Number.isFinite(angle) ? angle : 0; + const clamped = Math.max(-12, Math.min(12, sanitized)); + if (Math.abs(clamped) < 0.01) { + workspace.nodeDragRotation.delete(nodeId); + } else { + workspace.nodeDragRotation.set(nodeId, clamped); + } + + const node = workspace.graph.nodes.get(nodeId); + if (node) { + this.updateNodeDomPosition(nodeId, node.position); + } + + if (workspace.nodeDragRotation.size > 0) { + this.ensureNodeRotationDecay(); + } else { + this.stopNodeRotationDecay(); + } + } + + /** + * Ensures the drag rotation damping loop is active. + */ + ensureNodeRotationDecay() { + const { workspace } = this; + if (workspace.nodeDragRotationDecayFrame !== null) { + return; + } + + const step = () => { + let hasActive = false; + workspace.nodeDragRotation.forEach((angle, id) => { + const node = workspace.graph.nodes.get(id); + if (!node) { + workspace.nodeDragRotation.delete(id); + return; + } + + const damped = angle * 0.85; + if (Math.abs(damped) < 0.05) { + workspace.nodeDragRotation.delete(id); + } else { + workspace.nodeDragRotation.set(id, damped); + hasActive = true; + } + + this.updateNodeDomPosition(id, node.position); + }); + + if (hasActive) { + workspace.nodeDragRotationDecayFrame = + window.requestAnimationFrame(step); + } else { + workspace.nodeDragRotationDecayFrame = null; + } + }; + + workspace.nodeDragRotationDecayFrame = window.requestAnimationFrame(step); + } + + /** + * Stops the drag rotation damping loop if one is queued. + */ + stopNodeRotationDecay() { + const { workspace } = this; + if (workspace.nodeDragRotationDecayFrame !== null) { + window.cancelAnimationFrame(workspace.nodeDragRotationDecayFrame); + workspace.nodeDragRotationDecayFrame = null; + } + } +} diff --git a/scripts/ui/workspace/WorkspaceDropManager.js b/scripts/ui/workspace/WorkspaceDropManager.js new file mode 100644 index 0000000..18414c8 --- /dev/null +++ b/scripts/ui/workspace/WorkspaceDropManager.js @@ -0,0 +1,326 @@ +/** + * @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace + * @typedef {import('../../nodes/nodeTypes.js').NodeDefinition} NodeDefinition + */ + +import { WorkspaceGeometry } from "./WorkspaceGeometry.js"; +import { + PALETTE_DRAG_MIME, + VARIABLE_DRAG_MIME, +} from "../BlueprintWorkspaceConstants.js"; + +/** + * Manages drag-and-drop interactions for palette and variable entries. + */ +export class WorkspaceDropManager { + #workspace; + + /** + * @param {BlueprintWorkspace} workspace Owning workspace instance. + */ + constructor(workspace) { + this.#workspace = workspace; + } + + /** + * Hooks workspace-level drag events. + */ + initialize() { + const element = this.#workspace.workspaceElement; + element.addEventListener("dragenter", (event) => { + this.#handleDragEnter(event); + }); + element.addEventListener("dragover", (event) => { + this.#handleDragOver(event); + }); + element.addEventListener("dragleave", (event) => { + this.#handleDragLeave(event); + }); + element.addEventListener("drop", (event) => { + this.#handleDrop(event); + }); + } + + /** + * Begins a variable drag gesture from the overview panel. + * + * @param {DragEvent} event Drag event payload. + * @param {string} variableId Variable identifier being dragged. + */ + handleVariableDragStart(event, variableId) { + if (!event.dataTransfer) { + return; + } + + event.dataTransfer.setData(VARIABLE_DRAG_MIME, variableId); + event.dataTransfer.setData("text/plain", variableId); + event.dataTransfer.effectAllowed = "copy"; + + this.#workspace.workspaceDragDepth = 0; + const element = this.#workspace.workspaceElement; + element.classList.add("is-variable-dragging"); + element.classList.remove("is-palette-dragging"); + } + + /** + * Clears workspace drag styling for variable gestures. + */ + handleVariableDragEnd() { + this.#workspace.workspaceDragDepth = 0; + const element = this.#workspace.workspaceElement; + element.classList.remove("is-variable-dragging"); + element.classList.remove("is-drag-target"); + this.#workspace.dragManager.removePaletteGhost(); + } + + /** + * Responds to dragenter events and prepares drop feedback. + * + * @param {DragEvent} event Drag enter event. + */ + #handleDragEnter(event) { + const isPalette = this.#isPaletteDrag(event); + const isVariable = this.#isVariableDrag(event); + if (!isPalette && !isVariable) { + return; + } + + event.preventDefault(); + this.#workspace.workspaceDragDepth += 1; + const element = this.#workspace.workspaceElement; + element.classList.add("is-drag-target"); + + if (isPalette) { + const definitionId = this.#resolvePaletteDefinitionId(event); + this.#workspace.dragManager.ensurePaletteGhost(definitionId ?? null); + const point = WorkspaceGeometry.snapPositionToGrid( + this.#workspace, + WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event) + ); + this.#workspace.dragManager.updatePaletteGhost(point); + } else { + this.#workspace.dragManager.removePaletteGhost(); + } + } + + /** + * Keeps drop feedback active while objects move across the workspace. + * + * @param {DragEvent} event Drag over event. + */ + #handleDragOver(event) { + const isPalette = this.#isPaletteDrag(event); + const isVariable = this.#isVariableDrag(event); + if (!isPalette && !isVariable) { + return; + } + + event.preventDefault(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = "copy"; + } + + if (isPalette) { + const definitionId = this.#resolvePaletteDefinitionId(event); + this.#workspace.dragManager.ensurePaletteGhost(definitionId ?? null); + const point = WorkspaceGeometry.snapPositionToGrid( + this.#workspace, + WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event) + ); + this.#workspace.dragManager.updatePaletteGhost(point); + } else { + this.#workspace.dragManager.removePaletteGhost(); + } + } + + /** + * Clears drop feedback once the drag leaves workspace bounds. + * + * @param {DragEvent} event Drag leave event. + */ + #handleDragLeave(event) { + const isPalette = this.#isPaletteDrag(event); + const isVariable = this.#isVariableDrag(event); + if (!isPalette && !isVariable) { + return; + } + + this.#workspace.workspaceDragDepth = Math.max( + 0, + this.#workspace.workspaceDragDepth - 1 + ); + if (this.#workspace.workspaceDragDepth === 0) { + const element = this.#workspace.workspaceElement; + element.classList.remove("is-drag-target"); + this.#workspace.dragManager.removePaletteGhost(); + } + } + + /** + * Handles finalized drops for palette nodes and variables. + * + * @param {DragEvent} event Drop event containing pointer details. + */ + #handleDrop(event) { + const isPalette = this.#isPaletteDrag(event); + const isVariable = this.#isVariableDrag(event); + if (!isPalette && !isVariable) { + return; + } + + event.preventDefault(); + const transfer = event.dataTransfer; + if (!transfer) { + return; + } + + if (isVariable) { + this.handleVariableDragEnd(); + this.#handleVariableDrop(event, transfer); + return; + } + + const definitionId = + transfer.getData(PALETTE_DRAG_MIME) || transfer.getData("text/plain"); + if (!definitionId) { + return; + } + + this.#workspace.paletteManager.handleDragEnd(); + + const spawn = WorkspaceGeometry.snapPositionToGrid( + this.#workspace, + WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event) + ); + + this.#workspace.addNode(definitionId, spawn); + } + + /** + * Prompts for node selection when a variable is dropped onto the workspace. + * + * @param {DragEvent} event Drop event payload. + * @param {DataTransfer} transfer Drag data transfer object. + */ + #handleVariableDrop(event, transfer) { + const variableId = + transfer.getData(VARIABLE_DRAG_MIME) || transfer.getData("text/plain"); + const variable = variableId + ? this.#workspace.variables.get(variableId) + : undefined; + if (!variableId || !variable) { + return; + } + + const definitions = this.#getVariableSpawnDefinitions(variableId); + if (!definitions.length) { + const spawn = WorkspaceGeometry.snapPositionToGrid( + this.#workspace, + WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event) + ); + const fallback = this.#workspace.addNode("get_var", spawn); + if (fallback) { + const nodeName = variable.name || variable.id; + this.#workspace.graph.setNodeProperty(fallback.id, "name", nodeName); + this.#workspace.graph.setNodeProperty( + fallback.id, + "variableId", + variable.id + ); + } + return; + } + + this.#workspace.pendingVariableSpawn = { variableId }; + const placeholderName = variable.name || "variable"; + this.#workspace.contextMenuManager.show(event.clientX, event.clientY, { + definitions, + mode: "variable", + placeholder: `Create node for ${placeholderName}`, + }); + } + + /** + * Resolves potential variable spawn definitions from the registry. + * + * @param {string} variableId Variable identifier. + * @returns {Array} + */ + #getVariableSpawnDefinitions(_variableId) { + const definitions = []; + const getDefinition = this.#workspace.registry.get("get_var"); + const setDefinition = this.#workspace.registry.get("set_var"); + if (getDefinition) { + definitions.push(getDefinition); + } + if (setDefinition) { + definitions.push(setDefinition); + } + return definitions; + } + + /** + * Determines whether the drag originates from the palette interface. + * + * @param {DragEvent} event Drag event to inspect. + * @returns {boolean} + */ + #isPaletteDrag(event) { + const transfer = event.dataTransfer; + if (!transfer) { + return false; + } + + const types = Array.from(transfer.types ?? []); + if (types.includes(PALETTE_DRAG_MIME)) { + return true; + } + + return this.#workspace.workspaceElement.classList.contains( + "is-palette-dragging" + ); + } + + /** + * Determines whether the drag originates from the variable overview. + * + * @param {DragEvent} event Drag event to inspect. + * @returns {boolean} + */ + #isVariableDrag(event) { + const transfer = event.dataTransfer; + if (!transfer) { + return false; + } + + const types = Array.from(transfer.types ?? []); + if (types.includes(VARIABLE_DRAG_MIME)) { + return true; + } + + return this.#workspace.workspaceElement.classList.contains( + "is-variable-dragging" + ); + } + + /** + * Resolves the palette definition identifier associated with the active drag. + * + * @param {DragEvent} event Drag event payload. + * @returns {string | null} + */ + #resolvePaletteDefinitionId(event) { + if (this.#workspace.activePaletteDefinitionId) { + return this.#workspace.activePaletteDefinitionId; + } + + const transfer = event.dataTransfer; + if (!transfer) { + return null; + } + + const identifier = + transfer.getData(PALETTE_DRAG_MIME) || transfer.getData("text/plain"); + return identifier || null; + } +} diff --git a/scripts/ui/workspace/WorkspaceGeometry.js b/scripts/ui/workspace/WorkspaceGeometry.js new file mode 100644 index 0000000..4fdcd2d --- /dev/null +++ b/scripts/ui/workspace/WorkspaceGeometry.js @@ -0,0 +1,85 @@ +import { DEFAULT_GRID_SIZE } from "../BlueprintWorkspaceConstants.js"; + +/** + * Provides workspace geometry helpers for coordinate conversion and transforms. + */ +export class WorkspaceGeometry { + /** + * Resolves the active grid size from workspace styles. + * + * @param {import('../BlueprintWorkspace.js').BlueprintWorkspace} workspace Workspace instance. + * @returns {number} + */ + static resolveGridSize(workspace) { + const { workspaceElement } = workspace; + if (!workspaceElement || !workspaceElement.isConnected) { + return DEFAULT_GRID_SIZE; + } + + const computed = window.getComputedStyle(workspaceElement); + const raw = computed.getPropertyValue("--grid-size"); + const parsed = Number.parseFloat(raw); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + return DEFAULT_GRID_SIZE; + } + + /** + * Converts viewport coordinates into workspace-relative coordinates. + * + * @param {import('../BlueprintWorkspace.js').BlueprintWorkspace} workspace Workspace instance. + * @param {{ clientX: number, clientY: number }} event Pointer-like event payload. + * @returns {{ x: number, y: number }} + */ + static eventToWorkspacePoint(workspace, event) { + const rect = workspace.workspaceElement.getBoundingClientRect(); + const rawX = event?.clientX ?? 0; + const rawY = event?.clientY ?? 0; + const x = rawX - rect.left; + const y = rawY - rect.top; + const zoom = workspace.zoomLevel || 1; + return { + x: Math.max(0, Math.min(rect.width, x)) / zoom, + y: Math.max(0, Math.min(rect.height, y)) / zoom, + }; + } + + /** + * Snaps a workspace position to the configured grid size. + * + * @param {import('../BlueprintWorkspace.js').BlueprintWorkspace} workspace Workspace instance. + * @param {{ x: number, y: number }} position Raw workspace position. + * @returns {{ x: number, y: number }} + */ + static snapPositionToGrid(workspace, position) { + const size = workspace.gridSize || DEFAULT_GRID_SIZE; + const offsetX = workspace.workspaceBackgroundOffset?.x ?? 0; + const offsetY = workspace.workspaceBackgroundOffset?.y ?? 0; + const snap = (value, offset) => { + const local = value - offset; + const snapped = Math.round(local / size) * size; + return snapped + offset; + }; + return { x: snap(position.x, offsetX), y: snap(position.y, offsetY) }; + } + + /** + * Converts a position vector into a CSS transform. + * + * @param {{x:number,y:number}} position Position vector. + * @param {number} rotation Rotation in degrees around the Z axis. + * @returns {string} + */ + static positionToTransform(position, rotation = 0) { + const transforms = [`translate3d(${position.x}px, ${position.y}px, 0)`]; + const rotationZ = Number.isFinite(rotation) ? rotation : 0; + if (rotationZ) { + const tilt = Math.max(-10, Math.min(10, rotationZ * 0.8)); + transforms.push(`rotate3d(0, 1, 0, ${tilt}deg)`); + transforms.push(`rotate(${rotationZ}deg)`); + } + + return transforms.join(" "); + } +} diff --git a/scripts/ui/workspace/WorkspaceHistoryManager.js b/scripts/ui/workspace/WorkspaceHistoryManager.js new file mode 100644 index 0000000..278e907 --- /dev/null +++ b/scripts/ui/workspace/WorkspaceHistoryManager.js @@ -0,0 +1,310 @@ +/** + * @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace + * @typedef {ReturnType} GraphSnapshot + */ + +/** + * @typedef {Object} WorkspaceSnapshotSelection + * @property {Array} nodes + * @property {string | null} primary + * @property {string | null} variable + */ + +/** + * @typedef {Object} WorkspaceSnapshotView + * @property {{ x: number, y: number }} backgroundOffset + * @property {number} zoom + */ + +/** + * @typedef {Object} SerializedWorkspace + * @property {number} version + * @property {{ use60Fps: boolean }} projectSettings + * @property {GraphSnapshot} graph + * @property {{ entries: Array<{ id: string, name: string, type: import('../BlueprintWorkspace.js').VariableType, defaultValue: import('../BlueprintWorkspace.js').VariableDefaultValue | null }>, counter: number }} variables + * @property {number} spawnIndex + * @property {{ paletteVisible: boolean }} [ui] + */ + +/** + * @typedef {Object} WorkspaceSnapshot + * @property {SerializedWorkspace} workspace + * @property {WorkspaceSnapshotSelection} selection + * @property {WorkspaceSnapshotView} view + */ + +const HISTORY_MAX_ENTRIES = 200; +const HISTORY_DEBOUNCE_MS = 200; + +/** + * Coordinates undo/redo history for the blueprint workspace. + */ +export class WorkspaceHistoryManager { + #workspace; + #timerId; + #lastSerialized; + #pendingDirty; + #pendingReason; + #suspendDepth; + + /** + * @param {BlueprintWorkspace} workspace Owning workspace instance. + */ + constructor(workspace) { + this.#workspace = workspace; + /** @type {Array} */ + this.undoStack = []; + /** @type {Array} */ + this.redoStack = []; + this.isReady = false; + this.#timerId = null; + this.#lastSerialized = null; + this.#pendingDirty = false; + this.#pendingReason = null; + this.#suspendDepth = 0; + } + + /** + * Resets undo/redo buffers and records the current workspace state. + */ + initialize() { + this.#clearTimer(); + this.undoStack = []; + this.redoStack = []; + this.#pendingDirty = false; + this.#pendingReason = null; + this.#suspendDepth = 0; + + const snapshot = this.#captureSnapshot(); + this.undoStack.push(snapshot); + this.#lastSerialized = JSON.stringify(snapshot); + this.isReady = true; + } + + /** + * Clears undo/redo buffers without recording a snapshot. Used when the workspace is about to be rebuilt entirely. + */ + handleReset() { + this.isReady = false; + this.#clearTimer(); + this.undoStack = []; + this.redoStack = []; + this.#pendingDirty = false; + this.#pendingReason = null; + this.#lastSerialized = null; + this.#suspendDepth = 0; + } + + /** + * Records a mutation for undo/redo bookkeeping. + * + * @param {'nodeschanged'|'nodepropertychanged'|'connectionschanged'|'nodepositionchanged'} kind Mutation descriptor. + */ + registerMutation(kind) { + if (!this.isReady || this.#workspace.isRestoring) { + return; + } + + if (kind === "nodepositionchanged") { + this.markDirty(kind, HISTORY_DEBOUNCE_MS); + return; + } + + if (kind === "nodeschanged") { + this.markDirty(kind, 0); + return; + } + + this.commitCheckpoint(kind); + } + + /** + * Schedules a deferred history checkpoint to coalesce rapid mutations. + * + * @param {string} reason Descriptive label for the mutation. + * @param {number} [delay=HISTORY_DEBOUNCE_MS] Debounce duration. + */ + markDirty(reason, delay = HISTORY_DEBOUNCE_MS) { + if (!this.isReady || this.#workspace.isRestoring) { + return; + } + + if (this.#suspendDepth > 0) { + this.#pendingDirty = true; + this.#pendingReason = reason; + return; + } + + if (delay <= 0 || typeof window === "undefined") { + this.commitCheckpoint(reason); + return; + } + + this.#clearTimer(); + this.#pendingDirty = true; + this.#pendingReason = reason; + this.#timerId = window.setTimeout(() => { + this.#timerId = null; + const pendingReason = this.#pendingReason ?? reason; + this.#pendingDirty = false; + this.#pendingReason = null; + this.commitCheckpoint(pendingReason); + }, delay); + } + + /** + * Captures and stores a new undo checkpoint immediately. + * + * @param {string} reason Descriptive label for the mutation. + */ + commitCheckpoint(reason) { + if (!this.isReady || this.#workspace.isRestoring) { + return; + } + + if (this.#suspendDepth > 0) { + this.#pendingDirty = true; + this.#pendingReason = reason; + return; + } + + this.#clearTimer(); + const snapshot = this.#captureSnapshot(); + const serialized = JSON.stringify(snapshot); + if (serialized === this.#lastSerialized) { + return; + } + + this.undoStack.push(snapshot); + if (this.undoStack.length > HISTORY_MAX_ENTRIES + 1) { + this.undoStack.shift(); + } + this.#lastSerialized = serialized; + this.redoStack = []; + this.#pendingDirty = false; + this.#pendingReason = null; + } + + /** + * Executes a callback while temporarily suppressing automatic history commits. + * + * @template T + * @param {() => T} callback Callback executed while suspended. + * @param {{ suppressAutoCommit?: boolean }} [options] Behaviour overrides. + * @returns {T} + */ + withSuspended(callback, options = {}) { + const suppressAutoCommit = Boolean(options.suppressAutoCommit); + this.#suspendDepth += 1; + try { + return callback(); + } finally { + this.#suspendDepth = Math.max(0, this.#suspendDepth - 1); + if (this.#suspendDepth === 0 && this.#pendingDirty) { + if (suppressAutoCommit) { + this.#pendingDirty = false; + this.#pendingReason = null; + } else { + const reason = this.#pendingReason ?? "batched"; + this.#pendingDirty = false; + this.#pendingReason = null; + this.markDirty(reason, 0); + } + } + } + } + + /** + * Reverts the workspace to the previous history entry, if available. + */ + undo() { + if (!this.isReady || this.undoStack.length <= 1) { + return; + } + + this.#clearTimer(); + const current = this.undoStack.pop(); + if (!current) { + return; + } + + this.redoStack.push(current); + const previous = this.undoStack[this.undoStack.length - 1]; + if (!previous) { + return; + } + + this.#restoreSnapshot(previous); + this.#lastSerialized = JSON.stringify(previous); + } + + /** + * Re-applies the most recently undone history entry, if available. + */ + redo() { + if (!this.isReady || !this.redoStack.length) { + return; + } + + this.#clearTimer(); + const snapshot = this.redoStack.pop(); + if (!snapshot) { + return; + } + + this.undoStack.push(snapshot); + this.#restoreSnapshot(snapshot); + this.#lastSerialized = JSON.stringify(snapshot); + } + + /** + * Cancels any pending history timers. + */ + #clearTimer() { + if (this.#timerId !== null && typeof window !== "undefined") { + window.clearTimeout(this.#timerId); + this.#timerId = null; + } + } + + /** + * Captures a complete workspace snapshot, including selection and view state. + * + * @returns {WorkspaceSnapshot} + */ + #captureSnapshot() { + const workspacePayload = /** @type {SerializedWorkspace} */ ( + this.#workspace.serializeWorkspace() + ); + const selection = { + nodes: [...this.#workspace.selectedNodeIds], + primary: this.#workspace.selectedNodeId ?? null, + variable: this.#workspace.selectedVariableId ?? null, + }; + const view = { + backgroundOffset: { ...this.#workspace.workspaceBackgroundOffset }, + zoom: this.#workspace.zoomLevel, + }; + + return { workspace: workspacePayload, selection, view }; + } + + /** + * Restores the workspace to a previously captured snapshot. + * + * @param {WorkspaceSnapshot} snapshot Snapshot to restore. + */ + #restoreSnapshot(snapshot) { + this.#clearTimer(); + const wasReady = this.isReady; + this.isReady = false; + this.#pendingDirty = false; + this.#pendingReason = null; + + this.#workspace.applySerializedWorkspace(snapshot.workspace); + this.#workspace.applyHistoryView(snapshot.view); + this.#workspace.applyHistorySelection(snapshot.selection); + + this.isReady = wasReady; + } +} diff --git a/scripts/ui/workspace/WorkspaceInlineEditorManager.js b/scripts/ui/workspace/WorkspaceInlineEditorManager.js new file mode 100644 index 0000000..42c158a --- /dev/null +++ b/scripts/ui/workspace/WorkspaceInlineEditorManager.js @@ -0,0 +1,941 @@ +/** @typedef {import('../../core/BlueprintNode.js').BlueprintNode} BlueprintNode */ +/** @typedef {import('../../core/BlueprintNode.js').PinDescriptor} PinDescriptor */ +/** @typedef {import('../../core/NodeGraph.js').NodeGraph} NodeGraph */ +/** @typedef {import('../../nodes/NodeRegistry.js').NodeRegistry} NodeRegistry */ +/** @typedef {import('../BlueprintWorkspace.js').VariableType} VariableType */ + +/** + * Manages inline literal and pin editors for workspace nodes. + */ +export class WorkspaceInlineEditorManager { + /** + * @param {{ + * getGraph: () => NodeGraph, + * registry: NodeRegistry, + * formatVariableType?: (type: VariableType) => string, + * openLocalVariableTypeDropdown?: ( + * nodeId: string, + * trigger: HTMLButtonElement, + * anchor: HTMLElement, + * currentType: VariableType, + * onSelect: (type: VariableType) => void + * ) => void, + * closeTypeDropdown?: () => void, + * isTypeDropdownOpen?: (context: { kind: 'local_variable', nodeId: string }) => boolean, + * }} options Inline editor dependencies. + */ + constructor(options) { + this.#getGraph = options.getGraph; + this.registry = options.registry; + this.#formatVariableType = + typeof options.formatVariableType === "function" + ? (type) => options.formatVariableType(type) + : (type) => { + const value = typeof type === "string" ? type : ""; + if (!value.length) { + return ""; + } + return value.charAt(0).toUpperCase() + value.slice(1); + }; + this.#openLocalTypeDropdown = + typeof options.openLocalVariableTypeDropdown === "function" + ? options.openLocalVariableTypeDropdown + : null; + this.#closeTypeDropdown = + typeof options.closeTypeDropdown === "function" + ? options.closeTypeDropdown + : () => {}; + this.#isTypeDropdownOpen = + typeof options.isTypeDropdownOpen === "function" + ? options.isTypeDropdownOpen + : () => false; + /** + * @type {Map void }, + * pinEditors: Map void, setConnected: (connected: boolean) => void }> + * }>} + */ + this.inlineEditors = new Map(); + } + + /** + * Creates inline literal editor controls for supported nodes. + * + * @param {BlueprintNode} node Target node instance. + * @param {HTMLElement} article Node root article element. + * @param {HTMLElement} inputContainer Container holding input pins. + * @returns {{ setValue: (value: unknown) => void } | null} + */ + setupLiteralEditor(node, article, inputContainer) { + if (node.type === "set_local_var" || node.type === "get_local_var") { + return this.#setupLocalVariableEditor(node, article, inputContainer); + } + + const supported = new Set([ + "number_literal", + "string_literal", + "boolean_literal", + ]); + if (!supported.has(node.type)) { + return null; + } + + const existing = article.querySelector(".node-body"); + const body = existing ?? document.createElement("div"); + body.className = "node-body"; + if (!existing) { + article.insertBefore(body, inputContainer); + } + body.innerHTML = ""; + + const editor = document.createElement("div"); + editor.className = "literal-inline-editor"; + body.appendChild(editor); + + const controlId = `${node.id}_inline_value`; + const label = document.createElement("label"); + label.className = "literal-inline-label"; + label.setAttribute("for", controlId); + label.textContent = "Value"; + editor.appendChild(label); + + const graph = this.#getGraph(); + let control; + const commitNumber = () => { + const numeric = Number(control.value); + const value = Number.isFinite(numeric) ? numeric : 0; + graph.setNodeProperty(node.id, "value", value); + }; + + switch (node.type) { + case "number_literal": { + control = document.createElement("input"); + control.type = "number"; + control.step = "any"; + control.className = "literal-inline-input"; + control.addEventListener("change", commitNumber); + control.addEventListener("blur", commitNumber); + break; + } + case "string_literal": { + control = document.createElement("input"); + control.type = "text"; + control.className = "literal-inline-input"; + control.spellcheck = false; + control.addEventListener("input", () => { + graph.setNodeProperty(node.id, "value", control.value); + }); + break; + } + case "boolean_literal": { + control = document.createElement("input"); + control.type = "checkbox"; + control.className = "literal-inline-checkbox"; + control.addEventListener("change", () => { + graph.setNodeProperty(node.id, "value", control.checked); + }); + break; + } + default: + return null; + } + + control.id = controlId; + editor.appendChild(control); + + const setValue = (rawValue) => { + switch (node.type) { + case "number_literal": { + const numeric = Number(rawValue); + control.value = Number.isFinite(numeric) ? String(numeric) : "0"; + break; + } + case "string_literal": { + control.value = rawValue == null ? "" : String(rawValue); + break; + } + case "boolean_literal": { + control.checked = this.#isTruthy(rawValue); + break; + } + default: + break; + } + }; + + setValue(node.properties.value); + + const entry = this.#ensureEntry(node.id); + entry.literal = { setValue }; + return entry.literal; + } + + #setupLocalVariableEditor(node, article, inputContainer) { + const typeOptions = [ + { value: "number", label: "Number" }, + { value: "string", label: "String" }, + { value: "boolean", label: "Boolean" }, + { value: "table", label: "Table" }, + ]; + + const ensureBody = () => { + const existing = article.querySelector(".node-body"); + if (existing) { + existing.innerHTML = ""; + return existing; + } + const body = document.createElement("div"); + body.className = "node-body"; + article.insertBefore(body, inputContainer); + return body; + }; + + const body = ensureBody(); + const editor = document.createElement("div"); + editor.className = "local-variable-editor"; + body.appendChild(editor); + + const headerRow = document.createElement("div"); + headerRow.className = "local-variable-row local-variable-row--header"; + editor.appendChild(headerRow); + + const typeAnchor = document.createElement("div"); + typeAnchor.className = "local-variable-type"; + headerRow.appendChild(typeAnchor); + + const typeButton = document.createElement("button"); + typeButton.type = "button"; + typeButton.className = "variable-type-button local-variable-type-button"; + typeButton.setAttribute("aria-haspopup", "menu"); + typeButton.setAttribute("aria-expanded", "false"); + typeAnchor.appendChild(typeButton); + + const nameInput = document.createElement("input"); + nameInput.type = "text"; + nameInput.className = "local-variable-name-input"; + nameInput.placeholder = "localVar"; + nameInput.setAttribute("spellcheck", "false"); + nameInput.setAttribute("aria-label", "Local variable name"); + headerRow.appendChild(nameInput); + + const valueContainer = document.createElement("div"); + valueContainer.className = "local-variable-inline-value"; + const valueField = document.createElement("div"); + valueField.className = "local-variable-inline-value-field"; + valueContainer.appendChild(valueField); + + const attachValueContainer = () => { + if (node.type !== "set_local_var") { + if (valueContainer.isConnected) { + valueContainer.remove(); + } + return; + } + const valuePin = article.querySelector( + ".node-inputs .pin[data-pin-id='value']" + ); + if (!valuePin) { + return; + } + + const parent = valuePin.parentElement; + if (!parent) { + return; + } + + if (valueContainer.parentElement !== parent) { + valuePin.insertAdjacentElement("afterend", valueContainer); + return; + } + + if (valuePin.nextElementSibling !== valueContainer) { + valuePin.insertAdjacentElement("afterend", valueContainer); + } + }; + + const graph = this.#getGraph(); + + const normalizeType = (value) => { + const candidate = typeof value === "string" ? value : "number"; + return typeOptions.some((option) => option.value === candidate) + ? candidate + : "number"; + }; + + const formatType = (type) => { + return this.#formatVariableType(normalizeType(type)); + }; + + const resolveValuePropertyKey = (type) => { + switch (type) { + case "string": + return "valueString"; + case "boolean": + return "valueBoolean"; + case "table": + return "valueTable"; + case "number": + default: + return "valueNumber"; + } + }; + + const resolveValueControlKind = (type) => { + switch (type) { + case "boolean": + return "boolean"; + case "string": + return "string"; + case "table": + return "table"; + default: + return "number"; + } + }; + + const defaultValueForType = (type) => { + switch (type) { + case "string": + return ""; + case "boolean": + return false; + case "table": + return "{}"; + case "number": + default: + return 0; + } + }; + + let activeType = normalizeType(node.properties.variableType); + + const updateTypeButton = (type) => { + const normalized = normalizeType(type); + activeType = normalized; + typeButton.dataset.variableType = normalized; + const label = formatType(normalized); + typeButton.title = `Type: ${label}`; + typeButton.setAttribute( + "aria-label", + `Change local variable type (currently ${label})` + ); + }; + + const renderValueControl = (currentNode, type) => { + if (currentNode.type !== "set_local_var") { + if (valueContainer.isConnected) { + valueContainer.remove(); + } + valueField.innerHTML = ""; + return; + } + const key = resolveValuePropertyKey(type); + const current = currentNode.properties?.[key]; + const ensureValueControl = () => { + const desiredKind = resolveValueControlKind(type); + const existing = valueField.firstElementChild; + if ( + existing instanceof HTMLElement && + existing.dataset.localValueKind === desiredKind + ) { + return /** @type {HTMLElement} */ (existing); + } + + valueField.innerHTML = ""; + let control; + if (desiredKind === "boolean") { + const select = document.createElement("select"); + select.className = "local-variable-boolean"; + select.setAttribute("aria-label", "Local variable value"); + const trueOption = document.createElement("option"); + trueOption.value = "true"; + trueOption.textContent = "True"; + const falseOption = document.createElement("option"); + falseOption.value = "false"; + falseOption.textContent = "False"; + select.appendChild(trueOption); + select.appendChild(falseOption); + select.addEventListener("change", () => { + const next = select.value === "true"; + graph.setNodeProperty(currentNode.id, key, next); + }); + control = select; + } else if (desiredKind === "string") { + const input = document.createElement("input"); + input.type = "text"; + input.className = "local-variable-text"; + input.setAttribute("aria-label", "Local variable value"); + input.addEventListener("input", () => { + graph.setNodeProperty(currentNode.id, key, input.value); + }); + control = input; + } else if (desiredKind === "table") { + const textarea = document.createElement("textarea"); + textarea.className = "local-variable-textarea"; + textarea.rows = 2; + textarea.setAttribute("aria-label", "Local variable value"); + textarea.addEventListener("input", () => { + graph.setNodeProperty(currentNode.id, key, textarea.value); + }); + control = textarea; + } else { + const numberInput = document.createElement("input"); + numberInput.type = "number"; + numberInput.step = "any"; + numberInput.className = "local-variable-number"; + numberInput.setAttribute("aria-label", "Local variable value"); + const commit = () => { + const value = Number(numberInput.value); + const next = Number.isFinite(value) ? value : 0; + numberInput.value = String(next); + graph.setNodeProperty(currentNode.id, key, next); + }; + numberInput.addEventListener("change", commit); + numberInput.addEventListener("blur", commit); + control = numberInput; + } + + control.dataset.localValueKind = desiredKind; + valueField.appendChild(control); + return control; + }; + + const control = ensureValueControl(); + + if (control instanceof HTMLSelectElement) { + const nextValue = current ? "true" : "false"; + if (control.value !== nextValue) { + control.value = nextValue; + } + } else if (control instanceof HTMLTextAreaElement) { + const nextValue = typeof current === "string" ? current : "{}"; + if (control.value !== nextValue) { + control.value = nextValue; + } + } else if (control instanceof HTMLInputElement) { + if (control.type === "text") { + const nextValue = typeof current === "string" ? current : ""; + if (control.value !== nextValue) { + control.value = nextValue; + } + } else { + const numeric = Number(current); + const nextValue = Number.isFinite(numeric) ? String(numeric) : "0"; + if (control.value !== nextValue) { + control.value = nextValue; + } + } + } + + attachValueContainer(); + }; + + const applyTypeChange = (rawType) => { + const nextType = normalizeType(rawType); + const key = resolveValuePropertyKey(nextType); + const currentNode = graph.nodes.get(node.id) ?? node; + const existing = currentNode.properties?.[key]; + const fallback = existing ?? defaultValueForType(nextType); + updateTypeButton(nextType); + graph.setNodeProperty(currentNode.id, "variableType", nextType); + graph.setNodeProperty(currentNode.id, key, fallback); + const refreshed = graph.nodes.get(node.id) ?? currentNode; + renderValueControl(refreshed, nextType); + }; + + const entry = this.#ensureEntry(node.id); + + const setState = (currentNode) => { + const currentType = normalizeType(currentNode.properties.variableType); + updateTypeButton(currentType); + const currentName = + typeof currentNode.properties.name === "string" + ? currentNode.properties.name + : "localVar"; + if (nameInput.value !== currentName) { + nameInput.value = currentName; + } + renderValueControl(currentNode, currentType); + }; + + const openDropdown = () => { + if (!this.#openLocalTypeDropdown) { + return; + } + this.#openLocalTypeDropdown( + node.id, + typeButton, + typeAnchor, + activeType, + (selectedType) => { + applyTypeChange(selectedType); + } + ); + }; + + typeButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + if (!this.#openLocalTypeDropdown) { + return; + } + const context = { kind: "local_variable", nodeId: node.id }; + const isOpen = this.#isTypeDropdownOpen(context); + if (isOpen) { + this.#closeTypeDropdown(); + return; + } + openDropdown(); + }); + + typeButton.addEventListener("pointerdown", (event) => { + event.stopPropagation(); + }); + + typeButton.addEventListener("keydown", (event) => { + const isToggle = + event.key === "Enter" || event.key === " " || event.key === "Spacebar"; + if (isToggle || event.key === "ArrowDown") { + event.preventDefault(); + openDropdown(); + } else if (event.key === "Escape") { + event.preventDefault(); + this.#closeTypeDropdown(); + } + }); + + nameInput.addEventListener("blur", () => { + graph.setNodeProperty(node.id, "name", nameInput.value); + }); + + nameInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + graph.setNodeProperty(node.id, "name", nameInput.value); + nameInput.blur(); + } + }); + + entry.literal = { setState }; + setState(node); + return entry.literal; + } + + /** + * Creates inline pin editors when supported. + * + * @param {BlueprintNode} node Current node instance. + * @param {PinDescriptor} pin Pin descriptor. + * @param {HTMLElement} container Pin container element. + */ + setupPinEditor(node, pin, container) { + if (pin.direction !== "input") { + return; + } + + const isIfCondition = node.type === "if" && pin.id === "condition"; + const isExtcmdCommand = + node.type === "system_extcmd" && pin.id === "command"; + const isNamedButtonSelector = + node.type === "input_button_constants" && pin.id === "button"; + const isNamedPlayerSelector = + node.type === "input_button_constants" && pin.id === "player"; + if ( + !isIfCondition && + !isExtcmdCommand && + !isNamedButtonSelector && + !isNamedPlayerSelector + ) { + container.classList.remove("has-inline-dropdown"); + return; + } + + const entry = this.#ensureEntry(node.id); + const propertyKey = this.#pinPropertyKey(pin.id); + const graph = this.#getGraph(); + + if (!(propertyKey in node.properties)) { + if (isExtcmdCommand || isNamedButtonSelector || isNamedPlayerSelector) { + const definition = this.registry.get(node.type); + const targetKey = isExtcmdCommand + ? "command" + : isNamedButtonSelector + ? "button" + : "player"; + const schema = definition?.properties?.find( + (prop) => prop.key === targetKey + ); + const options = Array.isArray(schema?.options) ? schema.options : []; + const defaultOption = + typeof node.properties[targetKey] === "string" + ? node.properties[targetKey] + : String(schema?.defaultValue ?? options[0]?.value ?? ""); + if (typeof node.properties[targetKey] !== "string") { + node.properties[targetKey] = defaultOption; + } + if (isExtcmdCommand) { + node.properties[propertyKey] = defaultOption; + } else { + const numericDefault = Number.parseInt(defaultOption, 10); + node.properties[propertyKey] = Number.isFinite(numericDefault) + ? numericDefault + : 0; + } + } else { + node.properties[propertyKey] = this.#defaultValueForPin(pin); + } + } + + container.querySelectorAll(".pin-inline-control").forEach((element) => { + element.remove(); + }); + + let wrapper; + if (isExtcmdCommand || isNamedButtonSelector || isNamedPlayerSelector) { + wrapper = document.createElement("div"); + wrapper.className = "pin-inline-control pin-inline-dropdown"; + container.appendChild(wrapper); + } else { + wrapper = document.createElement("span"); + wrapper.className = "pin-inline-control"; + container.classList.remove("has-inline-dropdown"); + const label = container.querySelector(".pin-label"); + if (label) { + container.insertBefore(wrapper, label); + } else { + container.appendChild(wrapper); + } + } + + if (isIfCondition && pin.kind === "boolean") { + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.className = "pin-inline-checkbox"; + wrapper.appendChild(checkbox); + + const setValue = (value) => { + checkbox.checked = this.#isTruthy(value); + }; + + const setConnected = (connected) => { + wrapper.classList.toggle("is-hidden", connected); + }; + + checkbox.addEventListener("change", () => { + graph.setNodeProperty(node.id, propertyKey, checkbox.checked); + }); + + entry.pinEditors.set(pin.id, { setValue, setConnected }); + setValue(node.properties[propertyKey]); + setConnected(this.#hasConnection(node.id, pin.id)); + return; + } + + if (isExtcmdCommand || isNamedButtonSelector || isNamedPlayerSelector) { + const definition = this.registry.get(node.type); + const targetKey = isExtcmdCommand + ? "command" + : isNamedButtonSelector + ? "button" + : "player"; + const schema = definition?.properties?.find( + (prop) => prop.key === targetKey + ); + const options = Array.isArray(schema?.options) ? schema.options : []; + + const select = document.createElement("select"); + select.className = "pin-inline-select"; + const ariaLabel = isExtcmdCommand + ? "Command" + : isNamedButtonSelector + ? "Button" + : "Player"; + select.setAttribute("aria-label", ariaLabel); + + const values = options.map((option) => String(option.value)); + options.forEach((option) => { + const element = document.createElement("option"); + element.value = String(option.value); + element.textContent = option.label ?? String(option.value); + select.appendChild(element); + }); + + const normalizeValue = (value) => { + const candidate = + typeof value === "string" ? value : String(value ?? ""); + if (values.length === 0) { + return candidate; + } + if (values.includes(candidate)) { + return candidate; + } + const schemaDefault = + schema?.defaultValue != null ? String(schema.defaultValue) : null; + if (schemaDefault && values.includes(schemaDefault)) { + return schemaDefault; + } + return values[0]; + }; + + const setValue = (value) => { + const normalized = normalizeValue( + (isNamedButtonSelector || isNamedPlayerSelector) && + typeof node.properties[targetKey] === "string" + ? node.properties[targetKey] + : value + ); + select.value = normalized; + }; + + const setConnected = (connected) => { + wrapper.classList.toggle("is-hidden", connected); + container.classList.toggle("has-inline-dropdown", !connected); + }; + + select.addEventListener("change", () => { + const nextValue = select.value; + if (isExtcmdCommand) { + graph.setNodeProperty(node.id, propertyKey, nextValue); + graph.setNodeProperty(node.id, "command", nextValue); + } else { + const normalized = normalizeValue(nextValue); + const numeric = Number.parseInt(normalized, 10); + graph.setNodeProperty( + node.id, + propertyKey, + Number.isFinite(numeric) ? numeric : 0 + ); + graph.setNodeProperty(node.id, targetKey, normalized); + } + }); + + wrapper.appendChild(select); + + entry.pinEditors.set(pin.id, { setValue, setConnected }); + setValue(node.properties[propertyKey]); + setConnected(this.#hasConnection(node.id, pin.id)); + } + } + + /** + * Removes inline editors for pins no longer present on a node. + * + * @param {BlueprintNode} node Target node reference. + */ + prunePinEditors(node) { + const entry = this.inlineEditors.get(node.id); + if (!entry?.pinEditors) { + return; + } + const activePins = new Set(node.inputs.map((pin) => pin.id)); + [...entry.pinEditors.keys()].forEach((pinId) => { + if (!activePins.has(pinId)) { + entry.pinEditors.delete(pinId); + } + }); + } + + /** + * Synchronizes inline editor controls with the graph state for the supplied node. + * + * @param {string} nodeId Node identifier. + */ + syncNode(nodeId) { + const entry = this.inlineEditors.get(nodeId); + if (!entry) { + return; + } + + const graph = this.#getGraph(); + const node = graph.nodes.get(nodeId); + if (!node) { + return; + } + + if (entry.literal) { + if (typeof entry.literal.setState === "function") { + entry.literal.setState(node); + } else if (typeof entry.literal.setValue === "function") { + entry.literal.setValue(node.properties.value); + } + } + + entry.pinEditors.forEach((pinEditor, pinId) => { + const pin = node.getPin(pinId); + const propertyKey = this.#pinPropertyKey(pinId); + let value; + + if (node.type === "system_extcmd" && pinId === "command") { + const commandValue = + typeof node.properties.command === "string" + ? node.properties.command + : null; + if ( + commandValue !== null && + node.properties[propertyKey] !== commandValue + ) { + node.properties[propertyKey] = commandValue; + } + } else if (node.type === "input_button_constants") { + if (pinId === "button") { + const rawButton = + typeof node.properties.button === "string" + ? node.properties.button + : null; + if (rawButton !== null) { + const numericButton = Number.parseInt(rawButton, 10); + const safeButton = Number.isFinite(numericButton) + ? numericButton + : 0; + if (node.properties[propertyKey] !== safeButton) { + node.properties[propertyKey] = safeButton; + } + } + } else if (pinId === "player") { + const rawPlayer = + typeof node.properties.player === "string" + ? node.properties.player + : null; + if (rawPlayer !== null) { + const numericPlayer = Number.parseInt(rawPlayer, 10); + const safePlayer = Number.isFinite(numericPlayer) + ? numericPlayer + : 0; + if (node.properties[propertyKey] !== safePlayer) { + node.properties[propertyKey] = safePlayer; + } + } + } + } + + if (propertyKey in node.properties) { + value = node.properties[propertyKey]; + } else if (pin) { + value = this.#defaultValueForPin(pin); + } else { + value = null; + } + + pinEditor.setValue(value); + pinEditor.setConnected(this.#hasConnection(nodeId, pinId)); + }); + } + + /** Synchronizes inline editors for every tracked node. */ + syncAll() { + this.inlineEditors.forEach((_, nodeId) => this.syncNode(nodeId)); + } + + /** + * Removes inline editor tracking for a node. + * + * @param {string} nodeId Node identifier. + */ + removeNode(nodeId) { + this.inlineEditors.delete(nodeId); + } + + /** Clears all inline editor state. */ + clear() { + this.inlineEditors.clear(); + } + + /** + * Ensures inline editor entry exists for a node. + * + * @param {string} nodeId Node identifier. + * @returns {{ literal?: { setValue: (value: unknown) => void }, pinEditors: Map void, setConnected: (connected: boolean) => void }> }} + */ + #ensureEntry(nodeId) { + let entry = this.inlineEditors.get(nodeId); + if (!entry) { + entry = { pinEditors: new Map() }; + this.inlineEditors.set(nodeId, entry); + } else if (!entry.pinEditors) { + entry.pinEditors = new Map(); + } + return entry; + } + + /** + * Generates the property key used for storing inline pin overrides. + * + * @param {string} pinId Pin identifier. + * @returns {string} + */ + #pinPropertyKey(pinId) { + return `pin:${pinId}`; + } + + /** + * Determines whether a pin has any connections. + * + * @param {string} nodeId Node identifier. + * @param {string} pinId Pin identifier. + * @returns {boolean} + */ + #hasConnection(nodeId, pinId) { + const graph = this.#getGraph(); + return graph.getConnectionsForNode(nodeId, pinId).some((connection) => { + const isTarget = + connection.to.nodeId === nodeId && connection.to.pinId === pinId; + const isSource = + connection.from.nodeId === nodeId && connection.from.pinId === pinId; + return isTarget || isSource; + }); + } + + /** + * Provides a reasonable default value for the supplied pin. + * + * @param {PinDescriptor} pin Pin descriptor. + * @returns {unknown} + */ + #defaultValueForPin(pin) { + if (!pin) { + return null; + } + if (pin.defaultValue !== undefined) { + return pin.defaultValue; + } + switch (pin.kind) { + case "boolean": + return false; + case "number": + return 0; + case "string": + return ""; + default: + return null; + } + } + + /** + * Normalizes truthy values for inline boolean controls. + * + * @param {unknown} value Candidate value. + * @returns {boolean} + */ + #isTruthy(value) { + if (typeof value === "string") { + return value.toLowerCase() !== "false"; + } + return Boolean(value); + } + + /** @type {() => NodeGraph} */ + #getGraph; + + /** @type {(type: VariableType) => string} */ + #formatVariableType; + + /** @type {(nodeId: string, trigger: HTMLButtonElement, anchor: HTMLElement, currentType: VariableType, onSelect: (type: VariableType) => void) => void} */ + #openLocalTypeDropdown; + + /** @type {() => void} */ + #closeTypeDropdown; + + /** @type {(context: { kind: 'local_variable', nodeId: string }) => boolean} */ + #isTypeDropdownOpen; + +} diff --git a/scripts/ui/workspace/WorkspaceMarqueeManager.js b/scripts/ui/workspace/WorkspaceMarqueeManager.js new file mode 100644 index 0000000..6d75ed8 --- /dev/null +++ b/scripts/ui/workspace/WorkspaceMarqueeManager.js @@ -0,0 +1,228 @@ +/** + * @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace + */ + +/** + * Coordinates marquee-based selection gestures within the workspace. + */ +export class WorkspaceMarqueeManager { + /** @type {BlueprintWorkspace} */ + #workspace; + /** @type {HTMLDivElement | null} */ + #element; + /** @type {{ pointerId: number, origin: { x: number, y: number }, current: { x: number, y: number }, hasDragged: boolean, moveHandler: (event: PointerEvent) => void, upHandler: (event: PointerEvent) => void } | null} */ + #state; + + /** + * @param {BlueprintWorkspace} workspace Owning workspace instance. + */ + constructor(workspace) { + this.#workspace = workspace; + this.#element = null; + this.#state = null; + } + + /** + * Ensures the marquee overlay element exists within the workspace DOM. + */ + ensureElement() { + if (this.#element) { + return; + } + + const marquee = document.createElement("div"); + marquee.className = "workspace-marquee"; + marquee.style.display = "none"; + marquee.setAttribute("role", "presentation"); + this.#workspace.workspaceElement.appendChild(marquee); + this.#element = marquee; + } + + /** + * Indicates whether the provided element is the marquee overlay. + * + * @param {Element | null} target Potential marquee element. + * @returns {boolean} + */ + isMarqueeElement(target) { + return Boolean(target && this.#element && target === this.#element); + } + + /** + * Initiates a marquee selection gesture at the supplied pointer event. + * + * @param {PointerEvent} event Pointer event initiating the marquee. + */ + beginSelection(event) { + this.ensureElement(); + if (!this.#element) { + return; + } + + const bounds = this.#workspace.workspaceElement.getBoundingClientRect(); + const origin = { + x: Math.max(0, event.clientX - bounds.left), + y: Math.max(0, event.clientY - bounds.top), + }; + + this.#element.style.display = "block"; + this.#element.style.left = `${origin.x}px`; + this.#element.style.top = `${origin.y}px`; + this.#element.style.width = "0px"; + this.#element.style.height = "0px"; + + const handlePointerMove = (moveEvent) => { + if (!this.#state || moveEvent.pointerId !== this.#state.pointerId) { + return; + } + this.#updateSelection(moveEvent); + }; + + const handlePointerUp = (upEvent) => { + if (!this.#state || upEvent.pointerId !== this.#state.pointerId) { + return; + } + + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + window.removeEventListener("pointercancel", handlePointerUp); + this.#finalizeSelection(); + }; + + this.#state = { + pointerId: event.pointerId, + origin, + current: { ...origin }, + hasDragged: false, + moveHandler: handlePointerMove, + upHandler: handlePointerUp, + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + window.addEventListener("pointercancel", handlePointerUp); + + event.preventDefault(); + } + + /** + * Clears any ongoing marquee gesture and hides the overlay. + */ + clear() { + if (this.#state) { + window.removeEventListener("pointermove", this.#state.moveHandler); + window.removeEventListener("pointerup", this.#state.upHandler); + window.removeEventListener("pointercancel", this.#state.upHandler); + this.#state = null; + } + + if (this.#element) { + this.#element.style.display = "none"; + this.#element.style.width = "0px"; + this.#element.style.height = "0px"; + } + } + + /** + * Updates the marquee overlay to reflect the latest pointer location. + * + * @param {PointerEvent} event Active pointer event. + */ + #updateSelection(event) { + if (!this.#state || !this.#element) { + return; + } + + const bounds = this.#workspace.workspaceElement.getBoundingClientRect(); + const clampedX = Math.min( + Math.max(0, event.clientX - bounds.left), + bounds.width + ); + const clampedY = Math.min( + Math.max(0, event.clientY - bounds.top), + bounds.height + ); + + this.#state.current = { x: clampedX, y: clampedY }; + + const { origin, current } = this.#state; + const left = Math.min(origin.x, current.x); + const top = Math.min(origin.y, current.y); + const width = Math.abs(current.x - origin.x); + const height = Math.abs(current.y - origin.y); + + if (!this.#state.hasDragged && (width > 2 || height > 2)) { + this.#state.hasDragged = true; + } + + this.#element.style.left = `${left}px`; + this.#element.style.top = `${top}px`; + this.#element.style.width = `${width}px`; + this.#element.style.height = `${height}px`; + } + + /** + * Finishes the marquee gesture and updates the workspace selection. + */ + #finalizeSelection() { + if (!this.#state || !this.#element) { + return; + } + + const { origin, current, hasDragged } = this.#state; + this.clear(); + + if (!hasDragged) { + return; + } + + const bounds = this.#workspace.workspaceElement.getBoundingClientRect(); + const selectionBounds = { + left: Math.min(origin.x, current.x) + bounds.left, + top: Math.min(origin.y, current.y) + bounds.top, + right: Math.max(origin.x, current.x) + bounds.left, + bottom: Math.max(origin.y, current.y) + bounds.top, + }; + + this.#selectNodesWithinRect(selectionBounds); + } + + /** + * Selects nodes that intersect the provided screen-space bounds. + * + * @param {{ left: number, top: number, right: number, bottom: number }} bounds Selection bounds. + */ + #selectNodesWithinRect(bounds) { + /** @type {Array} */ + const selected = []; + this.#workspace.nodeElements.forEach((element, nodeId) => { + const rect = element.getBoundingClientRect(); + if (this.#rectanglesOverlap(bounds, rect)) { + selected.push(nodeId); + } + }); + + if (selected.length) { + const primary = selected[selected.length - 1]; + this.#workspace.setSelectionState(selected, primary); + } else { + this.#workspace.clearSelection(); + } + } + + /** + * Determines whether two rectangles overlap (inclusive of borders). + * + * @param {{ left: number, top: number, right: number, bottom: number }} a First rectangle. + * @param {{ left: number, top: number, right: number, bottom: number }} b Second rectangle. + * @returns {boolean} + */ + #rectanglesOverlap(a, b) { + return !( + b.left > a.right || + b.right < a.left || + b.top > a.bottom || + b.bottom < a.top + ); + } +} diff --git a/scripts/ui/workspace/WorkspaceNavigationManager.js b/scripts/ui/workspace/WorkspaceNavigationManager.js new file mode 100644 index 0000000..24180fe --- /dev/null +++ b/scripts/ui/workspace/WorkspaceNavigationManager.js @@ -0,0 +1,597 @@ +import { WorkspaceGeometry } from "./WorkspaceGeometry.js"; + +/** + * Coordinates workspace navigation behaviour including pan, zoom, and coordinate transforms. + */ +export class WorkspaceNavigationManager { + #workspace; + #renderConnections; + #markViewDirty; + #schedulePersist; + #backgroundOffset; + #zoomLevel; + #zoomConfig; + #panState; + #lastPanTimestamp; + + /** + * @param {import("../BlueprintWorkspace.js").BlueprintWorkspace} workspace Owning workspace instance. + * @param {{ renderConnections?: () => void, markViewDirty?: (reason: string, delay?: number) => void, schedulePersist?: () => void }} [callbacks] + * Hook callbacks invoked during navigation changes. + */ + constructor(workspace, callbacks = {}) { + this.#workspace = workspace; + this.#renderConnections = + typeof callbacks.renderConnections === "function" + ? callbacks.renderConnections + : () => {}; + this.#markViewDirty = + typeof callbacks.markViewDirty === "function" + ? callbacks.markViewDirty + : () => {}; + this.#schedulePersist = + typeof callbacks.schedulePersist === "function" + ? callbacks.schedulePersist + : () => {}; + + this.#backgroundOffset = { x: 0, y: 0 }; + this.#zoomLevel = 1; + this.#zoomConfig = { min: 0.25, max: 2.5, step: 0.1 }; + this.#panState = null; + this.#lastPanTimestamp = 0; + } + + /** + * @returns {number} Active zoom multiplier. + */ + getZoomLevel() { + return this.#zoomLevel; + } + + /** + * Adjusts the zoom level within configured bounds. + * + * @param {number} nextZoom Requested zoom multiplier. + * @param {{ silent?: boolean }} [options] Behaviour overrides. + * @returns {boolean} Whether the zoom value changed. + */ + setZoomLevel(nextZoom, options = {}) { + if (!Number.isFinite(nextZoom)) { + return false; + } + const { silent = false } = options; + const clamped = Math.max( + this.#zoomConfig.min, + Math.min(this.#zoomConfig.max, nextZoom) + ); + if (Math.abs(clamped - this.#zoomLevel) < 0.0001) { + return false; + } + + this.#zoomLevel = clamped; + this.updateZoomDisplay(); + if (!silent) { + this.#markViewDirty("view", 200); + this.#schedulePersist(); + } + return true; + } + + /** + * @returns {{ min: number, max: number, step: number }} Current zoom configuration. + */ + getZoomConfig() { + return { ...this.#zoomConfig }; + } + + /** + * Updates workspace transforms to match the stored zoom level. + */ + updateZoomDisplay() { + const zoom = this.#zoomLevel || 1; + const { nodeLayer, connectionLayer, workspaceElement } = this.#workspace; + + if (nodeLayer) { + nodeLayer.style.transformOrigin = "0 0"; + nodeLayer.style.transform = `scale(${zoom})`; + } + + if (connectionLayer) { + connectionLayer.style.transformOrigin = "0 0"; + connectionLayer.style.transform = `scale(${zoom})`; + } + + workspaceElement.style.setProperty("--workspace-zoom", `${zoom}`); + this.#applyBackgroundOffset(); + this.#renderConnections(); + } + + /** + * Converts a screen-space point to workspace coordinates. + * + * @param {{ x: number, y: number }} point Screen-space point relative to the workspace element. + * @returns {{ x: number, y: number }} + */ + screenPointToWorld(point) { + const zoom = this.#zoomLevel || 1; + return { + x: point.x / zoom, + y: point.y / zoom, + }; + } + + /** + * Converts a screen-space delta to workspace-space respecting the active zoom. + * + * @param {{ x: number, y: number }} delta Screen-space delta. + * @returns {{ x: number, y: number }} + */ + screenDeltaToWorld(delta) { + const zoom = this.#zoomLevel || 1; + return { + x: delta.x / zoom, + y: delta.y / zoom, + }; + } + + /** + * Applies zoom centred at a screen-space location. + * + * @param {{ x: number, y: number }} screenPoint Screen coordinates inside the workspace. + * @param {number} direction Positive to zoom in, negative to zoom out. + * @param {{ clientX?: number, clientY?: number }} [pointer] Optional pointer coordinates relative to the viewport. + */ + zoomAt(screenPoint, direction, pointer = {}) { + if (!direction) { + return; + } + + const previousZoom = this.#zoomLevel || 1; + const worldPoint = this.screenPointToWorld(screenPoint); + const nextZoom = previousZoom + direction * this.#zoomConfig.step; + const changed = this.setZoomLevel(nextZoom); + if (!changed) { + return; + } + + const currentZoom = this.#zoomLevel || 1; + if (Math.abs(currentZoom - previousZoom) < 0.0001) { + return; + } + + const scale = previousZoom / currentZoom; + const shift = { + x: worldPoint.x * (scale - 1), + y: worldPoint.y * (scale - 1), + }; + + if (Math.abs(shift.x) > 0.0001 || Math.abs(shift.y) > 0.0001) { + this.translateWorkspace(shift); + const { dragManager } = this.#workspace; + if (dragManager?.rebaseActiveDragPointer) { + dragManager.rebaseActiveDragPointer(pointer); + } + this.#rebasePanGestureAfterZoom(pointer); + this.#renderConnections(); + } + } + + /** + * Updates the persisted background offset and re-renders the tiled background. + * + * @param {{ x: number, y: number }} offset Workspace-space offset. + */ + setBackgroundOffset(offset) { + const next = offset ?? { x: 0, y: 0 }; + this.#backgroundOffset = { + x: Number.isFinite(next.x) ? next.x : 0, + y: Number.isFinite(next.y) ? next.y : 0, + }; + this.#applyBackgroundOffset(); + } + + /** + * @returns {{ x: number, y: number }} Current background offset reference. + */ + getBackgroundOffset() { + return this.#backgroundOffset; + } + + /** + * Applies a workspace translation to all nodes and the background. + * + * @param {{ x: number, y: number }} delta Translation amount in workspace units. + */ + translateWorkspace(delta) { + if (!delta || (delta.x === 0 && delta.y === 0)) { + return; + } + + this.setBackgroundOffset({ + x: this.#backgroundOffset.x + delta.x, + y: this.#backgroundOffset.y + delta.y, + }); + + const { graph, dragManager } = this.#workspace; + const panState = this.#panState; + const panDelta = panState ? panState.lastDelta : null; + + graph.nodes.forEach((node) => { + const origin = panState?.nodeOrigins.get(node.id) ?? node.position; + const baseDelta = panDelta ?? { x: 0, y: 0 }; + const next = { + x: origin.x + baseDelta.x + delta.x, + y: origin.y + baseDelta.y + delta.y, + }; + graph.setNodePosition(node.id, next); + if (dragManager?.updateNodeDomPosition) { + dragManager.updateNodeDomPosition(node.id, next); + } + }); + + if (dragManager?.applyActiveDragShift) { + dragManager.applyActiveDragShift(delta); + } + + this.#syncPanStateAfterShift(delta); + } + + /** + * Recentres the viewport on the supplied node if available. + * + * @param {string} nodeId Target node identifier. + */ + focusNode(nodeId) { + const element = this.#workspace.nodeElements.get(nodeId); + if (!element) { + return; + } + + const workspaceRect = this.#workspace.workspaceElement.getBoundingClientRect(); + const nodeRect = element.getBoundingClientRect(); + if (!workspaceRect.width || !workspaceRect.height) { + this.#workspace.selectNode(nodeId); + return; + } + + const workspaceCenterX = workspaceRect.left + workspaceRect.width / 2; + const workspaceCenterY = workspaceRect.top + workspaceRect.height / 2; + const nodeCenterX = nodeRect.left + nodeRect.width / 2; + const nodeCenterY = nodeRect.top + nodeRect.height / 2; + + const deltaXScreen = workspaceCenterX - nodeCenterX; + const deltaYScreen = workspaceCenterY - nodeCenterY; + + if (Math.abs(deltaXScreen) < 1 && Math.abs(deltaYScreen) < 1) { + this.#workspace.selectNode(nodeId); + return; + } + + const delta = this.screenDeltaToWorld({ + x: deltaXScreen, + y: deltaYScreen, + }); + + this.translateWorkspace(delta); + this.#workspace.selectNode(nodeId); + } + + /** + * Frames the supplied node identifiers within the viewport. + * + * @param {Array} nodeIds Node identifiers to include when framing. + */ + frameNodes(nodeIds) { + const { workspaceElement, nodeElements, graph } = this.#workspace; + const rect = workspaceElement.getBoundingClientRect(); + if (!rect.width || !rect.height) { + return; + } + + /** @type {Array<{ id: string, x: number, y: number, width: number, height: number }>} */ + const targets = []; + const collect = (id) => { + const node = graph.nodes.get(id); + const element = nodeElements.get(id); + if (!node || !element || !element.isConnected) { + return; + } + const width = Math.max(1, element.offsetWidth); + const height = Math.max(1, element.offsetHeight); + targets.push({ + id, + x: node.position.x, + y: node.position.y, + width, + height, + }); + }; + + if (Array.isArray(nodeIds) && nodeIds.length) { + nodeIds.forEach((id) => collect(id)); + } else { + nodeElements.forEach((_, id) => collect(id)); + } + + if (!targets.length) { + return; + } + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + targets.forEach((entry) => { + minX = Math.min(minX, entry.x); + minY = Math.min(minY, entry.y); + maxX = Math.max(maxX, entry.x + entry.width); + maxY = Math.max(maxY, entry.y + entry.height); + }); + + if (!Number.isFinite(minX) || !Number.isFinite(minY)) { + return; + } + + const boundsWidth = Math.max(1, maxX - minX); + const boundsHeight = Math.max(1, maxY - minY); + const viewportPadding = 200; + const availableWidth = Math.max(120, rect.width - viewportPadding); + const availableHeight = Math.max(120, rect.height - viewportPadding); + let targetZoom = Math.min(availableWidth / boundsWidth, availableHeight / boundsHeight); + + if (!Number.isFinite(targetZoom) || targetZoom <= 0) { + targetZoom = this.#zoomLevel || 1; + } + + targetZoom = Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, targetZoom)); + this.setZoomLevel(targetZoom); + + const contentCenter = { + x: minX + boundsWidth / 2, + y: minY + boundsHeight / 2, + }; + + const viewportCenter = this.screenPointToWorld({ + x: rect.width / 2, + y: rect.height / 2, + }); + + const delta = { + x: viewportCenter.x - contentCenter.x, + y: viewportCenter.y - contentCenter.y, + }; + + if (Math.abs(delta.x) > 0.0001 || Math.abs(delta.y) > 0.0001) { + this.translateWorkspace(delta); + this.#renderConnections(); + } + + this.#markViewDirty("view", 200); + this.#schedulePersist(); + } + + /** + * Begins a pan gesture driven by the supplied pointer event. + * + * @param {PointerEvent} event Pointer event initiating the pan. + */ + beginPan(event) { + if (this.#panState) { + return; + } + + const { dragManager, contextMenuManager, graph, nodeElements } = + this.#workspace; + + dragManager?.removeNodeGhost(); + dragManager?.removePaletteGhost(); + + const nodeOrigins = new Map(); + graph.nodes.forEach((node) => { + nodeOrigins.set(node.id, { x: node.position.x, y: node.position.y }); + }); + + const state = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + lastDelta: { x: 0, y: 0 }, + hasMoved: false, + nodeOrigins, + backgroundOrigin: { ...this.#backgroundOffset }, + }; + + const handlePointerMove = (moveEvent) => { + if (!this.#panState || moveEvent.pointerId !== this.#panState.pointerId) { + return; + } + + const deltaXScreen = moveEvent.clientX - this.#panState.startX; + const deltaYScreen = moveEvent.clientY - this.#panState.startY; + const worldDelta = this.screenDeltaToWorld({ + x: deltaXScreen, + y: deltaYScreen, + }); + this.#panState.lastDelta = worldDelta; + + if (!this.#panState.hasMoved) { + const distance = Math.hypot(deltaXScreen, deltaYScreen); + if (distance < 3) { + return; + } + moveEvent.preventDefault(); + this.#panState.hasMoved = true; + contextMenuManager?.hide(); + } + + if (!this.#panState.hasMoved) { + return; + } + + const backgroundOffset = { + x: this.#panState.backgroundOrigin.x + worldDelta.x, + y: this.#panState.backgroundOrigin.y + worldDelta.y, + }; + this.setBackgroundOffset(backgroundOffset); + + this.#panState.nodeOrigins.forEach((origin, nodeId) => { + const element = nodeElements.get(nodeId); + if (!element) { + return; + } + const next = { + x: origin.x + worldDelta.x, + y: origin.y + worldDelta.y, + }; + element.style.transform = WorkspaceGeometry.positionToTransform(next); + }); + + this.#renderConnections(); + }; + + const handlePointerUp = (upEvent) => { + if (!this.#panState || upEvent.pointerId !== this.#panState.pointerId) { + return; + } + + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + window.removeEventListener("pointercancel", handlePointerUp); + + const finalState = this.#panState; + this.#panState = null; + + if (!finalState.hasMoved) { + return; + } + + this.#lastPanTimestamp = this.#timestamp(); + const delta = finalState.lastDelta; + this.setBackgroundOffset({ + x: finalState.backgroundOrigin.x + delta.x, + y: finalState.backgroundOrigin.y + delta.y, + }); + finalState.nodeOrigins.forEach((origin, nodeId) => { + graph.setNodePosition(nodeId, { + x: origin.x + delta.x, + y: origin.y + delta.y, + }); + }); + }; + + this.#panState = state; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + window.addEventListener("pointercancel", handlePointerUp); + } + + /** + * Applies persisted view state values. + * + * @param {{ backgroundOffset?: { x: number, y: number }, zoom?: number } | null | undefined} view View state payload. + */ + applyViewState(view) { + const offset = view?.backgroundOffset ?? { x: 0, y: 0 }; + this.setBackgroundOffset(offset); + if (typeof view?.zoom === "number" && Number.isFinite(view.zoom)) { + this.setZoomLevel(view.zoom, { silent: true }); + } else { + this.updateZoomDisplay(); + } + } + + /** + * Determines whether a pan gesture concluded recently. + * + * @param {number} thresholdMs Time window in milliseconds. + * @returns {boolean} + */ + isPanRecent(thresholdMs) { + if (!Number.isFinite(thresholdMs) || thresholdMs <= 0) { + return false; + } + return this.#timestamp() - this.#lastPanTimestamp < thresholdMs; + } + + /** + * @returns {number} Timestamp of the most recent completed pan gesture. + */ + getLastPanTimestamp() { + return this.#lastPanTimestamp; + } + + /** + * Applies the current background offset to the workspace element styles. + */ + #applyBackgroundOffset() { + const zoom = this.#zoomLevel || 1; + const scaledX = this.#backgroundOffset.x * zoom; + const scaledY = this.#backgroundOffset.y * zoom; + const position = `${scaledX}px ${scaledY}px`; + this.#workspace.workspaceElement.style.backgroundPosition = `${position}, ${position}, ${position}, ${position}`; + } + + /** + * Aligns any active pan state with a programmatic workspace translation. + * + * @param {{ x: number, y: number }} shift Workspace-space delta applied externally. + */ + #syncPanStateAfterShift(shift) { + if (!this.#panState) { + return; + } + + if (!shift || (shift.x === 0 && shift.y === 0)) { + return; + } + + this.#panState.backgroundOrigin = { + x: this.#panState.backgroundOrigin.x + shift.x, + y: this.#panState.backgroundOrigin.y + shift.y, + }; + + this.#panState.nodeOrigins.forEach((origin, nodeId, map) => { + map.set(nodeId, { + x: origin.x + shift.x, + y: origin.y + shift.y, + }); + }); + } + + /** + * Rebases the active pan gesture to keep world deltas stable after zoom changes. + * + * @param {{ clientX?: number, clientY?: number }} [pointer] Pointer coordinates supplied by the zoom trigger. + */ + #rebasePanGestureAfterZoom(pointer) { + if (!this.#panState) { + return; + } + + const zoom = this.#zoomLevel || 1; + const { clientX, clientY } = pointer ?? {}; + + if (Number.isFinite(clientX)) { + this.#panState.startX = clientX - this.#panState.lastDelta.x * zoom; + } + + if (Number.isFinite(clientY)) { + this.#panState.startY = clientY - this.#panState.lastDelta.y * zoom; + } + } + + /** + * @returns {number} High-resolution timestamp when available. + */ + #timestamp() { + if ( + typeof performance !== "undefined" && + typeof performance.now === "function" + ) { + return performance.now(); + } + return Date.now(); + } +} diff --git a/scripts/ui/workspace/WorkspaceNodeRenderer.js b/scripts/ui/workspace/WorkspaceNodeRenderer.js new file mode 100644 index 0000000..8cd31ea --- /dev/null +++ b/scripts/ui/workspace/WorkspaceNodeRenderer.js @@ -0,0 +1,420 @@ +/** + * @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace + * @typedef {import('../../core/BlueprintNode.js').BlueprintNode} BlueprintNode + * @typedef {import('../../core/BlueprintNode.js').PinDescriptor} PinDescriptor + * @typedef {import('./WorkspaceInlineEditorManager.js').WorkspaceInlineEditorManager} WorkspaceInlineEditorManager + */ + +import { WorkspaceGeometry } from "./WorkspaceGeometry.js"; + +/** + * @typedef {"input"|"output"} PinDirection + */ + +/** + * Coordinates node DOM rendering, pin creation, and connection class updates. + */ +export class WorkspaceNodeRenderer { + #workspace; + #inlineEditors; + #callbacks; + + /** + * @param {BlueprintWorkspace} workspace Owning workspace instance. + * @param {{ + * inlineEditorManager: WorkspaceInlineEditorManager, + * applySelectionState: () => void, + * selectNode: (nodeId: string) => void, + * startNodeDrag: (event: PointerEvent, nodeId: string) => void, + * refreshVariableNode: (nodeId: string) => boolean, + * refreshLocalVariableNode: (nodeId: string) => boolean, + * refreshCustomEventNode: (nodeId: string) => boolean, + * refreshCustomEventCallNode: (nodeId: string) => boolean, + * renderConnections: () => void, + * shouldHighlightPin: (nodeId: string, pinId: string, direction: PinDirection) => boolean, + * beginConnection: (event: PointerEvent, nodeId: string, pinId: string, direction: PinDirection) => void, + * finalizeConnection: (nodeId: string, pinId: string, direction: PinDirection) => void, + * getPinElement: (nodeId: string, pinId: string, direction: PinDirection) => HTMLElement | null, + * }} options Manager dependencies. + */ + constructor(workspace, options) { + this.#workspace = workspace; + this.#inlineEditors = options.inlineEditorManager; + this.#callbacks = { + applySelectionState: options.applySelectionState, + selectNode: options.selectNode, + startNodeDrag: options.startNodeDrag, + refreshVariableNode: options.refreshVariableNode, + refreshLocalVariableNode: options.refreshLocalVariableNode, + refreshCustomEventNode: options.refreshCustomEventNode, + refreshCustomEventCallNode: options.refreshCustomEventCallNode, + renderConnections: options.renderConnections, + shouldHighlightPin: options.shouldHighlightPin, + beginConnection: options.beginConnection, + finalizeConnection: options.finalizeConnection, + getPinElement: options.getPinElement, + }; + } + + /** + * Renders a node article, wires pin events, and updates inline editors. + * + * @param {BlueprintNode} node Target node. + */ + renderNode(node) { + const fragment = this.#workspace.nodeTemplate.content.cloneNode(true); + const article = /** @type {HTMLElement} */ ( + fragment.querySelector(".blueprint-node") + ); + const title = /** @type {HTMLElement} */ ( + article.querySelector(".node-title") + ); + const inputContainer = /** @type {HTMLElement} */ ( + article.querySelector(".node-inputs") + ); + const outputContainer = /** @type {HTMLElement} */ ( + article.querySelector(".node-outputs") + ); + + title.textContent = node.title; + article.dataset.nodeId = node.id; + article.dataset.nodeType = node.type; + article.style.transform = WorkspaceGeometry.positionToTransform( + node.position + ); + + this.#inlineEditors.setupLiteralEditor(node, article, inputContainer); + + inputContainer.innerHTML = ""; + node.inputs.forEach((pin) => { + const pinElement = this.#createPinElement(node, pin); + pinElement.dataset.direction = "input"; + inputContainer.appendChild(pinElement); + this.#inlineEditors.setupPinEditor(node, pin, pinElement); + }); + + outputContainer.innerHTML = ""; + node.outputs.forEach((pin) => { + const pinElement = this.#createPinElement(node, pin); + pinElement.dataset.direction = "output"; + outputContainer.appendChild(pinElement); + this.#inlineEditors.setupPinEditor(node, pin, pinElement); + }); + + article.addEventListener("pointerdown", (event) => { + const target = /** @type {EventTarget | null} */ (event.target); + if (!(target instanceof HTMLElement)) { + return; + } + if (target.closest(".pin-handle")) { + return; + } + + const selectedIds = this.#workspace.selectedNodeIds; + if (selectedIds.has(node.id)) { + if (this.#workspace.selectedNodeId !== node.id) { + this.#workspace.selectedNodeId = node.id; + this.#callbacks.applySelectionState(); + } + } else { + this.#callbacks.selectNode(node.id); + } + + const isMouse = + event.pointerType === "mouse" || event.pointerType === ""; + if (isMouse && event.button !== 0) { + return; + } + if ( + !isMouse && + event.pointerType !== "touch" && + event.pointerType !== "pen" + ) { + return; + } + + if (target.closest(".pin")) { + return; + } + + if (this.#isInteractiveElement(target)) { + return; + } + + this.#callbacks.startNodeDrag(event, node.id); + }); + + article.addEventListener("pointerenter", () => { + article.classList.add("is-hovered"); + }); + + article.addEventListener("pointerleave", () => { + article.classList.remove("is-hovered"); + }); + + this.#workspace.nodeLayer.appendChild(article); + this.#workspace.nodeElements.set(node.id, article); + this.#inlineEditors.syncNode(node.id); + + let connectionsMutated = false; + connectionsMutated = + this.#callbacks.refreshVariableNode(node.id) || connectionsMutated; + if (this.#callbacks.refreshLocalVariableNode) { + connectionsMutated = + this.#callbacks.refreshLocalVariableNode(node.id) || + connectionsMutated; + } + if (node.type === "custom_event") { + connectionsMutated = + this.#callbacks.refreshCustomEventNode(node.id) || connectionsMutated; + } else if (node.type === "call_custom_event") { + connectionsMutated = + this.#callbacks.refreshCustomEventCallNode(node.id) || + connectionsMutated; + } + if (connectionsMutated) { + this.#callbacks.renderConnections(); + } + + this.updatePinConnectionsForNode(node.id); + } + + /** + * Rebuilds DOM pin containers for a node when its descriptors change. + * + * @param {BlueprintNode} node Target node. + * @param {{ inputs?: boolean, outputs?: boolean }} [options] Pin refresh options. + */ + rebuildNodePins(node, options = {}) { + const { inputs = true, outputs = true } = options; + const article = this.#workspace.nodeElements.get(node.id); + if (!article) { + return; + } + + if (inputs) { + const inputContainer = article.querySelector(".node-inputs"); + if (inputContainer instanceof HTMLElement) { + inputContainer.innerHTML = ""; + node.inputs.forEach((pin) => { + const pinElement = this.#createPinElement(node, pin); + pinElement.dataset.direction = "input"; + inputContainer.appendChild(pinElement); + this.#inlineEditors.setupPinEditor(node, pin, pinElement); + }); + } + this.#inlineEditors.prunePinEditors(node); + } + + if (outputs) { + const outputContainer = article.querySelector(".node-outputs"); + if (outputContainer instanceof HTMLElement) { + outputContainer.innerHTML = ""; + node.outputs.forEach((pin) => { + const pinElement = this.#createPinElement(node, pin); + pinElement.dataset.direction = "output"; + outputContainer.appendChild(pinElement); + this.#inlineEditors.setupPinEditor(node, pin, pinElement); + }); + } + } + + this.updatePinConnectionsForNode(node.id); + } + + /** + * Applies connection state classes to every pin associated with a node. + * + * @param {string} nodeId Node identifier. + */ + updatePinConnectionsForNode(nodeId) { + const node = this.#workspace.graph.nodes.get(nodeId); + if (!node) { + return; + } + + node.inputs.forEach((pin) => { + this.#applyPinConnectionState(nodeId, pin.id, "input"); + }); + node.outputs.forEach((pin) => { + this.#applyPinConnectionState(nodeId, pin.id, "output"); + }); + } + + /** + * Refreshes connection state classes for every pin in the workspace. + */ + refreshAllPinConnections() { + this.#workspace.graph.nodes.forEach((node) => { + this.updatePinConnectionsForNode(node.id); + }); + } + + /** + * Creates and wires a pin element for the provided descriptor. + * + * @param {BlueprintNode} node Parent node reference. + * @param {PinDescriptor} pin Pin descriptor. + * @returns {HTMLElement} + */ + #createPinElement(node, pin) { + const fragment = this.#workspace.pinTemplate.content.cloneNode(true); + const container = /** @type {HTMLElement} */ (fragment.firstElementChild); + const label = /** @type {HTMLElement} */ ( + container.querySelector(".pin-label") + ); + const handle = /** @type {HTMLElement} */ ( + container.querySelector(".pin-handle") + ); + + container.dataset.pinId = pin.id; + container.dataset.nodeId = node.id; + container.dataset.type = pin.kind; + const isStandardExec = + pin.kind === "exec" && (pin.id === "exec_in" || pin.id === "exec_out"); + if (isStandardExec) { + label.textContent = ""; + label.classList.add("is-hidden"); + } else { + label.textContent = pin.name; + } + + const shouldIgnoreForConnection = (target) => { + if (!(target instanceof HTMLElement)) { + return false; + } + return Boolean(target.closest(".pin-inline-control")); + }; + + const handlePointerDown = (event) => { + if (event.button !== 0) { + return; + } + if (shouldIgnoreForConnection(event.target)) { + return; + } + + if (event.altKey) { + event.preventDefault(); + event.stopPropagation(); + this.#workspace.graph.removeConnectionsForPin({ + nodeId: node.id, + pinId: pin.id, + }); + return; + } + + event.stopPropagation(); + event.preventDefault(); + this.#callbacks.beginConnection(event, node.id, pin.id, pin.direction); + }; + + const finalizeConnection = (event) => { + if (shouldIgnoreForConnection(event.target)) { + return; + } + if (!this.#workspace.pendingConnection) { + return; + } + event.stopPropagation(); + this.#callbacks.finalizeConnection(node.id, pin.id, pin.direction); + }; + + handle.addEventListener("pointerdown", handlePointerDown); + handle.addEventListener("pointerup", finalizeConnection); + + container.addEventListener("pointerdown", handlePointerDown); + container.addEventListener("pointerup", finalizeConnection); + + container.addEventListener("pointerenter", () => { + container.classList.add("is-hovered"); + if ( + this.#callbacks.shouldHighlightPin(node.id, pin.id, pin.direction) + ) { + container.classList.add("is-drop-hover"); + } + }); + + container.addEventListener("pointerleave", () => { + container.classList.remove("is-hovered"); + container.classList.remove("is-drop-hover"); + }); + + const handleContextMenu = (event) => { + if (shouldIgnoreForConnection(event.target)) { + return; + } + event.preventDefault(); + this.#workspace.graph.removeConnectionsForPin({ + nodeId: node.id, + pinId: pin.id, + }); + }; + + handle.addEventListener("contextmenu", handleContextMenu); + container.addEventListener("contextmenu", handleContextMenu); + + this.#applyPinConnectionState(node.id, pin.id, pin.direction); + return container; + } + + /** + * Applies connection state classes to the targeted pin element. + * + * @param {string} nodeId Node identifier. + * @param {string} pinId Pin identifier. + * @param {PinDirection} direction Pin direction. + */ + #applyPinConnectionState(nodeId, pinId, direction) { + const element = this.#callbacks.getPinElement(nodeId, pinId, direction); + if (!element) { + return; + } + + const connected = this.#hasConnection(nodeId, pinId); + element.classList.toggle("is-disconnected", !connected); + element.classList.toggle("is-connected", connected); + } + + /** + * Determines whether a pin currently participates in any connection. + * + * @param {string} nodeId Node identifier. + * @param {string} pinId Pin identifier. + * @returns {boolean} + */ + #hasConnection(nodeId, pinId) { + return this.#workspace.graph + .getConnectionsForNode(nodeId, pinId) + .some((connection) => { + const isTarget = + connection.to.nodeId === nodeId && connection.to.pinId === pinId; + const isSource = + connection.from.nodeId === nodeId && + connection.from.pinId === pinId; + return isTarget || isSource; + }); + } + + /** + * Determines whether a node event target should block drag initiation. + * + * @param {HTMLElement} element Candidate event target. + * @returns {boolean} + */ + #isInteractiveElement(element) { + if (element.closest(".pin-inline-control")) { + return true; + } + if (element.closest(".literal-inline-editor")) { + return true; + } + if (element.closest('[contenteditable="true"]')) { + return true; + } + if (element.closest("input, textarea, select, button")) { + return true; + } + return false; + } +} diff --git a/scripts/ui/workspace/WorkspacePaletteManager.js b/scripts/ui/workspace/WorkspacePaletteManager.js new file mode 100644 index 0000000..13d8e79 --- /dev/null +++ b/scripts/ui/workspace/WorkspacePaletteManager.js @@ -0,0 +1,295 @@ +/** + * @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace + * @typedef {import('../../nodes/nodeTypes.js').NodeDefinition} NodeDefinition + * @typedef {import('../BlueprintWorkspace.js').PaletteNodeDefinition} PaletteNodeDefinition + */ + +import { PALETTE_DRAG_MIME } from "../BlueprintWorkspaceConstants.js"; + +/** + * Coordinates palette rendering, search handling, and palette drag state. + */ +export class WorkspacePaletteManager { + #workspace; + /** @type {Set} */ + #collapsedCategories; + + /** + * @param {BlueprintWorkspace} workspace Owning workspace instance. + */ + constructor(workspace) { + this.#workspace = workspace; + this.#collapsedCategories = new Set(); + } + + /** + * Wires palette UI listeners and renders initial content. + */ + initialize() { + const search = this.#workspace.paletteSearch; + search.addEventListener("input", () => { + this.render(search.value); + }); + + this.render(); + } + + /** + * Recreates palette DOM structure using the provided search filter. + * + * @param {string} [query] Optional search query. + */ + render(query) { + const searchValue = query ?? ""; + const baseDefinitions = this.#workspace.registry.search(searchValue); + const filteredBase = baseDefinitions.filter( + (definition) => definition.id !== "get_var" && definition.id !== "set_var" + ); + const variableShortcuts = + this.#buildVariableShortcutDefinitions(searchValue); + const definitions = filteredBase.concat(variableShortcuts); + + const list = this.#workspace.paletteList; + list.innerHTML = ""; + + if (!definitions.length) { + const empty = document.createElement("p"); + empty.className = "palette-empty"; + empty.textContent = "No matching nodes"; + list.appendChild(empty); + return; + } + + const grouped = new Map(); + definitions.forEach((definition) => { + if (!grouped.has(definition.category)) { + grouped.set(definition.category, []); + } + grouped.get(definition.category).push(definition); + }); + + const isSearchActive = Boolean(searchValue && searchValue.trim().length); + const categories = [...grouped.keys()].sort((a, b) => a.localeCompare(b)); + + categories.forEach((category) => { + const entries = grouped + .get(category) + .slice() + .sort((a, b) => a.title.localeCompare(b.title)); + + const section = document.createElement("section"); + section.className = "palette-section"; + section.dataset.category = category; + + const header = document.createElement("button"); + header.type = "button"; + header.className = "palette-section-header"; + header.textContent = category; + const slug = category.toLowerCase().replace(/[^a-z0-9]+/g, "_"); + const headerId = `palette_section_${slug}`; + const bodyId = `${headerId}_body`; + header.id = headerId; + header.setAttribute("aria-controls", bodyId); + const collapsed = + !isSearchActive && this.#collapsedCategories.has(category); + header.setAttribute("aria-expanded", String(!collapsed)); + header.addEventListener("click", () => { + if (this.#collapsedCategories.has(category)) { + this.#collapsedCategories.delete(category); + } else { + this.#collapsedCategories.add(category); + } + this.render(this.#workspace.paletteSearch.value); + requestAnimationFrame(() => { + const refreshedSection = [ + ...this.#workspace.paletteList.querySelectorAll(".palette-section"), + ].find((element) => element.dataset.category === category); + const refreshedHeader = refreshedSection?.querySelector( + ".palette-section-header" + ); + if (refreshedHeader instanceof HTMLButtonElement) { + refreshedHeader.focus(); + } + }); + }); + section.appendChild(header); + + const body = document.createElement("div"); + body.className = "palette-section-body"; + body.setAttribute("role", "group"); + body.id = bodyId; + body.setAttribute("aria-labelledby", headerId); + body.dataset.category = category; + if (collapsed) { + body.hidden = true; + section.classList.add("is-collapsed"); + } + + entries.forEach((definition) => { + const isVariableShortcut = Boolean( + definition.shortcut?.type === "variable" + ); + const item = this.#createDefinitionButton(definition, { + className: "palette-item", + showCategory: false, + draggable: !isVariableShortcut, + onSelect: () => { + this.#workspace.createNodeFromDefinition(definition); + }, + onDragStart: isVariableShortcut + ? undefined + : (event) => { + this.#handlePaletteDragStart(event, definition.id); + }, + onDragEnd: isVariableShortcut + ? undefined + : () => { + this.#handlePaletteDragEnd(); + }, + }); + body.appendChild(item); + }); + + section.appendChild(body); + list.appendChild(section); + }); + } + + /** + * Allows external callers to cancel palette drag state. + */ + handleDragEnd() { + this.#handlePaletteDragEnd(); + } + + /** + * Builds a button node for palette entries with attached drag/click handlers. + * + * @param {PaletteNodeDefinition} definition Target definition. + * @param {{ className?: string, showCategory?: boolean, draggable?: boolean, onSelect: () => void, onDragStart?: (event: DragEvent) => void, onDragEnd?: (event: DragEvent) => void }} options Button configuration. + * @returns {HTMLButtonElement} + */ + #createDefinitionButton(definition, options) { + const button = document.createElement("button"); + button.type = "button"; + button.className = options?.className ?? "palette-item"; + button.dataset.definitionId = definition.id; + + const showCategory = options?.showCategory ?? true; + + if (showCategory) { + const category = document.createElement("span"); + category.className = "palette-item-category"; + category.textContent = definition.category; + button.appendChild(category); + } + + const title = document.createElement("span"); + title.className = "palette-item-name"; + title.textContent = definition.title; + button.appendChild(title); + + if (definition.description) { + button.title = definition.description; + } + + button.addEventListener("click", () => { + options.onSelect(); + }); + + if (options?.draggable) { + button.draggable = true; + button.addEventListener("dragstart", (event) => { + options.onDragStart?.(event); + }); + button.addEventListener("dragend", (event) => { + options.onDragEnd?.(event); + }); + } + + return button; + } + + /** + * Prepares drag metadata when beginning a palette drag interaction. + * + * @param {DragEvent} event Drag event payload. + * @param {string} definitionId Node definition identifier being dragged. + */ + #handlePaletteDragStart(event, definitionId) { + if (!event.dataTransfer) { + return; + } + + event.dataTransfer.setData(PALETTE_DRAG_MIME, definitionId); + event.dataTransfer.setData("text/plain", definitionId); + event.dataTransfer.effectAllowed = "copy"; + this.#workspace.workspaceDragDepth = 0; + this.#workspace.workspaceElement.classList.add("is-palette-dragging"); + this.#workspace.activePaletteDefinitionId = definitionId; + } + + /** + * Cleans up workspace styling once a palette drag interaction ends. + */ + #handlePaletteDragEnd() { + this.#workspace.workspaceDragDepth = 0; + this.#workspace.workspaceElement.classList.remove("is-palette-dragging"); + this.#workspace.workspaceElement.classList.remove("is-drag-target"); + this.#workspace.activePaletteDefinitionId = null; + this.#workspace.dragManager.removePaletteGhost(); + } + + /** + * Builds shortcut node definitions for existing workspace variables. + * + * @param {string} [query] Optional search filter. + * @returns {Array} + */ + #buildVariableShortcutDefinitions(query) { + const normalizedQuery = (query ?? "").trim().toLowerCase(); + const includeAll = normalizedQuery.length === 0; + const entries = []; + + this.#workspace.variableOrder.forEach((variableId) => { + const variable = this.#workspace.variables.get(variableId); + if (!variable) { + return; + } + + const displayName = (variable.name ?? "").trim() || variable.id; + const normalizedName = displayName.toLowerCase(); + const tokens = [ + normalizedName, + variableId.toLowerCase(), + `get ${normalizedName}`, + `set ${normalizedName}`, + ]; + + if ( + !includeAll && + !tokens.some((token) => token.includes(normalizedQuery)) + ) { + return; + } + + entries.push({ + id: `variable_get_${variableId}`, + title: `Get ${displayName}`, + category: "Variables", + description: `Get ${displayName}`, + shortcut: { type: "variable", action: "get", variableId }, + }); + + entries.push({ + id: `variable_set_${variableId}`, + title: `Set ${displayName}`, + category: "Variables", + description: `Set ${displayName}`, + shortcut: { type: "variable", action: "set", variableId }, + }); + }); + + return entries; + } +} diff --git a/scripts/ui/workspace/WorkspacePersistenceManager.js b/scripts/ui/workspace/WorkspacePersistenceManager.js new file mode 100644 index 0000000..5d66c1b --- /dev/null +++ b/scripts/ui/workspace/WorkspacePersistenceManager.js @@ -0,0 +1,213 @@ +/** + * @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace + * @typedef {import('./WorkspaceHistoryManager.js').SerializedWorkspace} SerializedWorkspace + */ + +import { WORKSPACE_STORAGE_KEY } from "../BlueprintWorkspaceConstants.js"; + +/** + * Handles saving and restoring workspace state using localStorage. + */ +export class WorkspacePersistenceManager { + #workspace; + #timerId; + #storageAvailable; + + /** + * @param {BlueprintWorkspace} workspace Owning workspace instance. + */ + constructor(workspace) { + this.#workspace = workspace; + this.#timerId = null; + this.#storageAvailable = this.#detectLocalStorage(); + } + + /** + * Indicates whether localStorage is available for persistence. + * + * @returns {boolean} + */ + get isStorageAvailable() { + return this.#storageAvailable; + } + + /** + * Clears any pending persist timer. + */ + cancelScheduledPersist() { + if (this.#timerId !== null && typeof window !== "undefined") { + window.clearTimeout(this.#timerId); + this.#timerId = null; + } + } + + /** + * Persists workspace state after a small debounce to reduce write frequency. + */ + schedulePersist() { + if ( + !this.#storageAvailable || + this.#workspace.isRestoring || + typeof window === "undefined" + ) { + return; + } + + this.cancelScheduledPersist(); + this.#timerId = window.setTimeout(() => { + this.#timerId = null; + this.persistImmediately(); + }, 200); + } + + /** + * Serializes current workspace state and writes it to localStorage immediately. + */ + persistImmediately() { + if (!this.#storageAvailable || typeof window === "undefined") { + return; + } + + try { + const payload = this.serializeWorkspace(); + window.localStorage.setItem( + WORKSPACE_STORAGE_KEY, + JSON.stringify(payload) + ); + } catch (error) { + console.error("Failed to persist workspace state", error); + } + } + + /** + * Creates a serializable snapshot of the workspace. + * + * @returns {SerializedWorkspace} + */ + serializeWorkspace() { + const variableEntries = this.#workspace.variableOrder + .map((variableId) => this.#workspace.variables.get(variableId)) + .filter((entry) => Boolean(entry)) + .map((entry) => { + const variable = + /** @type {{ id: string, name: string, type: 'any'|'number'|'string'|'boolean'|'table', defaultValue: import('../BlueprintWorkspace.js').VariableDefaultValue }} */ ( + entry + ); + return { + id: variable.id, + name: variable.name, + type: variable.type, + defaultValue: + variable.defaultValue === undefined + ? null + : this.#cloneVariableDefault(variable.defaultValue), + }; + }); + + return { + version: 4, + projectSettings: { ...this.#workspace.projectSettings }, + graph: this.#workspace.graph.toJSON(), + variables: { + entries: variableEntries, + counter: this.#workspace.variableCounter, + }, + spawnIndex: this.#workspace.spawnIndex, + ui: { + paletteVisible: Boolean(this.#workspace.isPaletteVisible), + }, + }; + } + + /** + * Attempts to load workspace state from localStorage. + * + * @returns {boolean} Whether a valid payload was applied. + */ + restoreFromStorage() { + if (!this.#storageAvailable || typeof window === "undefined") { + return false; + } + + let raw = null; + try { + raw = window.localStorage.getItem(WORKSPACE_STORAGE_KEY); + } catch (error) { + console.error("Failed to access stored workspace state", error); + return false; + } + + if (!raw) { + return false; + } + + try { + const payload = JSON.parse(raw); + const hasGraph = + payload && + typeof payload === "object" && + payload.graph && + typeof payload.graph === "object" && + Array.isArray(payload.graph.nodes) && + Array.isArray(payload.graph.connections); + if (!hasGraph) { + return false; + } + + this.#workspace.applySerializedWorkspace(payload); + return true; + } catch (error) { + console.error("Failed to restore workspace state", error); + return false; + } + } + + /** + * Detects whether localStorage is available in the execution environment. + * + * @returns {boolean} + */ + #detectLocalStorage() { + try { + if (typeof window === "undefined" || !("localStorage" in window)) { + return false; + } + const probeKey = "__picograph_probe__"; + window.localStorage.setItem(probeKey, "1"); + window.localStorage.removeItem(probeKey); + return true; + } catch (error) { + return false; + } + } + + /** + * Produces a JSON-safe clone of a variable default value. + * + * @param {import('../BlueprintWorkspace.js').VariableDefaultValue} value Default value to clone. + * @returns {import('../BlueprintWorkspace.js').VariableDefaultValue} + */ + #cloneVariableDefault(value) { + if (Array.isArray(value)) { + return value.map((entry) => ({ + id: typeof entry?.id === "string" ? entry.id : "", + key: typeof entry?.key === "string" ? entry.key : "", + value: typeof entry?.value === "string" ? entry.value : "", + })); + } + + if (value === undefined || value === null) { + return null; + } + + if (typeof value === "object") { + try { + return JSON.parse(JSON.stringify(value)); + } catch (_error) { + return null; + } + } + + return value; + } +} diff --git a/scripts/ui/workspace/renderEventList.js b/scripts/ui/workspace/renderEventList.js new file mode 100644 index 0000000..14d09e2 --- /dev/null +++ b/scripts/ui/workspace/renderEventList.js @@ -0,0 +1,108 @@ +/** + * Renders the event summary list. + * + * @param {object} options Rendering options. + * @param {HTMLUListElement | null} options.element Target list element. + * @param {import('../../nodes/NodeRegistry.js').NodeRegistry} options.registry Node registry instance. + * @param {import('../../core/NodeGraph.js').NodeGraph} options.graph Active node graph. + * @param {(nodeId: string) => void} options.focusNode Callback to focus a node. + * @param {(message: string) => void} options.renderEmpty Callback to render empty state. + * @param {(node: import('../../core/BlueprintNode.js').BlueprintNode, entryTypes: Set) => boolean} options.isEventNode Predicate to determine if node represents an event. + */ +export function renderEventList({ + element, + registry, + graph, + focusNode, + renderEmpty, + isEventNode, +}) { + if (!element) { + return; + } + + element.innerHTML = ""; + + const entryTypes = new Set(registry.getEntryNodeTypes()); + const events = [...graph.nodes.values()].filter((node) => + isEventNode(node, entryTypes) + ); + const builtInEvents = []; + const customEvents = []; + + events.forEach((node) => { + if (node.type === "custom_event") { + customEvents.push(node); + } else { + builtInEvents.push(node); + } + }); + + if (!builtInEvents.length && !customEvents.length) { + renderEmpty("No events yet"); + return; + } + + const appendHeading = (title) => { + const headingItem = document.createElement("li"); + headingItem.className = "overview-subheading"; + headingItem.textContent = title; + element.appendChild(headingItem); + }; + + const appendEventItem = (node, displayName) => { + const item = document.createElement("li"); + const button = document.createElement("button"); + button.type = "button"; + button.className = "overview-item"; + button.dataset.nodeId = node.id; + button.addEventListener("click", () => { + focusNode(node.id); + }); + + const label = document.createElement("span"); + label.className = "overview-item-label"; + label.textContent = displayName; + button.appendChild(label); + + const meta = document.createElement("span"); + meta.className = "overview-item-meta"; + meta.textContent = node.id; + button.appendChild(meta); + + item.appendChild(button); + element.appendChild(item); + }; + + if (builtInEvents.length) { + appendHeading("Events"); + builtInEvents + .slice() + .sort((a, b) => a.title.localeCompare(b.title)) + .forEach((node) => appendEventItem(node, node.title)); + } + + if (customEvents.length) { + appendHeading("Custom Events"); + customEvents + .slice() + .sort((a, b) => { + const nameA = + (typeof a.properties.name === "string" + ? a.properties.name.trim() + : "") || "CustomEvent"; + const nameB = + (typeof b.properties.name === "string" + ? b.properties.name.trim() + : "") || "CustomEvent"; + return nameA.localeCompare(nameB); + }) + .forEach((node) => { + const displayName = + (typeof node.properties.name === "string" + ? node.properties.name.trim() + : "") || "CustomEvent"; + appendEventItem(node, displayName); + }); + } +} diff --git a/scripts/ui/workspace/renderVariableList.js b/scripts/ui/workspace/renderVariableList.js new file mode 100644 index 0000000..98af967 --- /dev/null +++ b/scripts/ui/workspace/renderVariableList.js @@ -0,0 +1,442 @@ +import { VARIABLE_SORT_MIME } from "../BlueprintWorkspaceConstants.js"; + +/** + * Renders the global variable summary list. + * + * @param {object} options Rendering options. + * @param {HTMLUListElement | null} options.element Target list element. + * @param {Array} options.variableOrder Display order of variable identifiers. + * @param {Map} options.variables Registered variables. + * @param {import('../../core/NodeGraph.js').NodeGraph} options.graph Active node graph. + * @param {() => void} options.cancelVariableRename Callback to cancel an in-progress rename. + * @param {() => void} options.closeVariableTypeDropdown Callback to close the variable type dropdown. + * @param {(message: string) => void} options.renderEmpty Renders an empty-state message. + * @param {(name: string) => string} options.normalizeVariableName Normalizes variable names for comparisons. + * @param {(type: import('../BlueprintWorkspace.js').VariableType) => string} options.formatVariableType Creates a human-readable type label. + * @param {(variableId: string) => void} options.selectVariable Activates a variable selection. + * @param {(event: DragEvent, variableId: string) => void} options.handleVariableDragStart Prepares workspace drag metadata for a variable. + * @param {() => void} options.handleVariableDragEnd Clears workspace drag feedback for variables. + * @param {(variableId: string, targetIndex: number) => void} options.reorderVariable Reorders a variable in the list. + * @param {(variableId: string, button: HTMLButtonElement, anchor: HTMLElement) => void} options.openVariableTypeDropdown Opens the variable type dropdown. + * @param {(variableId: string) => void} options.deleteVariable Removes the selected variable. + * @param {(variableId: string, label: HTMLElement) => void} options.startVariableRename Begins inline rename mode. + * @param {() => void} options.highlightVariableSelection Refreshes selection highlight state. + * @param {(variableId: string) => boolean} options.isVariableDropdownOpen Determines whether the dropdown is already open for a variable. + */ +export function renderVariableList({ + element, + variableOrder, + variables, + graph, + cancelVariableRename, + closeVariableTypeDropdown, + renderEmpty, + normalizeVariableName, + formatVariableType, + selectVariable, + handleVariableDragStart, + handleVariableDragEnd, + reorderVariable, + openVariableTypeDropdown, + deleteVariable, + startVariableRename, + highlightVariableSelection, + isVariableDropdownOpen, +}) { + if (!element) { + return; + } + + cancelVariableRename(); + closeVariableTypeDropdown(); + element.innerHTML = ""; + + if (!variableOrder.length) { + renderEmpty("No variables yet"); + return; + } + + const SORT_MIME = VARIABLE_SORT_MIME; + let draggedVariableId = null; + + /** + * Determines whether a drag event carries the variable sort payload. + * + * @param {DragEvent} event Drag event payload. + * @returns {boolean} + */ + const hasSortPayload = (event) => { + if (!event.dataTransfer) { + return false; + } + const types = event.dataTransfer.types; + if (!types) { + return false; + } + return Array.from(types).includes(SORT_MIME); + }; + + const clearDropIndicators = () => { + const rows = element.querySelectorAll( + ".overview-item--variable.is-drop-before, .overview-item--variable.is-drop-after" + ); + rows.forEach((node) => { + node.classList.remove("is-drop-before", "is-drop-after"); + }); + }; + + const clearDragState = () => { + clearDropIndicators(); + const rows = element.querySelectorAll( + ".overview-item--variable.is-dragging" + ); + rows.forEach((node) => { + node.classList.remove("is-dragging"); + }); + draggedVariableId = null; + }; + + const setDropIndicator = (row, position) => { + clearDropIndicators(); + if (!row) { + return; + } + if (position === "before") { + row.classList.add("is-drop-before"); + } else if (position === "after") { + row.classList.add("is-drop-after"); + } + }; + + element.addEventListener("dragover", (event) => { + if (!draggedVariableId || !hasSortPayload(event)) { + return; + } + const targetRow = + event.target instanceof HTMLElement + ? event.target.closest(".overview-item--variable") + : null; + if (targetRow) { + return; + } + event.preventDefault(); + const lastRow = element.querySelector( + ".overview-item--variable:last-of-type" + ); + if (!lastRow) { + return; + } + setDropIndicator(lastRow, "after"); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = "move"; + } + }); + + element.addEventListener("drop", (event) => { + if (!draggedVariableId || !hasSortPayload(event)) { + return; + } + const targetRow = + event.target instanceof HTMLElement + ? event.target.closest(".overview-item--variable") + : null; + if (targetRow) { + return; + } + event.preventDefault(); + const sourceId = + (event.dataTransfer && event.dataTransfer.getData(SORT_MIME)) || + draggedVariableId; + const sourceIndex = variableOrder.indexOf(sourceId); + if (sourceIndex === -1) { + if (typeof handleVariableDragEnd === "function") { + handleVariableDragEnd(); + } + clearDragState(); + return; + } + let targetIndex = variableOrder.length; + if (targetIndex > sourceIndex) { + targetIndex -= 1; + } + if (targetIndex !== sourceIndex) { + reorderVariable(sourceId, targetIndex); + } + if (typeof handleVariableDragEnd === "function") { + handleVariableDragEnd(); + } + clearDragState(); + }); + + element.addEventListener("dragleave", (event) => { + if (!draggedVariableId) { + return; + } + const related = event.relatedTarget; + if (related instanceof HTMLElement && element.contains(related)) { + return; + } + clearDropIndicators(); + }); + + /** @type {Map} */ + const usageById = new Map(); + /** @type {Map} */ + const usageByName = new Map(); + + graph.nodes.forEach((node) => { + if (node.type !== "set_var" && node.type !== "get_var") { + return; + } + + const nodeVariableId = + typeof node.properties.variableId === "string" && + node.properties.variableId + ? node.properties.variableId + : ""; + const raw = + typeof node.properties.name === "string" ? node.properties.name : ""; + const normalized = normalizeVariableName(raw) || "__blank__"; + const usageMap = + nodeVariableId && variables.has(nodeVariableId) ? usageById : usageByName; + const key = + nodeVariableId && variables.has(nodeVariableId) + ? nodeVariableId + : normalized; + const usage = usageMap.get(key) ?? { sets: 0, gets: 0 }; + if (node.type === "set_var") { + usage.sets += 1; + } else { + usage.gets += 1; + } + usageMap.set(key, usage); + }); + + variableOrder.forEach((variableId) => { + const variable = variables.get(variableId); + if (!variable) { + return; + } + + const item = document.createElement("li"); + const row = document.createElement("div"); + row.className = "overview-item overview-item--variable"; + row.tabIndex = 0; + row.setAttribute("role", "button"); + row.dataset.variableId = variable.id; + row.dataset.variableType = variable.type; + row.draggable = true; + row.setAttribute("aria-pressed", "false"); + + const activate = () => { + selectVariable(variable.id); + }; + + row.addEventListener("click", () => { + activate(); + }); + + row.addEventListener("dragstart", (event) => { + activate(); + draggedVariableId = variable.id; + row.classList.add("is-dragging"); + if (typeof handleVariableDragStart === "function") { + handleVariableDragStart(event, variable.id); + } + if (event.dataTransfer) { + event.dataTransfer.setData(SORT_MIME, variable.id); + event.dataTransfer.effectAllowed = "copyMove"; + } + }); + + row.addEventListener("dragend", () => { + if (typeof handleVariableDragEnd === "function") { + handleVariableDragEnd(); + } + clearDragState(); + }); + + row.addEventListener("dragover", (event) => { + if (!draggedVariableId || !hasSortPayload(event)) { + return; + } + event.preventDefault(); + const rect = row.getBoundingClientRect(); + const midpoint = rect.top + rect.height / 2; + const position = event.clientY <= midpoint ? "before" : "after"; + setDropIndicator(row, position); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = "move"; + } + }); + + row.addEventListener("dragleave", (event) => { + if (!draggedVariableId) { + return; + } + const related = event.relatedTarget; + if (related instanceof HTMLElement && row.contains(related)) { + return; + } + clearDropIndicators(); + }); + + row.addEventListener("drop", (event) => { + if (!draggedVariableId || !hasSortPayload(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const sourceId = + (event.dataTransfer && event.dataTransfer.getData(SORT_MIME)) || + draggedVariableId; + const sourceIndex = variableOrder.indexOf(sourceId); + const targetId = row.dataset.variableId || ""; + const targetIndexRaw = variableOrder.indexOf(targetId); + if (sourceIndex === -1 || targetIndexRaw === -1) { + if (typeof handleVariableDragEnd === "function") { + handleVariableDragEnd(); + } + clearDragState(); + return; + } + const rect = row.getBoundingClientRect(); + const midpoint = rect.top + rect.height / 2; + const dropPosition = event.clientY <= midpoint ? "before" : "after"; + let targetIndex = targetIndexRaw; + if (dropPosition === "after") { + targetIndex += 1; + } + if (targetIndex > variableOrder.length) { + targetIndex = variableOrder.length; + } + if (targetIndex > sourceIndex) { + targetIndex -= 1; + } + if (targetIndex !== sourceIndex) { + reorderVariable(sourceId, targetIndex); + } + if (typeof handleVariableDragEnd === "function") { + handleVariableDragEnd(); + } + clearDragState(); + }); + + const layout = document.createElement("div"); + layout.className = "variable-overview-row"; + row.appendChild(layout); + + const typeButton = document.createElement("button"); + typeButton.type = "button"; + typeButton.className = "variable-type-button"; + typeButton.dataset.variableType = variable.type; + typeButton.setAttribute("aria-haspopup", "menu"); + typeButton.setAttribute("aria-expanded", "false"); + typeButton.title = `Type: ${formatVariableType(variable.type)}`; + typeButton.setAttribute( + "aria-label", + `Change type for ${ + variable.name || variable.id + } (currently ${formatVariableType(variable.type)})` + ); + typeButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + activate(); + const isOpen = isVariableDropdownOpen(variable.id); + if (isOpen) { + closeVariableTypeDropdown(); + } else { + openVariableTypeDropdown(variable.id, typeButton, row); + } + }); + typeButton.addEventListener("pointerdown", (event) => { + event.stopPropagation(); + }); + typeButton.addEventListener("keydown", (event) => { + const isToggleKey = + event.key === "Enter" || event.key === " " || event.key === "Spacebar"; + if (isToggleKey || event.key === "ArrowDown") { + event.preventDefault(); + activate(); + openVariableTypeDropdown(variable.id, typeButton, row); + } else if (event.key === "Escape") { + event.preventDefault(); + closeVariableTypeDropdown(); + } + }); + layout.appendChild(typeButton); + + const label = document.createElement("span"); + label.className = "overview-item-label variable-name-display"; + label.tabIndex = 0; + label.textContent = variable.name || "Unnamed"; + label.title = variable.name || "Unnamed"; + label.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + activate(); + startVariableRename(variable.id, label); + }); + label.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + activate(); + startVariableRename(variable.id, label); + } + }); + layout.appendChild(label); + + const meta = document.createElement("span"); + meta.className = "overview-item-meta"; + const fallbackKey = normalizeVariableName(variable.name) || "__blank__"; + const usage = usageById.get(variable.id) ?? usageByName.get(fallbackKey); + const summary = [formatVariableType(variable.type)]; + if (usage) { + if (usage.sets) { + summary.push(`${usage.sets} set${usage.sets === 1 ? "" : "s"}`); + } + if (usage.gets) { + summary.push(`${usage.gets} get${usage.gets === 1 ? "" : "s"}`); + } + } + meta.textContent = summary.join(" • "); + layout.appendChild(meta); + + const removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.className = "overview-item-action overview-item-action--remove"; + const removeLabel = variable.name || variable.id; + removeButton.setAttribute( + "aria-label", + `Delete variable ${removeLabel}` + ); + removeButton.title = `Delete ${removeLabel}`; + removeButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + deleteVariable(variable.id); + }); + removeButton.addEventListener("pointerdown", (event) => { + event.stopPropagation(); + }); + layout.appendChild(removeButton); + + row.addEventListener("keydown", (event) => { + const isSpace = + event.key === " " || event.key === "Space" || event.key === "Spacebar"; + if (event.key === "Enter" || isSpace) { + event.preventDefault(); + activate(); + } else if (event.key === "F2") { + event.preventDefault(); + startVariableRename(variable.id, label); + } + }); + + item.appendChild(row); + element.appendChild(item); + }); + + highlightVariableSelection(); +} diff --git a/styles/main.css b/styles/main.css new file mode 100644 index 0000000..91aaf16 --- /dev/null +++ b/styles/main.css @@ -0,0 +1,2323 @@ +:root { + color-scheme: dark; + font-family: "Segoe UI", "Trebuchet MS", sans-serif; + --surface-0: #101218; + --surface-1: #161921; + --surface-2: #1d2029; + --surface-3: #242833; + --accent: #ff7f11; + --accent-muted: #f0a202; + --edge: #2f3544; + --text-primary: #f6f9ff; + --text-secondary: #a8b2cc; + --pin-exec: #ffffff; + --pin-number: #3ee581; + --pin-boolean: #ff4f4f; + --pin-string: #ff66ff; + --pin-table: #5ec4ff; + --pin-any: #8c919d; + --variable-color-any: #ff7f11; + --variable-color-number: #3ee581; + --variable-color-string: #ff66ff; + --variable-color-boolean: #ff4f4f; + --variable-color-table: #5ec4ff; + --shadow-0: 0 8px 18px rgba(0, 0, 0, 0.35); + --grid-color: rgba(255, 255, 255, 0.04); + --grid-color-strong: rgba(255, 255, 255, 0.1); + --grid-size: 20px; +} + +* { + box-sizing: border-box; + user-select: none; + scrollbar-width: thin; + scrollbar-color: rgba(246, 249, 255, 0.18) transparent; +} + +*::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +*::-webkit-scrollbar-thumb { + background-color: rgba(246, 249, 255, 0.18); + border-radius: 999px; + border: 2px solid transparent; + background-clip: content-box; +} + +*::-webkit-scrollbar-thumb:hover { + background-color: rgba(246, 249, 255, 0.3); +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +body { + margin: 0; + display: flex; + flex-direction: column; + height: 100vh; + background: var(--surface-0); + color: var(--text-primary); + overflow: hidden; +} + +button, +input, +textarea { + font: inherit; + color: inherit; +} + +button { + background: var(--surface-2); + border: 1px solid var(--edge); + color: var(--text-primary); + border-radius: 4px; + padding: 0.4rem 0.75rem; + cursor: pointer; +} + +button:hover:enabled, +button:focus-visible:enabled { + background: var(--surface-3); + border-color: var(--accent); + outline: none; +} + +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +input[type='search'] { + width: 100%; + padding: 0.4rem 0.6rem; + border-radius: 4px; + border: 1px solid var(--edge); + background: var(--surface-2); + color: var(--text-primary); +} + +.app-header { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + justify-content: stretch; + gap: 1rem; + padding: 0.75rem 1.25rem; + background: linear-gradient(90deg, var(--surface-1), var(--surface-2)); + border-bottom: 1px solid var(--edge); + box-shadow: var(--shadow-0); + z-index: 10; +} + +.header-primary-actions { + display: flex; + align-items: center; +} + +.header-title { + font-size: 1.1rem; + letter-spacing: 0.04em; + text-transform: uppercase; + justify-self: center; + text-align: center; +} + +.header-primary-button { + min-width: 7rem; + padding: 0.55rem 1.25rem; + border: none; + border-radius: 999px; + font-size: 0.9rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + background: var(--accent); + color: #1e1e28; + cursor: pointer; + transition: background-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; + box-shadow: 0 0 0 0 rgba(255, 127, 17, 0.45); +} + +.header-primary-button:hover:enabled, +.header-primary-button:focus-visible:enabled { + transform: translateY(-1px); + box-shadow: 0 10px 18px rgba(255, 127, 17, 0.35); + outline: none; +} + +.header-primary-button:active:enabled { + transform: translateY(0); + box-shadow: 0 4px 10px rgba(255, 127, 17, 0.28); +} + +.header-primary-button:disabled { + opacity: 0.6; + cursor: not-allowed; + box-shadow: none; +} + +.header-primary-button.is-busy:disabled { + cursor: progress; +} + +.header-primary-button.is-success { + background: #3ee581; + color: #06100a; + box-shadow: 0 0 0 0 rgba(62, 229, 129, 0.45); +} + +.header-primary-button.is-error { + background: #ff4f4f; + color: #1e0a0a; + box-shadow: 0 0 0 0 rgba(255, 79, 79, 0.45); +} + +.header-actions { + display: inline-flex; + gap: 0.5rem; +} + +.header-actions .header-icon-button { + min-width: 0; + padding: 0; + width: 2.5rem; + height: 2.5rem; + border-radius: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-primary); + transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease; +} + +.header-icon-button .header-icon { + display: inline-block; + width: 1.3rem; + height: 1.3rem; + background-color: currentColor; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; +} + +.header-icon--settings { + mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpath d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z"/%3E%3Cpath d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c0 .69.4 1.31 1.02 1.58.21.09.44.14.67.14H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/%3E%3C/svg%3E'); +} + +.header-icon--frame { + mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpath d="M4 9V5a1 1 0 0 1 1-1h4"/%3E%3Cpath d="M20 9V5a1 1 0 0 0-1-1h-4"/%3E%3Cpath d="M20 15v4a1 1 0 0 1-1 1h-4"/%3E%3Cpath d="M4 15v4a1 1 0 0 0 1 1h4"/%3E%3C/svg%3E'); +} + +.header-icon--palette { + mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpath d="M12 4a8 8 0 1 0 7.54 5.14 1.5 1.5 0 0 0-1.54-1.14h-1.38a1.5 1.5 0 0 1-1.5-1.5V5.5A1.5 1.5 0 0 0 12.62 4Z"/%3E%3Ccircle cx="7.5" cy="10.5" r="1"/%3E%3Ccircle cx="12" cy="7.5" r="1"/%3E%3Ccircle cx="16.5" cy="10.5" r="1"/%3E%3Ccircle cx="11" cy="14.5" r="1"/%3E%3C/svg%3E'); +} + +.header-icon--export { + mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpath d="M12 3v12"/%3E%3Cpath d="m8 11 4 4 4-4"/%3E%3Cpath d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/%3E%3C/svg%3E'); +} + +.header-icon-button.is-active { + background: rgba(255, 127, 17, 0.2); + border-color: var(--accent); + color: var(--accent); +} + +.header-icon-button:hover:enabled, +.header-icon-button:focus-visible:enabled { + color: var(--accent); + background: var(--surface-3); + border-color: var(--accent); + outline: none; +} + +.header-actions button.is-active { + background: var(--accent-muted); + color: #1e1e28; + border-color: var(--accent); +} + +.app-body { + flex: 1; + display: grid; + grid-template-columns: 240px minmax(0, 1fr) 280px 280px; + grid-template-rows: 1fr; + gap: 0; + min-height: 0; + overflow: hidden; +} + +.app-body > * { + min-width: 0; + min-height: 0; + height: 100%; +} + +.app-body--palette-hidden { + grid-template-columns: 240px minmax(0, 1fr) 280px; +} + +.app-body--palette-hidden .palette { + display: none; +} + +.overview, +.palette, +.inspector { + display: flex; + flex-direction: column; + background: var(--surface-1); + overflow: hidden; +} + +.overview { + border-right: 1px solid var(--edge); +} + +.palette { + border-left: 1px solid var(--edge); +} + +.inspector { + border-left: 1px solid var(--edge); +} + +.palette-search { + padding: 0.75rem; + border-bottom: 1px solid var(--edge); +} + +.palette-list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0.5rem 0.75rem 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.overview-section { + flex: 1; + display: flex; + flex-direction: column; + padding: 0.75rem; + border-bottom: 1px solid var(--edge); + min-height: 0; +} + +.overview-section:last-child { + border-bottom: none; +} + +.overview-title { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-secondary); +} + +.overview-subheading { + margin: 0.35rem 0 0.15rem; + padding: 0 0.25rem; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-secondary); + list-style: none; +} + +.overview-title--with-action { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.overview-action { + min-width: 1.75rem; + padding: 0.25rem 0; + border-radius: 999px; + font-size: 1rem; + line-height: 1; + background: var(--surface-3); + border-color: var(--edge); +} + +.overview-action:hover, +.overview-action:focus-visible { + border-color: var(--accent); +} + +.overview-list { + flex: 1; + margin: 0.5rem 0 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 0.35rem; + overflow-y: auto; +} + +.overview-item { + --overview-variable-color: var(--variable-color-any); + background: var(--surface-2); + border: 1px solid var(--edge); + border-radius: 6px; + padding: 0.45rem 0.6rem; + font-size: 0.8rem; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + text-align: left; + gap: 0.6rem; + cursor: pointer; + position: relative; +} + +.overview-item.is-selected { + border-color: var(--overview-variable-color, #ff7f11); + box-shadow: 0 0 0 1px var(--overview-variable-color, #ff7f11); + background: var(--surface-3); +} + +.overview-item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.overview-item--variable { + gap: 0.35rem; + position: relative; +} + +.overview-item--variable.is-dragging { + opacity: 0.6; +} + +.overview-item--variable.is-drop-before::before, +.overview-item--variable.is-drop-after::after { + content: ''; + position: absolute; + left: 0.4rem; + right: 0.4rem; + height: 2px; + background: var(--accent); + border-radius: 999px; + pointer-events: none; +} + +.overview-item--variable.is-drop-before::before { + top: -1px; +} + +.overview-item--variable.is-drop-after::after { + bottom: -1px; +} + +.variable-overview-row { + display: flex; + align-items: center; + gap: 0.4rem; + width: 100%; + flex-wrap: wrap; +} + +.variable-name-display { + display: block; + padding: 0.2rem 0.35rem; + border-radius: 4px; + border: 1px solid transparent; + background: transparent; + font-size: 0.78rem; + color: var(--text-primary); + cursor: text; + flex: 1; +} + +.overview-item--variable:hover .variable-name-display, +.variable-name-display:hover { + background: rgba(246, 249, 255, 0.06); +} + +.variable-name-display:focus, +.variable-name-display:focus-visible { + background: var(--surface-2); + border-color: var(--accent); + outline: none; +} + +.variable-overview-row .overview-item-meta { + flex: 0 0 auto; +} + +.overview-item--variable .overview-item-action, +.custom-event-parameter-row .custom-event-parameter-action, +.table-default-row .overview-item-action { + appearance: none; + border: none; + background: none; + padding: 0; + width: 1.6rem; + height: 1.6rem; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + cursor: pointer; + flex-shrink: 0; + border-radius: 0; + transition: color 0.15s ease, transform 0.15s ease; + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.overview-item--variable .overview-item-action:hover, +.custom-event-parameter-row .custom-event-parameter-action:hover, +.table-default-row .overview-item-action:hover { + background: none; + border: none; +} + +.overview-item--variable .overview-item-action:focus-visible, +.custom-event-parameter-row .custom-event-parameter-action:focus-visible, +.table-default-row .overview-item-action:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + background: none; + border: none; + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +.overview-item--variable .overview-item-action::before, +.custom-event-parameter-row .custom-event-parameter-action::before, +.table-default-row .overview-item-action::before { + content: ''; + width: 1rem; + height: 1rem; + background-color: currentColor; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; +} + +.overview-item--variable .overview-item-action--remove::before, +.custom-event-parameter-row .custom-event-parameter-action--remove::before, +.table-default-row .overview-item-action--remove::before { + mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpath d="M3 6h18"/%3E%3Cpath d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/%3E%3Cpath d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/%3E%3Cpath d="M10 11v6"/%3E%3Cpath d="M14 11v6"/%3E%3C/svg%3E'); +} + +.overview-item--variable .overview-item-action--remove:hover, +.overview-item--variable .overview-item-action--remove:focus-visible, +.custom-event-parameter-row .custom-event-parameter-action--remove:hover, +.custom-event-parameter-row .custom-event-parameter-action--remove:focus-visible, +.table-default-row .overview-item-action--remove:hover, +.table-default-row .overview-item-action--remove:focus-visible { + color: var(--pin-boolean); + transform: scale(1.05); +} + +.overview-item--variable:hover .overview-item-action, +.overview-item--variable:focus-within .overview-item-action { + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +.custom-event-parameter-row:hover .custom-event-parameter-action, +.custom-event-parameter-row:focus-within .custom-event-parameter-action { + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +.table-default-row:hover .overview-item-action, +.table-default-row:focus-within .overview-item-action { + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +.variable-type-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.25rem 0.45rem; + border-radius: 999px; + border: 1px solid var(--edge); + background: var(--surface-3); + color: var(--text-secondary); + cursor: pointer; + transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease; + flex-shrink: 0; +} + +.variable-type-button::before { + content: ''; + width: 0.55rem; + height: 0.55rem; + border-radius: 999px; + background: var(--overview-variable-color, #ff7f11); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25); +} + +.variable-type-button[data-variable-type='table']::before { + border-radius: 0; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25); + background-color: transparent; + background-image: + linear-gradient(var(--variable-color-table), var(--variable-color-table)), + linear-gradient(var(--variable-color-table), var(--variable-color-table)), + linear-gradient(var(--variable-color-table), var(--variable-color-table)), + linear-gradient(var(--variable-color-table), var(--variable-color-table)); + background-size: calc(50% - 1px) calc(50% - 1px); + background-repeat: no-repeat; + background-position: left top, right top, left bottom, right bottom; +} + +.variable-type-button::after { + content: ''; + border-left: 0.3rem solid transparent; + border-right: 0.3rem solid transparent; + border-top: 0.35rem solid currentColor; +} + +.variable-type-button:hover, +.variable-type-button:focus-visible { + background: var(--surface-2); + border-color: var(--accent); + color: var(--text-primary); + outline: none; +} + +.variable-type-button[aria-expanded='true'] { + background: var(--surface-2); + border-color: var(--accent); + color: var(--text-primary); +} + +.overview-item[data-variable-type='number'] { + --overview-variable-color: var(--variable-color-number); +} + +.overview-item[data-variable-type='string'] { + --overview-variable-color: var(--variable-color-string); +} + +.overview-item[data-variable-type='boolean'] { + --overview-variable-color: var(--variable-color-boolean); +} + +.overview-item[data-variable-type='any'] { + --overview-variable-color: var(--variable-color-any); +} + +.overview-item[data-variable-type='table'] { + --overview-variable-color: var(--variable-color-table); +} + +.overview-item[data-variable-type] .overview-item-label { + cursor: text; +} + +.variable-type-dropdown { + position: absolute; + top: calc(100% + 0.35rem); + left: 0; + background: var(--surface-2); + border: 1px solid var(--edge); + border-radius: 8px; + z-index: 0; + box-shadow: var(--shadow-0); + padding: 0.4rem; + display: flex; + flex-direction: column; + gap: 0.3rem; + z-index: 10; + min-width: 8rem; +} + +.variable-type-dropdown.is-above { + top: auto; + bottom: calc(100% + 0.35rem); +} + +.variable-type-option { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + border: none; + background: transparent; + color: var(--text-primary); + padding: 0.3rem 0.45rem; + border-radius: 6px; + font-size: 0.75rem; + cursor: pointer; +} + +.variable-type-option::before { + content: ''; + width: 0.55rem; + height: 0.55rem; + border-radius: 999px; + background: var(--variable-color-any); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25); +} + +.variable-type-option[data-variable-type='number']::before { + background: var(--variable-color-number); +} + +.variable-type-option[data-variable-type='string']::before { + background: var(--variable-color-string); +} + +.variable-type-option[data-variable-type='boolean']::before { + background: var(--variable-color-boolean); +} + +.variable-type-option[data-variable-type='any']::before { + background: var(--variable-color-any); +} + +.variable-type-option[data-variable-type='table']::before { + border-radius: 0; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25); + background-color: transparent; + background-image: + linear-gradient(var(--variable-color-table), var(--variable-color-table)), + linear-gradient(var(--variable-color-table), var(--variable-color-table)), + linear-gradient(var(--variable-color-table), var(--variable-color-table)), + linear-gradient(var(--variable-color-table), var(--variable-color-table)); + background-size: calc(50% - 1px) calc(50% - 1px); + background-repeat: no-repeat; + background-position: left top, right top, left bottom, right bottom; +} + +.variable-type-option:hover, +.variable-type-option:focus-visible { + background: var(--surface-3); + outline: none; +} + +.variable-type-option[aria-checked='true'] { + background: rgba(255, 127, 17, 0.18); +} + +.overview-item-rename { + flex: 1; + min-width: 0; + padding: 0.35rem 0.45rem; + border-radius: 4px; + border: 1px solid var(--accent); + background: var(--surface-0); + color: var(--text-primary); + font: inherit; + outline: none; +} + +.overview-item-rename:focus { + box-shadow: 0 0 0 1px var(--accent); +} + +.overview-item-label { + font-weight: 600; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.overview-item-meta { + font-size: 0.7rem; + color: var(--text-secondary); + visibility: collapse; +} + +.overview-empty { + margin: 0; + padding: 0.35rem 0; + font-size: 0.75rem; + color: var(--text-secondary); + opacity: 0.75; + text-align: center; +} + +.workspace-context-menu { + position: absolute; + display: none; + flex-direction: column; + gap: 0.4rem; + min-width: 220px; + max-height: 400px; + padding: 0.5rem; + background: var(--surface-2); + border: 1px solid var(--edge); + border-radius: 8px; + box-shadow: var(--shadow-0); + z-index: 30; +} + +.workspace-context-menu.is-visible { + display: flex; +} + +.context-menu-list { + flex: 1; + /* Critical for flexbox to allow the list to shrink and scroll */ + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.context-menu-item { + width: 100%; + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + gap: 0.4rem; + padding: 0.3rem 0.5rem; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--text-primary); + font-size: 0.8rem; + text-align: left; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease, color 120ms ease; +} + +.context-menu-item:hover, +.context-menu-item:focus-visible { + background: var(--surface-3); + border-color: var(--surface-4, var(--edge)); + color: var(--text-primary); + outline: none; +} + +.context-menu-item:active { + background: var(--surface-4, var(--surface-3)); +} + +.context-menu-item .palette-item-name { + font-size: 0.8rem; + font-weight: 500; + display: block; + text-align: left; +} + +.context-menu-item.is-selected, +.context-menu-item[aria-selected='true'] { + background: var(--surface-3); + border-color: var(--accent); + box-shadow: inset 0.2rem 0 0 var(--accent); + color: var(--text-primary); +} + +.context-menu-section { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.context-menu-section + .context-menu-section { + margin-top: 0.35rem; +} + +.context-menu-section-header { + padding: 0.2rem 0.5rem; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-secondary); +} + +.context-menu-section-body { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.context-menu-item { + width: 100%; + background: var(--surface-2); + cursor: pointer; +} + +.context-menu-item:active { + cursor: pointer; +} + +.workspace-context-menu .palette-item-category { + font-size: 0.6rem; +} + +.workspace-context-menu .palette-item-name { + font-size: 0.75rem; +} + +.context-menu-empty { + margin: 0; + font-size: 0.75rem; + color: var(--text-secondary); + text-align: center; + padding: 0.4rem 0; + opacity: 0.75; +} + +.palette-section { + background: var(--surface-2); + border: 1px solid var(--edge); + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: var(--shadow-0); + flex: 0 0 auto; + width: 100%; +} + +.palette-section.is-collapsed { + box-shadow: none; +} + +.palette-section + .palette-section { + margin-top: 0.25rem; +} + +.palette-section-header { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 0.55rem 0.75rem; + background: linear-gradient(90deg, var(--surface-2), var(--surface-3)); + border: none; + color: var(--text-secondary); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + cursor: pointer; +} + +.palette-section-header::after { + content: '▾'; + font-size: 0.8rem; + transition: transform 120ms ease; +} + +.palette-section.is-collapsed .palette-section-header::after { + transform: rotate(-90deg); +} + +.palette-section-header:hover, +.palette-section-header:focus-visible { + color: var(--accent); + outline: none; +} + +.palette-section-body { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.5rem 0.75rem 0.75rem; +} + +.palette-item { + background: var(--surface-1); + border-radius: 6px; + padding: 0.45rem 0.6rem; + border: 1px solid var(--edge); + display: flex; + flex-direction: column; + gap: 0.15rem; + cursor: grab; + transition: transform 120ms ease, border-color 120ms ease, background 120ms ease; +} + +.palette-item:active { + cursor: grabbing; +} + +.palette-item:hover, +.palette-item:focus-visible { + transform: translateY(-2px); + border-color: var(--accent); + outline: none; +} + +.palette-item-category { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-secondary); +} + +.palette-item-name { + font-size: 0.85rem; + font-weight: 600; +} + +.palette-empty { + margin: 0.75rem 0; + text-align: center; + font-size: 0.8rem; + color: var(--text-secondary); + opacity: 0.75; +} + +.workspace, +.workspace:focus, +.workspace:focus-visible, +.workspace-canvas, +.workspace-canvas:focus, +.workspace-canvas:focus-visible { + outline: none; +} + +.workspace:focus-visible, +.workspace-canvas:focus-visible { + box-shadow: inset 0 0 0 2px rgba(255, 127, 17, 0.35); +} + +.workspace { + position: relative; + background: var(--surface-0); + overflow: hidden; + height: 100%; +} + +.workspace-canvas { + --workspace-zoom: 1; + position: relative; + width: 100%; + height: 100%; + perspective: 1400px; + perspective-origin: center center; + background-image: + linear-gradient(var(--grid-color-strong) 2px, transparent 2px), + linear-gradient(90deg, var(--grid-color-strong) 2px, transparent 2px), + linear-gradient(var(--grid-color) 1px, transparent 1px), + linear-gradient(90deg, var(--grid-color) 1px, transparent 1px); + background-size: + calc(var(--grid-size) * var(--workspace-zoom) * 5) calc(var(--grid-size) * var(--workspace-zoom) * 5), + calc(var(--grid-size) * var(--workspace-zoom) * 5) calc(var(--grid-size) * var(--workspace-zoom) * 5), + calc(var(--grid-size) * var(--workspace-zoom)) calc(var(--grid-size) * var(--workspace-zoom)), + calc(var(--grid-size) * var(--workspace-zoom)) calc(var(--grid-size) * var(--workspace-zoom)); + background-repeat: repeat; + background-position: + 0 0, + 0 0, + 0 0, + 0 0; + cursor: grab; +} + +.workspace-canvas:active { + cursor: grabbing; +} + +.workspace-canvas.is-palette-dragging { + cursor: copy; +} + +.workspace-canvas.is-variable-dragging { + cursor: copy; +} + +.workspace-canvas.is-drag-target { + outline: 2px dashed var(--accent); + outline-offset: -4px; +} + +.connection-layer { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + transform-origin: 0 0; +} + +.node-layer { + position: absolute; + inset: 0; + transform-origin: 0 0; +} + +.workspace-marquee { + position: absolute; + border: 1px solid var(--accent); + background: rgba(255, 127, 17, 0.16); + border-radius: 6px; + pointer-events: none; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2); + z-index: 35; +} + +.workspace-drop-ghost, +.workspace-node-ghost { + position: absolute; + pointer-events: none; + border-radius: 8px; + transform: translate(0, 0); + transition: transform 140ms cubic-bezier(0.22, 0.61, 0.36, 1); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08); +} + +.workspace-drop-ghost { + min-width: 220px; + min-height: 140px; + border: 2px dashed var(--accent); + background: rgba(255, 127, 17, 0.08); + display: grid; + place-items: center; + color: var(--accent); + font-weight: 600; + z-index: 25; + opacity: 0.85; +} + +.workspace-drop-ghost__label { + padding: 0.5rem 1rem; + text-align: center; + font-size: 0.75rem; +} + +.workspace-node-ghost { + border: 2px solid rgba(255, 127, 17, 0.6); + background: rgba(255, 127, 17, 0.08); + z-index: 5; + opacity: 0.5; +} + +.blueprint-node { + position: absolute; + min-width: 220px; + background: var(--surface-2); + border: 1px solid var(--edge); + border-radius: 8px; + box-shadow: var(--shadow-0); + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-rows: auto auto 1fr; + grid-template-areas: + 'header header' + 'body body' + 'inputs outputs'; + column-gap: 0.35rem; + overflow: hidden; + z-index: 10; + transition: box-shadow 160ms ease, border-color 160ms ease; +} + +.blueprint-node::after { + content: ''; + position: absolute; + inset: -4px; + border-radius: 12px; + opacity: 0; + pointer-events: none; + transition: opacity 160ms ease, transform 160ms ease; + transform: scale(0.97); + box-shadow: 0 0 0 2px rgba(255, 127, 17, 0.2); +} + +.blueprint-node.is-hovered { + box-shadow: 0 14px 32px rgba(0, 0, 0, 0.42), 0 0 0 1px rgba(255, 127, 17, 0.16); +} + +.blueprint-node.is-hovered::after { + opacity: 1; + transform: scale(1); +} + +.blueprint-node.is-selected::after { + box-shadow: 0 0 0 2px var(--accent); + opacity: 1; + transform: scale(1); +} + +.blueprint-node.is-selected { + border-color: var(--accent); + z-index: 24; +} + +.blueprint-node.is-selection-primary { + z-index: 28; +} + +.node-header { + background: linear-gradient(90deg, var(--surface-2), var(--surface-3)); + padding: 0.5rem 0.75rem; + font-weight: 600; + letter-spacing: 0.02em; + border-bottom: 1px solid var(--edge); + cursor: grab; + grid-area: header; + position: relative; +} + +.node-title--variable { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.node-title-label { + font-weight: 600; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.node-title-type { + flex-shrink: 0; + font-size: 0.65rem; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 0.15rem 0.45rem; + border-radius: 999px; + border: 1px solid var(--edge); + background: rgba(255, 255, 255, 0.06); + color: var(--text-secondary); +} + +.blueprint-node--variable { + --node-variable-color: var(--variable-color-any); +} + +.blueprint-node[data-variable-scope='local'] { + overflow: visible; + z-index: 12; +} + +.blueprint-node[data-variable-type='number'] { + --node-variable-color: var(--variable-color-number); +} + +.blueprint-node[data-variable-type='string'] { + --node-variable-color: var(--variable-color-string); +} + +.blueprint-node[data-variable-type='boolean'] { + --node-variable-color: var(--variable-color-boolean); +} + +.blueprint-node[data-variable-type='any'] { + --node-variable-color: var(--variable-color-any); +} + +.blueprint-node[data-variable-type='table'] { + --node-variable-color: var(--variable-color-table); +} + +.blueprint-node--variable .node-header { + padding-left: 1.3rem; + border-bottom-color: rgba(0, 0, 0, 0.45); +} + +.blueprint-node--variable .node-header::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 6px; + background: var(--node-variable-color); +} + +.blueprint-node--variable .node-title-type { + border-color: var(--node-variable-color); + color: var(--node-variable-color); + background: rgba(255, 255, 255, 0.08); +} + +.blueprint-node--variable.is-selected { + border-color: var(--node-variable-color); + box-shadow: 0 0 0 1px var(--node-variable-color); +} + +.node-body { + grid-area: body; + padding: 0.5rem 0.6rem; + border-bottom: 1px solid var(--edge); + background: rgba(0, 0, 0, 0.08); +} + +.node-io { + display: flex; + flex-direction: column; + padding: 0.5rem 0.6rem; + gap: 0.35rem; + min-height: 100%; +} + +.node-inputs { + grid-area: inputs; + align-items: flex-start; +} + +.node-outputs { + grid-area: outputs; + align-items: flex-end; + border-left: 1px solid var(--edge); + background: rgba(0, 0, 0, 0.12); +} + +.node-outputs:empty { + display: none; +} + +.literal-inline-editor { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.local-variable-editor { + display: flex; + flex-direction: column; + gap: 0.35rem; + background: transparent; + border: none; + padding: 0; + max-width: 240px; + align-self: flex-start; +} + +.local-variable-row { + display: grid; + align-items: center; + gap: 0.4rem; +} + +.local-variable-row--header { + grid-template-columns: auto minmax(0, 1fr); +} + +.local-variable-row.is-hidden { + display: none; +} + +.local-variable-label { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-secondary); + white-space: nowrap; +} + +.local-variable-type { + position: relative; + display: inline-flex; + align-items: center; +} + +.local-variable-type-button { + min-width: 0; + padding: 0.2rem 0.45rem; +} + +.local-variable-select, +.local-variable-name-input, +.local-variable-number, +.local-variable-text, +.local-variable-textarea, +.local-variable-boolean { + width: 100%; + background: var(--surface-2); + border: 1px solid var(--edge); + border-radius: 4px; + color: var(--text-primary); + padding: 0.35rem 0.45rem; + font-size: 0.74rem; + min-width: 0; +} + +.local-variable-select:focus, +.local-variable-name-input:focus, +.local-variable-number:focus, +.local-variable-text:focus, +.local-variable-textarea:focus, +.local-variable-boolean:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 1px rgba(255, 127, 17, 0.35); +} + +.local-variable-textarea { + resize: vertical; + min-height: 2.5rem; +} + +.local-variable-inline-value { + display: flex; + margin-left: calc(18px + 0.6rem); + margin-top: 0.35rem; + min-width: 0; +} + +.local-variable-inline-value-field { + flex: 1; + min-width: 0; +} + +.literal-inline-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-secondary); +} + +.literal-inline-input { + flex: 1; + min-width: 0; + padding: 0.3rem 0.4rem; + border-radius: 4px; + border: 1px solid var(--edge); + background: var(--surface-3); + color: var(--text-primary); +} + +.literal-inline-input:focus-visible { + outline: 1px solid var(--accent); +} + +.literal-inline-checkbox { + width: 16px; + height: 16px; + border-radius: 3px; + border: 1px solid var(--edge); + background: var(--surface-3); +} + +.pin-inline-checkbox { + width: 16px; + height: 16px; + border-radius: 3px; + border: 1px solid var(--edge); + background: var(--surface-3); +} + +.pin-inline-select { + width: auto; + min-width: 0; + max-width: 12rem; + padding: 0.3rem 0.4rem; + border-radius: 4px; + border: 1px solid var(--edge); + background: var(--surface-3); + color: var(--text-primary); + font-size: 0.75rem; +} + +.pin-inline-select:focus-visible { + outline: 1px solid var(--accent); +} + +.pin { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; + justify-content: flex-start; + cursor: pointer; + border-radius: 4px; + position: relative; + transition: background 120ms ease, transform 120ms ease, box-shadow 120ms ease; +} + +.pin::after { + content: ''; + position: absolute; + inset: -3px; + border-radius: 6px; + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08); +} + + +.pin.is-hovered::after { + opacity: 1; +} + +.pin.is-hovered .pin-handle::after { + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2); +} + +.pin-label { + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.68rem; + order: 1; +} + +.pin-label.is-hidden { + display: none; +} + +.pin-handle { + order: 2; + margin-bottom: 3px; + margin-top: 3px; +} + +.pin-inline-control { + display: flex; + align-items: center; + justify-content: center; + order: 1; +} + +.pin-inline-control.pin-inline-dropdown { + display: flex; + justify-content: flex-start; + width: auto; + min-width: 0; + margin-top: 0.25rem; +} + +.pin-inline-control.is-hidden { + display: none; +} + +.node-inputs .pin { + justify-content: flex-start; +} + +.node-inputs .pin .pin-handle { + order: 0; +} + +.node-inputs .pin .pin-inline-control { + order: 1; +} + +.node-inputs .pin.has-inline-dropdown { + display: grid; + grid-template-columns: auto 1fr; + grid-auto-rows: auto; + column-gap: 0.4rem; + row-gap: 0.35rem; + align-items: center; +} + +.node-inputs .pin.has-inline-dropdown .pin-handle { + grid-column: 1; + grid-row: 1; +} + +.node-inputs .pin.has-inline-dropdown .pin-label { + grid-column: 2; + grid-row: 1; + align-self: center; +} + +.node-inputs .pin.has-inline-dropdown .pin-inline-dropdown { + grid-column: 1 / span 2; + grid-row: 2; + justify-self: flex-start; + align-self: flex-start; + margin-top: 0; + padding-left: 0; +} + +.node-inputs .pin.has-inline-dropdown .pin-inline-dropdown .pin-inline-select { + width: auto; + max-width: 12rem; +} + +.node-inputs .pin .pin-label { + order: 2; + text-align: left; +} + +.node-outputs .pin { + justify-content: flex-end; +} + +.node-outputs .pin .pin-label { + order: 0; + text-align: right; +} + +.node-outputs .pin .pin-inline-control { + order: 1; +} + +.node-outputs .pin .pin-handle { + order: 2; +} + + +.pin-handle { + display: inline-flex; + width: 18px; + height: 18px; + border-radius: 50%; + border: 2px solid var(--edge); + background: var(--surface-3); + position: relative; + cursor: pointer; + transition: transform 120ms ease, border-color 120ms ease; +} + +.pin-handle::after { + content: ''; + position: absolute; + inset: 3px; + border-radius: 50%; + background: currentColor; + transition: transform 120ms ease; +} + +.pin.is-disconnected .pin-handle { + background: transparent; + border-color: currentColor; + width: 16px; + height: 16px; +} + + +.pin.is-disconnected .pin-handle::after { + inset: 4px; + background: transparent; + box-shadow: inset 0 0 0 2.5px currentColor; +} + +.pin.is-disconnected:not([data-type='exec']) .pin-handle::after { + background: transparent; + box-shadow: none; + opacity: 0; +} + +.pin[data-type='exec'].is-disconnected .pin-handle::after { + inset: 0; + background: currentColor; + box-shadow: none; +} + +.pin[data-type='exec'].is-disconnected .pin-handle::before { + content: ''; + position: absolute; + inset: 2px; + clip-path: polygon(0% 0%, 60% 0%, 100% 50%, 60% 100%, 0% 100%); + background: var(--surface-3); + pointer-events: none; + z-index: 1; +} + +.pin-handle:hover, +.pin-handle:focus-visible { + transform: scale(1.1); + border-color: currentColor; + outline: none; +} + +.pin.is-drag-source { + background: rgba(255, 255, 255, 0.08); +} + +.pin.is-drag-source .pin-handle::after { + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.25); +} + +.pin.is-drop-target { + background: rgba(255, 127, 17, 0.18); +} + +.pin.is-drop-target .pin-handle { + border-color: var(--accent); +} + +.pin.is-drop-target .pin-handle::after { + box-shadow: 0 0 0 2px rgba(255, 127, 17, 0.35); +} + +.pin.is-drop-hover { + background: rgba(255, 127, 17, 0.28); + transform: translateY(-1px); +} + +.pin[data-type='exec'] .pin-handle { + width: 16px; + height: 12px; + border: none; + border-radius: 4px; + background: transparent; + margin: 4px 0; +} + +.pin[data-type='exec'] .pin-handle::after { + inset: 0; + border-radius: 0; + clip-path: polygon(0% 0%, 60% 0%, 100% 50%, 60% 100%, 0% 100%); + background: currentColor; + z-index: 0; +} + +.pin[data-type='exec'] .pin-handle { + color: var(--pin-exec); +} + +.pin[data-type='number'] .pin-handle { + color: var(--variable-color-number); +} + +.pin[data-type='boolean'] .pin-handle { + color: var(--variable-color-boolean); +} + +.pin[data-type='string'] .pin-handle { + color: var(--variable-color-string); +} + +.pin[data-type='table'] .pin-handle { + color: var(--variable-color-table); + border-radius: 5px; + position: relative; +} + +.pin[data-type='table']:not(.is-disconnected) .pin-handle { + border-color: var(--edge); + border-width: 1px; +} + +.pin[data-type='table'] .pin-handle::after { + content: ''; + position: absolute; + top: 3px; + left: 3px; + width: 4px; + height: 4px; + border-radius: 1px; + background-color: currentColor; + box-shadow: + 6px 0 0 0 currentColor, + 0 6px 0 0 currentColor, + 6px 6px 0 0 currentColor; +} + +.inspector-header { + padding: 0.75rem 0.9rem; + border-bottom: 1px solid var(--edge); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); +} + +.inspector-content { + flex: 1; + overflow-y: auto; + padding: 0.75rem 0.9rem; + display: grid; + gap: 0.65rem; + align-content: start; +} + +.inspector-section-title { + margin: 0 0 0.25rem; + font-size: 0.8rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-secondary); +} + +.inspector-field { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.custom-event-parameter-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.custom-event-parameter-add { + min-width: 0; + padding: 0.3rem 0.65rem; + font-size: 0.76rem; + border-radius: 6px; +} + +.custom-event-parameter-list { + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.custom-event-parameter-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + gap: 0.5rem; + align-items: center; + --overview-variable-color: var(--variable-color-any); + position: relative; +} + +.custom-event-parameter-row .custom-event-parameter-action { + width: 1.35rem; + height: 1.35rem; +} + +.custom-event-parameter-row .custom-event-parameter-action::before { + width: 0.9rem; + height: 0.9rem; +} + +.custom-event-parameter-row[data-parameter-type='number'] { + --overview-variable-color: var(--variable-color-number); +} + +.custom-event-parameter-row[data-parameter-type='string'] { + --overview-variable-color: var(--variable-color-string); +} + +.custom-event-parameter-row[data-parameter-type='boolean'] { + --overview-variable-color: var(--variable-color-boolean); +} + +.custom-event-parameter-row[data-parameter-type='table'] { + --overview-variable-color: var(--variable-color-table); +} + +.custom-event-parameter-row input[type='text'] { + width: 100%; + padding: 0.2rem 0.35rem; + border-radius: 4px; + border: 1px solid transparent; + background: transparent; + font-size: 0.78rem; + color: var(--text-primary); +} + +.custom-event-parameter-row input[type='text']:hover { + background: rgba(246, 249, 255, 0.06); +} + +.custom-event-parameter-row input[type='text']:focus, +.custom-event-parameter-row input[type='text']:focus-visible { + background: var(--surface-2); + border-color: var(--accent); + outline: none; +} + +.custom-event-parameter-row .variable-type-button { + min-width: 0; +} + +.table-default-editor { + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.table-default-toolbar { + display: flex; + justify-content: flex-end; + align-items: center; +} + +.table-default-add { + min-width: 0; + padding: 0.3rem 0.65rem; + font-size: 0.76rem; + border-radius: 6px; +} + +.table-default-list { + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.table-default-row { + display: grid; + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1fr) auto; + gap: 0.5rem; + align-items: center; +} + +.table-default-row input[type='text'] { + width: 100%; + padding: 0.2rem 0.35rem; + border-radius: 4px; + border: 1px solid transparent; + background: transparent; + font-size: 0.78rem; + color: var(--text-primary); +} + +.table-default-row input[type='text']:hover { + background: rgba(246, 249, 255, 0.06); +} + +.table-default-row input[type='text']:focus, +.table-default-row input[type='text']:focus-visible { + background: var(--surface-2); + border-color: var(--accent); + outline: none; +} + +.table-default-empty { + font-size: 0.78rem; + color: var(--text-secondary); + font-style: italic; +} + +.custom-event-parameter-optional { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.72rem; + color: var(--text-secondary); + cursor: pointer; +} + +.custom-event-parameter-optional input { + margin: 0; +} + +.custom-event-parameter-optional span { + pointer-events: none; +} + +.inspector-field--checkbox { + flex-direction: row; + align-items: center; +} + +.inspector-field--checkbox label { + margin: 0; +} + +.inspector-field--checkbox input[type='checkbox'] { + width: auto; + align-self: center; +} + +.inspector-tooltip { + position: relative; + display: inline-flex; + --tooltip-top: -9999px; + --tooltip-left: -9999px; + --tooltip-hidden-shift: -0.25rem; + --tooltip-arrow-offset: 16px; +} + +.inspector-tooltip__trigger { + display: inline-flex; + align-items: center; + gap: 0.35rem; + cursor: help; +} + +.inspector-tooltip__trigger::after { + content: ''; + display: inline-block; + width: 0.65rem; + height: 0.65rem; + background: var(--accent); + mask: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"%3E%3Cpath stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.879 7.879A3 3 0 0115 9c0 1.657-1.343 3-3 3v1m0 3h.01M21 12C21 6.477 16.523 2 11 2S1 6.477 1 12s4.477 10 10 10 10-4.477 10-10z"/%3E%3C/svg%3E') center/contain; + mask-repeat: no-repeat; +} + +.inspector-tooltip__trigger:hover::after, +.inspector-tooltip__trigger:focus-visible::after { + background: var(--accent-muted); +} + +.inspector-tooltip__content { + position: fixed; + top: var(--tooltip-top); + left: var(--tooltip-left); + transform: translate3d(0, var(--tooltip-hidden-shift), 0); + max-width: min(340px, 48vw); + background: var(--surface-2); + border: 1px solid var(--edge); + border-radius: 6px; + padding: 0.8rem; + box-shadow: var(--shadow-0); + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease-out, transform 120ms ease-out; + z-index: 99; +} + +.inspector-tooltip__content::after { + content: ''; + position: absolute; + top: -6px; + left: var(--tooltip-arrow-offset, 16px); + border-width: 0 6px 6px 6px; + border-style: solid; + border-color: transparent transparent var(--surface-2) transparent; +} + +.inspector-tooltip.is-active .inspector-tooltip__content { + opacity: 1; + pointer-events: auto; + transform: translate3d(0, 0, 0); +} + +.inspector-tooltip[data-placement='top'] { + --tooltip-hidden-shift: 0.25rem; +} + +.inspector-tooltip[data-placement='top'] .inspector-tooltip__content::after { + top: auto; + bottom: -6px; + left: var(--tooltip-arrow-offset, 16px); + border-width: 6px 6px 0 6px; + border-color: var(--surface-2) transparent transparent transparent; +} + +.inspector-tooltip__content p { + margin: 0; + font-size: 0.78rem; + line-height: 1.45; + color: var(--text-secondary); +} + +.inspector-tooltip__content p + p, +.inspector-tooltip__content p + ul, +.inspector-tooltip__list + .inspector-tooltip__note { + margin-top: 0.55rem; +} + +.inspector-tooltip__list { + margin: 0; + padding-left: 1.15rem; + list-style: disc; + display: grid; + gap: 0.35rem; + font-size: 0.78rem; + color: var(--text-secondary); +} + +.inspector-tooltip__note { + font-size: 0.78rem; + color: var(--text-secondary); + opacity: 0.85; +} + +.inspector-tooltip__content code { + font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace; + font-size: 0.72rem; + padding: 0.05rem 0.25rem; + border-radius: 3px; + border: 1px solid var(--edge); + background: var(--surface-1); +} + +.inspector-field label { + font-size: 0.75rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.inspector-field input, +.inspector-field select, +.inspector-field textarea { + width: 100%; + padding: 0.4rem 0.5rem; + border-radius: 4px; + border: 1px solid var(--edge); + background: var(--surface-2); + color: var(--text-primary); + resize: vertical; +} + +.inspector-note { + margin: 0; + font-size: 0.78rem; + color: var(--text-secondary); + line-height: 1.4; +} + +.inspector-note + .inspector-note, +.inspector-note + .inspector-note-list, +.inspector-note-list + .inspector-note { + margin-top: 0.5rem; +} + +.inspector-note-list { + margin: 0; + padding-left: 1.1rem; + list-style: disc; + font-size: 0.78rem; + color: var(--text-secondary); + display: grid; + gap: 0.35rem; +} + +.inspector-note code, +.inspector-note-list code { + font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace; + font-size: 0.75rem; + background: var(--surface-2); + padding: 0.05rem 0.3rem; + border-radius: 3px; + border: 1px solid var(--edge); +} + +.inspector-note--muted { + opacity: 0.85; +} + +.inspector-footer { + padding: 0.75rem; + border-top: 1px solid var(--edge); + display: flex; + gap: 0.5rem; + justify-content: flex-end; +} + +.inspector-empty { + margin: 0; + font-size: 0.8rem; + color: var(--text-secondary); + opacity: 0.8; +} + +body.modal-open { + overflow: hidden; +} + +.modal { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 2.5rem; + z-index: 200; +} + +.modal[hidden] { + display: none; +} + +.modal__backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(6px); +} + +.modal__panel { + position: relative; + display: flex; + flex-direction: column; + width: min(960px, calc(100vw - 4rem)); + max-height: calc(100vh - 5rem); + background: var(--surface-1); + border: 1px solid var(--edge); + border-radius: 16px; + box-shadow: var(--shadow-0); + overflow: hidden; + z-index: 1; +} + +.modal__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--edge); + background: linear-gradient(135deg, rgba(36, 40, 51, 0.95), rgba(29, 32, 41, 0.95)); +} + +.modal__header h2 { + margin: 0; + font-size: 0.95rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.modal__actions { + display: inline-flex; + gap: 0.5rem; +} + +.modal__icon-button { + width: 2.25rem; + height: 2.25rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 10px; + background: var(--surface-2); + border: 1px solid var(--edge); + color: var(--text-primary); + transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease; +} + +.modal__icon-button:hover, +.modal__icon-button:focus-visible { + background: var(--surface-3); + border-color: var(--accent); + color: var(--accent); + outline: none; +} + +.modal__icon-button.is-success { + border-color: var(--pin-number); + color: var(--pin-number); +} + +.modal-icon { + width: 1.15rem; + height: 1.15rem; + display: inline-block; + background-color: currentColor; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; +} + +.modal-icon--close { + mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpath d="M18 6 6 18"/%3E%3Cpath d="m6 6 12 12"/%3E%3C/svg%3E'); +} + +.modal-icon--copy { + mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Crect x="9" y="9" width="13" height="13" rx="2" ry="2"/%3E%3Cpath d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/%3E%3C/svg%3E'); +} + +.modal__body { + position: relative; + flex: 1; + overflow: hidden; + background: var(--surface-0); + display: flex; +} + +.lua-modal__code { + margin: 0; + padding: 1.25rem 1.5rem; + flex: 1; + overflow: auto; + -moz-user-select: text; + font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace; + font-size: 0.82rem; + line-height: 1.6; + color: var(--text-primary); + background: transparent; + -moz-user-select: text; + border: none; + outline: none; + user-select: text; + -webkit-user-select: text; +} + +.lua-modal__code code { + display: block; + white-space: pre; +} + +.lua-modal__code code span { + user-select: text; + -webkit-user-select: text; +} + +.lua-token { + color: inherit; +} + +.lua-token--keyword { + color: var(--accent); +} + +.lua-token--number { + color: var(--pin-number); +} + +.lua-token--string { + color: #f4c95d; +} + +.lua-token--comment { + color: var(--text-secondary); + opacity: 0.75; + font-style: italic; +} + +.connection-path { + fill: none; + stroke-width: 3px; + stroke-linecap: round; + pointer-events: stroke; +} + +.connection-path[data-type='exec'] { + stroke: var(--pin-exec); +} + +.connection-path[data-type='number'] { + stroke: var(--variable-color-number); +} + +.connection-path[data-type='boolean'] { + stroke: var(--variable-color-boolean); +} + +.connection-path[data-type='string'] { + stroke: var(--variable-color-string); +} + +.connection-path[data-type='table'] { + stroke: var(--variable-color-table); +} + +.connection-path[data-type='any'] { + stroke: var(--variable-color-any); +} + +.connection-path[data-active='true'] { + stroke-dasharray: 6; + animation: dash 600ms linear infinite; +} + +@keyframes dash { + to { + stroke-dashoffset: 12; + } +} + +@media (max-width: 1200px) { + .app-body { + grid-template-columns: 220px minmax(0, 1fr) 220px; + } + + .blueprint-node { + min-width: 190px; + } +}