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