Finished Blockly prototype, updated project template json

This commit is contained in:
Richard Osborne
2026-01-12 13:23:12 +01:00
parent a64e113189
commit 39fe8fba27
34 changed files with 3652 additions and 196 deletions

View File

@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
</dict>
</plist>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 682 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -1,36 +0,0 @@
const fs = require('fs');
const path = require('path');
module.exports = async function (params) {
if (process.platform !== 'darwin') {
return;
}
if (!process.env.appleIdPassword) {
console.log('apple password not set, skipping notarization');
return;
}
const appId = 'com.opennoodl.app';
const appPath = path.join(params.appOutDir, `${params.packager.appInfo.productFilename}.app`);
if (!fs.existsSync(appPath)) {
throw new Error(`Cannot find application at: ${appPath}`);
}
console.log(`Notarizing ${appId} found at ${appPath}`);
try {
const electron_notarize = require('electron-notarize');
await electron_notarize.notarize({
appBundleId: appId,
appPath: appPath,
appleId: process.env.appleId,
appleIdPassword: process.env.appleIdPassword
});
} catch (error) {
console.error(error);
}
console.log(`Done notarizing ${appId}`);
};

View File

@@ -60,6 +60,7 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.71.2",
"@babel/parser": "^7.28.5",
"@blockly/theme-dark": "^8.0.3",
"@electron/remote": "^2.1.3",
"@jaames/iro": "^5.5.2",
"@microlink/react-json-view": "^1.27.0",

View File

@@ -1,9 +1,11 @@
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
import { EventDispatcher } from '../../../shared/utils/EventDispatcher';
/**
* Tab types supported by the canvas tab system
*/
export type TabType = 'canvas' | 'logic-builder';
export type TabType = 'logic-builder';
/**
* Tab data structure
@@ -62,15 +64,10 @@ interface CanvasTabsProviderProps {
* Provider for canvas tabs state
*/
export function CanvasTabsProvider({ children }: CanvasTabsProviderProps) {
// Always start with the canvas tab
const [tabs, setTabs] = useState<Tab[]>([
{
id: 'canvas',
type: 'canvas'
}
]);
// Start with no tabs - Logic Builder tabs are opened on demand
const [tabs, setTabs] = useState<Tab[]>([]);
const [activeTabId, setActiveTabId] = useState<string>('canvas');
const [activeTabId, setActiveTabId] = useState<string | undefined>(undefined);
/**
* Open a new tab or switch to existing one
@@ -93,23 +90,49 @@ export function CanvasTabsProvider({ children }: CanvasTabsProviderProps) {
...newTab,
id: tabId
};
return [...prevTabs, tab];
const newTabs = [...prevTabs, tab];
// Emit event that a Logic Builder tab was opened (first tab)
if (prevTabs.length === 0) {
EventDispatcher.instance.emit('LogicBuilder.TabOpened');
}
return newTabs;
});
// Switch to the new/existing tab
setActiveTabId(tabId);
}, []);
/**
* Listen for Logic Builder tab open requests from property panel
*/
useEffect(() => {
const context = {};
const handleOpenTab = (data: { nodeId: string; nodeName: string; workspace: string }) => {
console.log('[CanvasTabsContext] Received LogicBuilder.OpenTab event:', data);
openTab({
type: 'logic-builder',
nodeId: data.nodeId,
nodeName: data.nodeName,
workspace: data.workspace
});
};
EventDispatcher.instance.on('LogicBuilder.OpenTab', handleOpenTab, context);
return () => {
EventDispatcher.instance.off(context);
};
}, [openTab]);
/**
* Close a tab by ID
*/
const closeTab = useCallback(
(tabId: string) => {
// Can't close the canvas tab
if (tabId === 'canvas') {
return;
}
setTabs((prevTabs) => {
const tabIndex = prevTabs.findIndex((t) => t.id === tabId);
if (tabIndex === -1) {
@@ -118,9 +141,15 @@ export function CanvasTabsProvider({ children }: CanvasTabsProviderProps) {
const newTabs = prevTabs.filter((t) => t.id !== tabId);
// If closing the active tab, switch to canvas
// If closing the active tab, switch to another tab or clear active
if (activeTabId === tabId) {
setActiveTabId('canvas');
if (newTabs.length > 0) {
setActiveTabId(newTabs[newTabs.length - 1].id);
} else {
setActiveTabId(undefined);
// Emit event that all Logic Builder tabs are closed
EventDispatcher.instance.emit('LogicBuilder.AllTabsClosed');
}
}
return newTabs;

View File

@@ -1,6 +1,6 @@
<div class="nodegrapgeditor-bg nodegrapheditor-canvas" style="width: 100%; height: 100%">
<!-- Canvas Tabs Root (for React component) -->
<div id="canvas-tabs-root" style="width: 100%; height: 100%"></div>
<div id="canvas-tabs-root" style="position: absolute; width: 100%; height: 100%; z-index: 100; pointer-events: none;"></div>
<!--
wrap in a div to not trigger chromium bug where comments "scrolls" all the siblings

View File

@@ -22,19 +22,28 @@ declare global {
* This makes IODetector and code generation available to runtime nodes
*/
export function initBlocklyEditorGlobals() {
console.log('🔍 [BlocklyGlobals] initBlocklyEditorGlobals called');
console.log('🔍 [BlocklyGlobals] window undefined?', typeof window === 'undefined');
// Create NoodlEditor namespace if it doesn't exist
if (typeof window !== 'undefined') {
console.log('🔍 [BlocklyGlobals] window.NoodlEditor before:', window.NoodlEditor);
if (!window.NoodlEditor) {
window.NoodlEditor = {};
console.log('🔍 [BlocklyGlobals] Created new window.NoodlEditor');
}
// Expose IODetector
window.NoodlEditor.detectIO = detectIO;
console.log('🔍 [BlocklyGlobals] Assigned detectIO:', typeof window.NoodlEditor.detectIO);
// Expose code generator
window.NoodlEditor.generateBlocklyCode = generateCode;
console.log('🔍 [BlocklyGlobals] Assigned generateBlocklyCode:', typeof window.NoodlEditor.generateBlocklyCode);
console.log('✅ [Blockly] Editor globals initialized');
console.log('🔍 [BlocklyGlobals] window.NoodlEditor after:', window.NoodlEditor);
}
}

View File

@@ -80,14 +80,16 @@
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
}
.blocklyMenuItem {
.blocklyContextMenu .blocklyMenuItem {
color: var(--theme-color-fg-default) !important;
font-family: var(--theme-font-family) !important;
font-size: 13px !important;
padding: 6px 12px !important;
&:hover {
&:hover,
&:hover * {
background-color: var(--theme-color-bg-4) !important;
color: var(--theme-color-fg-default) !important;
}
&.blocklyMenuItemDisabled {
@@ -101,21 +103,148 @@
fill: var(--theme-color-border-default) !important;
}
/* Field editor backgrounds */
.blocklyWidgetDiv {
& .goog-menu {
/* Field editor backgrounds (dropdowns, text inputs, etc.) */
/* NOTE: blocklyWidgetDiv and blocklyDropDownDiv are rendered at document root! */
.blocklyWidgetDiv,
.blocklyDropDownDiv {
z-index: 10000 !important; /* Ensure it's above everything */
}
/* Blockly dropdown container - DARK BACKGROUND */
.blocklyDropDownDiv,
:global(.blocklyDropDownDiv) {
background-color: var(--theme-color-bg-3) !important; /* DARK background */
max-height: 400px !important;
overflow-y: auto !important;
overflow-x: hidden !important;
/* Inner scrollable container */
& > div {
background-color: var(--theme-color-bg-3) !important;
border: 1px solid var(--theme-color-border-default) !important;
border-radius: 4px !important;
max-height: 400px !important;
overflow-y: auto !important;
}
& .goog-menuitem {
color: var(--theme-color-fg-default) !important;
font-family: var(--theme-font-family) !important;
&:hover {
background-color: var(--theme-color-bg-4) !important;
}
/* SVG containers inside dropdown */
& svg {
background-color: var(--theme-color-bg-3) !important;
}
}
/* Text input fields */
.blocklyWidgetDiv input,
.blocklyHtmlInput {
background-color: var(--theme-color-bg-3) !important;
color: var(--theme-color-fg-default) !important;
border: 1px solid var(--theme-color-border-default) !important;
border-radius: 4px !important;
padding: 4px 8px !important;
font-family: var(--theme-font-family) !important;
}
/* Dropdown menus - DARK BACKGROUND with WHITE TEXT (matches Noodl theme) */
/* Target ACTUAL Blockly classes: .blocklyMenuItem not .goog-menuitem */
.goog-menu,
:global(.goog-menu) {
background-color: var(--theme-color-bg-3) !important; /* DARK background */
border: 1px solid var(--theme-color-border-default) !important;
border-radius: 4px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
max-height: 400px !important;
overflow-y: auto !important;
overflow-x: hidden !important;
z-index: 10001 !important;
}
/* Target Blockly's ACTUAL menu item class - DROPDOWN MENUS */
.blocklyDropDownDiv .blocklyMenuItem,
:global(.blocklyDropDownDiv) :global(.blocklyMenuItem) {
color: #ffffff !important; /* WHITE text */
background-color: transparent !important;
padding: 6px 12px !important;
cursor: pointer !important;
font-family: var(--theme-font-family) !important;
font-size: 13px !important;
/* ALL children white */
& *,
& div,
& span {
color: #ffffff !important;
}
/* HOVER - Keep white text with lighter background */
&:hover,
&:hover *,
&:hover div,
&:hover span {
background-color: var(--theme-color-bg-4) !important;
color: #ffffff !important; /* WHITE on hover */
}
&[aria-selected='true'],
&[aria-selected='true'] * {
background-color: var(--theme-color-primary) !important;
color: #ffffff !important;
}
&[aria-disabled='true'],
&[aria-disabled='true'] * {
color: #999999 !important;
opacity: 0.5;
cursor: not-allowed !important;
}
}
/* Target Blockly's ACTUAL content class */
.blocklyMenuItemContent,
:global(.blocklyMenuItemContent) {
color: #ffffff !important;
& *,
& div,
& span {
color: #ffffff !important;
}
}
/* Fallback for goog- classes if they exist */
.goog-menuitem,
.goog-option,
:global(.goog-menuitem),
:global(.goog-option) {
color: #ffffff !important;
background-color: transparent !important;
padding: 6px 12px !important;
font-family: var(--theme-font-family) !important;
font-size: 13px !important;
& *,
& div,
& span {
color: #ffffff !important;
}
&:hover,
&:hover * {
background-color: var(--theme-color-bg-4) !important;
}
}
.goog-menuitem-content,
:global(.goog-menuitem-content) {
color: #ffffff !important;
& * {
color: #ffffff !important;
}
}
/* Blockly dropdown content container */
:global(.blocklyDropDownContent) {
max-height: 400px !important;
overflow-y: auto !important;
overflow-x: hidden !important;
}
}

View File

@@ -7,10 +7,13 @@
* @module BlocklyEditor
*/
import DarkTheme from '@blockly/theme-dark';
import * as Blockly from 'blockly';
import React, { useEffect, useRef, useState } from 'react';
import { javascriptGenerator } from 'blockly/javascript';
import React, { useEffect, useRef } from 'react';
import css from './BlocklyWorkspace.module.scss';
import { initBlocklyIntegration } from './index';
export interface BlocklyWorkspaceProps {
/** Initial workspace JSON (for loading saved state) */
@@ -43,18 +46,21 @@ export function BlocklyWorkspace({
}: BlocklyWorkspaceProps) {
const blocklyDiv = useRef<HTMLDivElement>(null);
const workspaceRef = useRef<Blockly.WorkspaceSvg | null>(null);
const [isInitialized, setIsInitialized] = useState(false);
const changeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Initialize Blockly workspace
useEffect(() => {
if (!blocklyDiv.current) return;
// Initialize custom Noodl blocks and generators before creating workspace
initBlocklyIntegration();
console.log('🔧 [Blockly] Initializing workspace');
// Inject Blockly
// Inject Blockly with dark theme
const workspace = Blockly.inject(blocklyDiv.current, {
toolbox: toolbox || getDefaultToolbox(),
theme: theme,
theme: theme || DarkTheme,
readOnly: readOnly,
trashcan: true,
zoom: {
@@ -86,13 +92,43 @@ export function BlocklyWorkspace({
}
}
setIsInitialized(true);
// Listen for changes - filter to only respond to finished workspace changes,
// not UI events like dragging or moving blocks
const changeListener = (event: Blockly.Events.Abstract) => {
if (!onChange || !workspace) return;
// Listen for changes
const changeListener = () => {
if (onChange && workspace) {
// Ignore UI events that don't change the workspace structure
// These fire constantly during drags and can cause state corruption
if (event.type === Blockly.Events.BLOCK_DRAG) return;
if (event.type === Blockly.Events.BLOCK_MOVE && !event.isUiEvent) return; // Allow programmatic moves
if (event.type === Blockly.Events.SELECTED) return;
if (event.type === Blockly.Events.CLICK) return;
if (event.type === Blockly.Events.VIEWPORT_CHANGE) return;
if (event.type === Blockly.Events.TOOLBOX_ITEM_SELECT) return;
if (event.type === Blockly.Events.THEME_CHANGE) return;
if (event.type === Blockly.Events.TRASHCAN_OPEN) return;
// For UI events that DO change the workspace, debounce them
const isUiEvent = event.isUiEvent;
if (isUiEvent) {
// Clear any pending timeout for UI events
if (changeTimeoutRef.current) {
clearTimeout(changeTimeoutRef.current);
}
// Debounce UI-initiated changes (user editing)
changeTimeoutRef.current = setTimeout(() => {
const json = JSON.stringify(Blockly.serialization.workspaces.save(workspace));
const code = javascriptGenerator.workspaceToCode(workspace);
console.log('[Blockly] Generated code:', code);
onChange(workspace, json, code);
}, 300);
} else {
// Programmatic changes fire immediately (e.g., undo/redo, loading)
const json = JSON.stringify(Blockly.serialization.workspaces.save(workspace));
const code = Blockly.JavaScript.workspaceToCode(workspace);
const code = javascriptGenerator.workspaceToCode(workspace);
console.log('[Blockly] Generated code:', code);
onChange(workspace, json, code);
}
};
@@ -102,24 +138,21 @@ export function BlocklyWorkspace({
// Cleanup
return () => {
console.log('🧹 [Blockly] Disposing workspace');
// Clear any pending debounced calls
if (changeTimeoutRef.current) {
clearTimeout(changeTimeoutRef.current);
}
workspace.removeChangeListener(changeListener);
workspace.dispose();
workspaceRef.current = null;
setIsInitialized(false);
};
}, [toolbox, theme, readOnly]);
// Handle initial workspace separately to avoid re-initialization
useEffect(() => {
if (isInitialized && initialWorkspace && workspaceRef.current) {
try {
const json = JSON.parse(initialWorkspace);
Blockly.serialization.workspaces.load(json, workspaceRef.current);
} catch (error) {
console.error('❌ [Blockly] Failed to update workspace:', error);
}
}
}, [initialWorkspace]);
// NOTE: Do NOT reload workspace on initialWorkspace changes!
// The initialWorkspace prop changes on every save, which would cause corruption.
// Workspace is loaded ONCE on mount above, and changes are saved via onChange callback.
return (
<div className={css.Root}>
@@ -129,13 +162,68 @@ export function BlocklyWorkspace({
}
/**
* Default toolbox with standard Blockly blocks
* This will be replaced with Noodl-specific toolbox
* Default toolbox with Noodl-specific blocks
*/
function getDefaultToolbox(): Blockly.utils.toolbox.ToolboxDefinition {
return {
kind: 'categoryToolbox',
contents: [
// Noodl I/O Category
{
kind: 'category',
name: 'Noodl Inputs/Outputs',
colour: '230',
contents: [
{ kind: 'block', type: 'noodl_define_input' },
{ kind: 'block', type: 'noodl_get_input' },
{ kind: 'block', type: 'noodl_define_output' },
{ kind: 'block', type: 'noodl_set_output' }
]
},
// Noodl Signals Category
{
kind: 'category',
name: 'Noodl Signals',
colour: '180',
contents: [
{ kind: 'block', type: 'noodl_define_signal_input' },
{ kind: 'block', type: 'noodl_define_signal_output' },
{ kind: 'block', type: 'noodl_send_signal' }
]
},
// Noodl Variables Category
{
kind: 'category',
name: 'Noodl Variables',
colour: '330',
contents: [
{ kind: 'block', type: 'noodl_get_variable' },
{ kind: 'block', type: 'noodl_set_variable' }
]
},
// Noodl Objects Category
{
kind: 'category',
name: 'Noodl Objects',
colour: '20',
contents: [
{ kind: 'block', type: 'noodl_get_object' },
{ kind: 'block', type: 'noodl_get_object_property' },
{ kind: 'block', type: 'noodl_set_object_property' }
]
},
// Noodl Arrays Category
{
kind: 'category',
name: 'Noodl Arrays',
colour: '260',
contents: [
{ kind: 'block', type: 'noodl_get_array' },
{ kind: 'block', type: 'noodl_array_length' },
{ kind: 'block', type: 'noodl_array_add' }
]
},
// Standard Logic blocks (useful for conditionals)
{
kind: 'category',
name: 'Logic',
@@ -148,6 +236,7 @@ function getDefaultToolbox(): Blockly.utils.toolbox.ToolboxDefinition {
{ kind: 'block', type: 'logic_boolean' }
]
},
// Standard Math blocks
{
kind: 'category',
name: 'Math',
@@ -158,6 +247,7 @@ function getDefaultToolbox(): Blockly.utils.toolbox.ToolboxDefinition {
{ kind: 'block', type: 'math_single' }
]
},
// Standard Text blocks
{
kind: 'category',
name: 'Text',

View File

@@ -13,7 +13,7 @@
*/
import * as Blockly from 'blockly';
import { javascriptGenerator } from 'blockly/javascript';
import { javascriptGenerator, Order } from 'blockly/javascript';
/**
* Initialize all Noodl code generators
@@ -49,7 +49,7 @@ function initInputOutputGenerators() {
javascriptGenerator.forBlock['noodl_get_input'] = function (block) {
const name = block.getFieldValue('NAME');
const code = `Inputs["${name}"]`;
return [code, Blockly.JavaScript.ORDER_MEMBER];
return [code, Order.MEMBER];
};
// Define Output - no runtime code (used for I/O detection only)
@@ -60,7 +60,7 @@ function initInputOutputGenerators() {
// Set Output - generates: Outputs["name"] = value;
javascriptGenerator.forBlock['noodl_set_output'] = function (block) {
const name = block.getFieldValue('NAME');
const value = javascriptGenerator.valueToCode(block, 'VALUE', Blockly.JavaScript.ORDER_ASSIGNMENT) || 'null';
const value = javascriptGenerator.valueToCode(block, 'VALUE', Order.ASSIGNMENT) || 'null';
return `Outputs["${name}"] = ${value};\n`;
};
@@ -89,13 +89,13 @@ function initVariableGenerators() {
javascriptGenerator.forBlock['noodl_get_variable'] = function (block) {
const name = block.getFieldValue('NAME');
const code = `Noodl.Variables["${name}"]`;
return [code, Blockly.JavaScript.ORDER_MEMBER];
return [code, Order.MEMBER];
};
// Set Variable - generates: Noodl.Variables["name"] = value;
javascriptGenerator.forBlock['noodl_set_variable'] = function (block) {
const name = block.getFieldValue('NAME');
const value = javascriptGenerator.valueToCode(block, 'VALUE', Blockly.JavaScript.ORDER_ASSIGNMENT) || 'null';
const value = javascriptGenerator.valueToCode(block, 'VALUE', Order.ASSIGNMENT) || 'null';
return `Noodl.Variables["${name}"] = ${value};\n`;
};
}
@@ -106,24 +106,24 @@ function initVariableGenerators() {
function initObjectGenerators() {
// Get Object - generates: Noodl.Objects[id]
javascriptGenerator.forBlock['noodl_get_object'] = function (block) {
const id = javascriptGenerator.valueToCode(block, 'ID', Blockly.JavaScript.ORDER_NONE) || '""';
const id = javascriptGenerator.valueToCode(block, 'ID', Order.NONE) || '""';
const code = `Noodl.Objects[${id}]`;
return [code, Blockly.JavaScript.ORDER_MEMBER];
return [code, Order.MEMBER];
};
// Get Object Property - generates: object["property"]
javascriptGenerator.forBlock['noodl_get_object_property'] = function (block) {
const property = block.getFieldValue('PROPERTY');
const object = javascriptGenerator.valueToCode(block, 'OBJECT', Blockly.JavaScript.ORDER_MEMBER) || '{}';
const object = javascriptGenerator.valueToCode(block, 'OBJECT', Order.MEMBER) || '{}';
const code = `${object}["${property}"]`;
return [code, Blockly.JavaScript.ORDER_MEMBER];
return [code, Order.MEMBER];
};
// Set Object Property - generates: object["property"] = value;
javascriptGenerator.forBlock['noodl_set_object_property'] = function (block) {
const property = block.getFieldValue('PROPERTY');
const object = javascriptGenerator.valueToCode(block, 'OBJECT', Blockly.JavaScript.ORDER_MEMBER) || '{}';
const value = javascriptGenerator.valueToCode(block, 'VALUE', Blockly.JavaScript.ORDER_ASSIGNMENT) || 'null';
const object = javascriptGenerator.valueToCode(block, 'OBJECT', Order.MEMBER) || '{}';
const value = javascriptGenerator.valueToCode(block, 'VALUE', Order.ASSIGNMENT) || 'null';
return `${object}["${property}"] = ${value};\n`;
};
}
@@ -136,20 +136,20 @@ function initArrayGenerators() {
javascriptGenerator.forBlock['noodl_get_array'] = function (block) {
const name = block.getFieldValue('NAME');
const code = `Noodl.Arrays["${name}"]`;
return [code, Blockly.JavaScript.ORDER_MEMBER];
return [code, Order.MEMBER];
};
// Array Length - generates: array.length
javascriptGenerator.forBlock['noodl_array_length'] = function (block) {
const array = javascriptGenerator.valueToCode(block, 'ARRAY', Blockly.JavaScript.ORDER_MEMBER) || '[]';
const array = javascriptGenerator.valueToCode(block, 'ARRAY', Order.MEMBER) || '[]';
const code = `${array}.length`;
return [code, Blockly.JavaScript.ORDER_MEMBER];
return [code, Order.MEMBER];
};
// Array Add - generates: array.push(item);
javascriptGenerator.forBlock['noodl_array_add'] = function (block) {
const item = javascriptGenerator.valueToCode(block, 'ITEM', Blockly.JavaScript.ORDER_NONE) || 'null';
const array = javascriptGenerator.valueToCode(block, 'ARRAY', Blockly.JavaScript.ORDER_MEMBER) || '[]';
const item = javascriptGenerator.valueToCode(block, 'ITEM', Order.NONE) || 'null';
const array = javascriptGenerator.valueToCode(block, 'ARRAY', Order.MEMBER) || '[]';
return `${array}.push(${item});\n`;
};
}

View File

@@ -20,11 +20,20 @@ export type { BlocklyWorkspaceProps } from './BlocklyWorkspace';
export { initNoodlBlocks } from './NoodlBlocks';
export { initNoodlGenerators, generateCode } from './NoodlGenerators';
// Track initialization to prevent double-registration
let blocklyInitialized = false;
/**
* Initialize all Noodl Blockly extensions
* Call this once at app startup before using Blockly components
* Safe to call multiple times - will only initialize once
*/
export function initBlocklyIntegration() {
if (blocklyInitialized) {
console.log('⏭️ [Blockly] Already initialized, skipping');
return;
}
console.log('🔧 [Blockly] Initializing Noodl Blockly integration');
// Initialize custom blocks
@@ -35,5 +44,6 @@ export function initBlocklyIntegration() {
// Note: BlocklyEditorGlobals auto-initializes via side-effect import above
blocklyInitialized = true;
console.log('✅ [Blockly] Integration initialized');
}

View File

@@ -9,6 +9,7 @@
flex-direction: column;
height: 100%;
width: 100%;
pointer-events: all; /* Enable clicks on tabs */
}
.TabBar {

View File

@@ -6,14 +6,15 @@ import css from './CanvasTabs.module.scss';
export interface CanvasTabsProps {
/** Callback when workspace changes */
onWorkspaceChange?: (nodeId: string, workspace: string) => void;
onWorkspaceChange?: (nodeId: string, workspace: string, code: string) => void;
}
/**
* Canvas Tabs Component
*
* Manages tabs for canvas view and Logic Builder (Blockly) editors.
* Renders a tab bar and switches content based on active tab.
* Manages tabs for Logic Builder (Blockly) editors.
* The canvas itself is NOT managed here - it's always visible in the background
* unless a Logic Builder tab is open.
*/
export function CanvasTabs({ onWorkspaceChange }: CanvasTabsProps) {
const { tabs, activeTabId, switchTab, closeTab, updateTab } = useCanvasTabs();
@@ -23,17 +24,17 @@ export function CanvasTabs({ onWorkspaceChange }: CanvasTabsProps) {
/**
* Handle workspace changes from Blockly editor
*/
const handleWorkspaceChange = (_workspaceSvg: unknown, json: string) => {
if (!activeTab || activeTab.type !== 'logic-builder') {
const handleWorkspaceChange = (_workspaceSvg: unknown, json: string, code: string) => {
if (!activeTab) {
return;
}
// Update tab's workspace with JSON
updateTab(activeTab.id, { workspace: json });
// Notify parent
// Notify parent (pass both workspace JSON and generated code)
if (onWorkspaceChange && activeTab.nodeId) {
onWorkspaceChange(activeTab.nodeId, json);
onWorkspaceChange(activeTab.nodeId, json, code);
}
};
@@ -52,13 +53,17 @@ export function CanvasTabs({ onWorkspaceChange }: CanvasTabsProps) {
closeTab(tabId);
};
// Don't render anything if no tabs are open
if (tabs.length === 0) {
return null;
}
return (
<div className={css['CanvasTabs']}>
{/* Tab Bar */}
<div className={css['TabBar']}>
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
const canClose = tab.type !== 'canvas';
return (
<div
@@ -69,20 +74,16 @@ export function CanvasTabs({ onWorkspaceChange }: CanvasTabsProps) {
aria-selected={isActive}
tabIndex={0}
>
<span className={css['TabLabel']}>
{tab.type === 'canvas' ? 'Canvas' : `Logic Builder: ${tab.nodeName || 'Unnamed'}`}
</span>
<span className={css['TabLabel']}>Logic Builder: {tab.nodeName || 'Unnamed'}</span>
{canClose && (
<button
className={css['TabCloseButton']}
onClick={(e) => handleTabClose(e, tab.id)}
aria-label="Close tab"
title="Close tab"
>
×
</button>
)}
<button
className={css['TabCloseButton']}
onClick={(e) => handleTabClose(e, tab.id)}
aria-label="Close tab"
title="Close tab"
>
×
</button>
</div>
);
})}
@@ -90,13 +91,7 @@ export function CanvasTabs({ onWorkspaceChange }: CanvasTabsProps) {
{/* Tab Content */}
<div className={css['TabContent']}>
{activeTab?.type === 'canvas' && (
<div className={css['CanvasContainer']} id="nodegraph-canvas-container">
{/* Canvas will be rendered here by NodeGraphEditor */}
</div>
)}
{activeTab?.type === 'logic-builder' && (
{activeTab && (
<div className={css['BlocklyContainer']}>
<BlocklyWorkspace initialWorkspace={activeTab.workspace || undefined} onChange={handleWorkspaceChange} />
</div>

View File

@@ -37,6 +37,8 @@ import { NodeLibrary } from '../models/nodelibrary';
import { ProjectModel } from '../models/projectmodel';
import { WarningsModel } from '../models/warningsmodel';
import { HighlightManager } from '../services/HighlightManager';
// Initialize Blockly globals early (must run before runtime nodes load)
import { initBlocklyEditorGlobals } from '../utils/BlocklyEditorGlobals';
import DebugInspector from '../utils/debuginspector';
import { rectanglesOverlap, guid } from '../utils/utils';
import { ViewerConnection } from '../ViewerConnection';
@@ -59,6 +61,8 @@ import PopupLayer from './popuplayer';
import { showContextMenuInPopup } from './ShowContextMenuInPopup';
import { ToastLayer } from './ToastLayer/ToastLayer';
initBlocklyEditorGlobals();
// eslint-disable-next-line @typescript-eslint/no-var-requires
const NodeGraphEditorTemplate = require('../templates/nodegrapheditor.html');
@@ -300,13 +304,32 @@ export class NodeGraphEditor extends View {
this
);
// Listen for Logic Builder tab open requests
// Listen for Logic Builder tab opened - hide canvas
EventDispatcher.instance.on(
'LogicBuilder.TabOpened',
() => {
console.log('[NodeGraphEditor] Logic Builder tab opened - hiding canvas');
this.setCanvasVisibility(false);
},
this
);
// Listen for all Logic Builder tabs closed - show canvas
EventDispatcher.instance.on(
'LogicBuilder.AllTabsClosed',
() => {
console.log('[NodeGraphEditor] All Logic Builder tabs closed - showing canvas');
this.setCanvasVisibility(true);
},
this
);
// Listen for Logic Builder tab open requests (for opening tabs from property panel)
EventDispatcher.instance.on(
'LogicBuilder.OpenTab',
(args: { nodeId: string; nodeName: string; workspace: string }) => {
console.log('[NodeGraphEditor] Opening Logic Builder tab for node:', args.nodeId);
// The CanvasTabs context will handle the actual tab opening via EventDispatcher
// This is just logged for debugging - the actual implementation happens in Phase C Step 6
// The CanvasTabs context will handle the actual tab opening
},
this
);
@@ -941,7 +964,7 @@ export class NodeGraphEditor extends View {
/**
* Handle workspace changes from Blockly editor
*/
handleBlocklyWorkspaceChange(nodeId: string, workspace: string) {
handleBlocklyWorkspaceChange(nodeId: string, workspace: string, code: string) {
console.log(`[NodeGraphEditor] Workspace changed for node ${nodeId}`);
const node = this.findNodeWithId(nodeId);
@@ -950,11 +973,14 @@ export class NodeGraphEditor extends View {
return;
}
// Save workspace to node model
// Save workspace JSON to node model
node.model.setParameter('workspace', workspace);
// TODO: Generate code and update ports
// This will be implemented in Phase C Step 7
// Save generated JavaScript code to node model
// This triggers the runtime's parameterUpdated listener which calls updatePorts()
node.model.setParameter('generatedCode', code);
console.log(`[NodeGraphEditor] Saved workspace and generated code for node ${nodeId}`);
}
/**
@@ -1015,6 +1041,35 @@ export class NodeGraphEditor extends View {
}
}
/**
* Set canvas visibility (hide when Logic Builder is open, show when closed)
*/
setCanvasVisibility(visible: boolean) {
const canvasElement = this.el.find('#nodegraphcanvas');
const commentLayerBg = this.el.find('#comment-layer-bg');
const commentLayerFg = this.el.find('#comment-layer-fg');
const highlightOverlay = this.el.find('#highlight-overlay-layer');
const componentTrail = this.el.find('.nodegraph-component-trail-root');
if (visible) {
// Show canvas and related elements
canvasElement.css('display', 'block');
commentLayerBg.css('display', 'block');
commentLayerFg.css('display', 'block');
highlightOverlay.css('display', 'block');
componentTrail.css('display', 'flex');
this.domElementContainer.style.display = '';
} else {
// Hide canvas and related elements
canvasElement.css('display', 'none');
commentLayerBg.css('display', 'none');
commentLayerFg.css('display', 'none');
highlightOverlay.css('display', 'none');
componentTrail.css('display', 'none');
this.domElementContainer.style.display = 'none';
}
}
// This is called by the parent view (frames view) when the size and position
// changes
resize(layout) {

View File

@@ -33,7 +33,7 @@ export class LogicBuilderWorkspaceType extends TypeView {
render() {
// Create a simple container with a button
const html = `
<div class="property-basic-container logic-builder-workspace-editor">
<div class="property-basic-container logic-builder-workspace-editor" style="display: flex; flex-direction: column; gap: 8px;">
<div class="property-label-container" style="display: flex; align-items: center; gap: 8px;">
<div class="property-changed-dot" data-click="resetToDefault" style="display: none;"></div>
<div class="property-label">${this.displayName}</div>
@@ -77,11 +77,12 @@ export class LogicBuilderWorkspaceType extends TypeView {
}
onEditBlocksClicked() {
const nodeId = this.parent.model.id;
const nodeName = this.parent.model.label || this.parent.model.getDisplayName() || 'Logic Builder';
const workspace = this.parent.model.getParameter('workspace') || '';
// ModelProxy wraps the actual node model in a .model property
const nodeId = this.parent?.model?.model?.id;
const nodeName = this.parent?.model?.model?.label || this.parent?.model?.type?.displayName || 'Logic Builder';
const workspace = this.parent?.model?.getParameter('workspace') || '';
console.log('[LogicBuilderWorkspaceType] Opening tab for node:', nodeId);
console.log('[LogicBuilderWorkspaceType] Opening Logic Builder tab for node:', nodeId);
// Emit event to open Logic Builder tab
EventDispatcher.instance.emit('LogicBuilder.OpenTab', {