mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-13 15:52:56 +01:00
feat(blockly): Phase C Steps 1-4 - Core tab system components
Created complete tab system foundation: - CanvasTabsContext: React Context for tab state management - CanvasTabs component: Tab UI with canvas/Blockly switching - Theme-aware SCSS styling using design tokens - Full TypeScript types and exports Next: Integrate into NodeGraphEditor, add property panel button
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Tab types supported by the canvas tab system
|
||||
*/
|
||||
export type TabType = 'canvas' | 'logic-builder';
|
||||
|
||||
/**
|
||||
* Tab data structure
|
||||
*/
|
||||
export interface Tab {
|
||||
/** Unique tab identifier */
|
||||
id: string;
|
||||
/** Type of tab */
|
||||
type: TabType;
|
||||
/** Node ID (for logic-builder tabs) */
|
||||
nodeId?: string;
|
||||
/** Node name for display (for logic-builder tabs) */
|
||||
nodeName?: string;
|
||||
/** Blockly workspace JSON (for logic-builder tabs) */
|
||||
workspace?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context value shape
|
||||
*/
|
||||
export interface CanvasTabsContextValue {
|
||||
/** All open tabs */
|
||||
tabs: Tab[];
|
||||
/** Currently active tab ID */
|
||||
activeTabId: string;
|
||||
/** Open a new tab or switch to existing */
|
||||
openTab: (tab: Omit<Tab, 'id'> & { id?: string }) => void;
|
||||
/** Close a tab by ID */
|
||||
closeTab: (tabId: string) => void;
|
||||
/** Switch to a different tab */
|
||||
switchTab: (tabId: string) => void;
|
||||
/** Update tab data */
|
||||
updateTab: (tabId: string, updates: Partial<Tab>) => void;
|
||||
/** Get tab by ID */
|
||||
getTab: (tabId: string) => Tab | undefined;
|
||||
}
|
||||
|
||||
const CanvasTabsContext = createContext<CanvasTabsContextValue | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Hook to access canvas tabs context
|
||||
*/
|
||||
export function useCanvasTabs(): CanvasTabsContextValue {
|
||||
const context = useContext(CanvasTabsContext);
|
||||
if (!context) {
|
||||
throw new Error('useCanvasTabs must be used within a CanvasTabsProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface CanvasTabsProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'
|
||||
}
|
||||
]);
|
||||
|
||||
const [activeTabId, setActiveTabId] = useState<string>('canvas');
|
||||
|
||||
/**
|
||||
* Open a new tab or switch to existing one
|
||||
*/
|
||||
const openTab = useCallback((newTab: Omit<Tab, 'id'> & { id?: string }) => {
|
||||
// Generate ID if not provided
|
||||
const tabId = newTab.id || `${newTab.type}-${newTab.nodeId || Date.now()}`;
|
||||
|
||||
setTabs((prevTabs) => {
|
||||
// Check if tab already exists
|
||||
const existingTab = prevTabs.find((t) => t.id === tabId);
|
||||
if (existingTab) {
|
||||
// Tab exists, just switch to it
|
||||
setActiveTabId(tabId);
|
||||
return prevTabs;
|
||||
}
|
||||
|
||||
// Add new tab
|
||||
const tab: Tab = {
|
||||
...newTab,
|
||||
id: tabId
|
||||
};
|
||||
return [...prevTabs, tab];
|
||||
});
|
||||
|
||||
// Switch to the new/existing tab
|
||||
setActiveTabId(tabId);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
return prevTabs;
|
||||
}
|
||||
|
||||
const newTabs = prevTabs.filter((t) => t.id !== tabId);
|
||||
|
||||
// If closing the active tab, switch to canvas
|
||||
if (activeTabId === tabId) {
|
||||
setActiveTabId('canvas');
|
||||
}
|
||||
|
||||
return newTabs;
|
||||
});
|
||||
},
|
||||
[activeTabId]
|
||||
);
|
||||
|
||||
/**
|
||||
* Switch to a different tab
|
||||
*/
|
||||
const switchTab = useCallback((tabId: string) => {
|
||||
setTabs((prevTabs) => {
|
||||
// Verify tab exists
|
||||
const tab = prevTabs.find((t) => t.id === tabId);
|
||||
if (!tab) {
|
||||
console.warn(`[CanvasTabs] Tab ${tabId} not found`);
|
||||
return prevTabs;
|
||||
}
|
||||
|
||||
setActiveTabId(tabId);
|
||||
return prevTabs;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Update tab data
|
||||
*/
|
||||
const updateTab = useCallback((tabId: string, updates: Partial<Tab>) => {
|
||||
setTabs((prevTabs) => {
|
||||
return prevTabs.map((tab) => {
|
||||
if (tab.id === tabId) {
|
||||
return { ...tab, ...updates };
|
||||
}
|
||||
return tab;
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get tab by ID
|
||||
*/
|
||||
const getTab = useCallback(
|
||||
(tabId: string): Tab | undefined => {
|
||||
return tabs.find((t) => t.id === tabId);
|
||||
},
|
||||
[tabs]
|
||||
);
|
||||
|
||||
const value: CanvasTabsContextValue = {
|
||||
tabs,
|
||||
activeTabId,
|
||||
openTab,
|
||||
closeTab,
|
||||
switchTab,
|
||||
updateTab,
|
||||
getTab
|
||||
};
|
||||
|
||||
return <CanvasTabsContext.Provider value={value}>{children}</CanvasTabsContext.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Canvas Tabs Styling
|
||||
*
|
||||
* Theme-aware styling for canvas/Blockly tab system
|
||||
*/
|
||||
|
||||
.CanvasTabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.TabBar {
|
||||
display: flex;
|
||||
gap: var(--spacing-1);
|
||||
padding: var(--spacing-2);
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.Tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
background-color: var(--theme-color-bg-3);
|
||||
color: var(--theme-color-fg-default);
|
||||
border-radius: var(--radius-default) var(--radius-default) 0 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color 0.15s ease;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-4);
|
||||
}
|
||||
|
||||
&.isActive {
|
||||
background-color: var(--theme-color-bg-1);
|
||||
color: var(--theme-color-fg-highlight);
|
||||
border-color: var(--theme-color-border-default);
|
||||
border-bottom-color: var(--theme-color-bg-1); // Hide bottom border
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--theme-color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.TabLabel {
|
||||
font-size: var(--theme-font-size-default);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.TabCloseButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-s);
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-5);
|
||||
color: var(--theme-color-fg-highlight);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--theme-color-bg-4);
|
||||
}
|
||||
}
|
||||
|
||||
.TabContent {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background-color: var(--theme-color-bg-1);
|
||||
}
|
||||
|
||||
.CanvasContainer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.BlocklyContainer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: var(--theme-color-bg-1);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useCanvasTabs } from '../../contexts/CanvasTabsContext';
|
||||
import { BlocklyWorkspace } from '../BlocklyEditor';
|
||||
import css from './CanvasTabs.module.scss';
|
||||
|
||||
export interface CanvasTabsProps {
|
||||
/** Callback when workspace changes */
|
||||
onWorkspaceChange?: (nodeId: string, workspace: 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.
|
||||
*/
|
||||
export function CanvasTabs({ onWorkspaceChange }: CanvasTabsProps) {
|
||||
const { tabs, activeTabId, switchTab, closeTab, updateTab } = useCanvasTabs();
|
||||
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||
|
||||
/**
|
||||
* Handle workspace changes from Blockly editor
|
||||
*/
|
||||
const handleWorkspaceChange = (_workspaceSvg: unknown, json: string) => {
|
||||
if (!activeTab || activeTab.type !== 'logic-builder') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update tab's workspace with JSON
|
||||
updateTab(activeTab.id, { workspace: json });
|
||||
|
||||
// Notify parent
|
||||
if (onWorkspaceChange && activeTab.nodeId) {
|
||||
onWorkspaceChange(activeTab.nodeId, json);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle tab click
|
||||
*/
|
||||
const handleTabClick = (tabId: string) => {
|
||||
switchTab(tabId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle tab close
|
||||
*/
|
||||
const handleTabClose = (e: React.MouseEvent, tabId: string) => {
|
||||
e.stopPropagation(); // Don't trigger tab switch
|
||||
closeTab(tabId);
|
||||
};
|
||||
|
||||
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
|
||||
key={tab.id}
|
||||
className={`${css['Tab']} ${isActive ? css['isActive'] : ''}`}
|
||||
onClick={() => handleTabClick(tab.id)}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className={css['TabLabel']}>
|
||||
{tab.type === 'canvas' ? 'Canvas' : `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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 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' && (
|
||||
<div className={css['BlocklyContainer']}>
|
||||
<BlocklyWorkspace initialWorkspace={activeTab.workspace || undefined} onChange={handleWorkspaceChange} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* CanvasTabs Module
|
||||
*
|
||||
* Exports canvas tab system components
|
||||
*/
|
||||
|
||||
export { CanvasTabs } from './CanvasTabs';
|
||||
export type { CanvasTabsProps } from './CanvasTabs';
|
||||
Reference in New Issue
Block a user