diff --git a/packages/noodl-editor/src/editor/src/contexts/CanvasTabsContext.tsx b/packages/noodl-editor/src/editor/src/contexts/CanvasTabsContext.tsx new file mode 100644 index 0000000..2000e6d --- /dev/null +++ b/packages/noodl-editor/src/editor/src/contexts/CanvasTabsContext.tsx @@ -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 & { 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) => void; + /** Get tab by ID */ + getTab: (tabId: string) => Tab | undefined; +} + +const CanvasTabsContext = createContext(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([ + { + id: 'canvas', + type: 'canvas' + } + ]); + + const [activeTabId, setActiveTabId] = useState('canvas'); + + /** + * Open a new tab or switch to existing one + */ + const openTab = useCallback((newTab: Omit & { 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) => { + 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 {children}; +} diff --git a/packages/noodl-editor/src/editor/src/views/CanvasTabs/CanvasTabs.module.scss b/packages/noodl-editor/src/editor/src/views/CanvasTabs/CanvasTabs.module.scss new file mode 100644 index 0000000..821003d --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/CanvasTabs/CanvasTabs.module.scss @@ -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); +} diff --git a/packages/noodl-editor/src/editor/src/views/CanvasTabs/CanvasTabs.tsx b/packages/noodl-editor/src/editor/src/views/CanvasTabs/CanvasTabs.tsx new file mode 100644 index 0000000..ae3dcd7 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/CanvasTabs/CanvasTabs.tsx @@ -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 ( +
+ {/* Tab Bar */} +
+ {tabs.map((tab) => { + const isActive = tab.id === activeTabId; + const canClose = tab.type !== 'canvas'; + + return ( +
handleTabClick(tab.id)} + role="tab" + aria-selected={isActive} + tabIndex={0} + > + + {tab.type === 'canvas' ? 'Canvas' : `Logic Builder: ${tab.nodeName || 'Unnamed'}`} + + + {canClose && ( + + )} +
+ ); + })} +
+ + {/* Tab Content */} +
+ {activeTab?.type === 'canvas' && ( +
+ {/* Canvas will be rendered here by NodeGraphEditor */} +
+ )} + + {activeTab?.type === 'logic-builder' && ( +
+ +
+ )} +
+
+ ); +} diff --git a/packages/noodl-editor/src/editor/src/views/CanvasTabs/index.ts b/packages/noodl-editor/src/editor/src/views/CanvasTabs/index.ts new file mode 100644 index 0000000..cd5e79e --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/CanvasTabs/index.ts @@ -0,0 +1,8 @@ +/** + * CanvasTabs Module + * + * Exports canvas tab system components + */ + +export { CanvasTabs } from './CanvasTabs'; +export type { CanvasTabsProps } from './CanvasTabs';