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:
Richard Osborne
2026-01-11 13:55:04 +01:00
parent c2f1ba320c
commit 30a70a4eb3
4 changed files with 403 additions and 0 deletions

View File

@@ -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>;
}

View File

@@ -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);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,8 @@
/**
* CanvasTabs Module
*
* Exports canvas tab system components
*/
export { CanvasTabs } from './CanvasTabs';
export type { CanvasTabsProps } from './CanvasTabs';