Initial commit
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { useEditor, TldrawUiButton } from '@tldraw/tldraw';
|
||||
|
||||
export function StickerDropdown() {
|
||||
const editor = useEditor()
|
||||
|
||||
const handleToolSelect = (toolId: string) => {
|
||||
editor.setCurrentTool(toolId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
padding: '8px',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
}}>
|
||||
<TldrawUiButton type="tool" onClick={() => handleToolSelect('heartSticker')} style={{ fontSize: '1.5rem' }}>❤️</TldrawUiButton>
|
||||
<TldrawUiButton type="tool" onClick={() => handleToolSelect('starSticker')} style={{ fontSize: '1.5rem' }}>⭐</TldrawUiButton>
|
||||
<TldrawUiButton type="tool" onClick={() => handleToolSelect('smileySticker')} style={{ fontSize: '1.5rem' }}>😊</TldrawUiButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BasePanel } from './shared/BasePanel';
|
||||
import { CCExamMarkerPanel } from './shared/CCExamMarkerPanel';
|
||||
import { BaseContext, ViewContext } from '../../../../types/navigation';
|
||||
|
||||
interface CCPanelProps {
|
||||
examMarkerProps?: React.ComponentProps<typeof CCExamMarkerPanel>;
|
||||
isExpanded?: boolean;
|
||||
isPinned?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
onPinnedChange?: (pinned: boolean) => void;
|
||||
}
|
||||
|
||||
export const CCPanel: React.FC<CCPanelProps> = ({
|
||||
examMarkerProps,
|
||||
isExpanded,
|
||||
isPinned,
|
||||
onExpandedChange,
|
||||
onPinnedChange,
|
||||
}) => {
|
||||
const [currentContext, setCurrentContext] = useState<BaseContext>('profile');
|
||||
const [currentExtendedContext, setCurrentExtendedContext] = useState<ViewContext>('overview');
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
// Reset menu state when panel is closed
|
||||
const handleExpandedChange = (expanded: boolean) => {
|
||||
if (!expanded) {
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
onExpandedChange?.(expanded);
|
||||
};
|
||||
|
||||
return (
|
||||
<BasePanel
|
||||
examMarkerProps={examMarkerProps}
|
||||
isExpanded={isExpanded}
|
||||
isPinned={isPinned}
|
||||
onExpandedChange={handleExpandedChange}
|
||||
onPinnedChange={onPinnedChange}
|
||||
currentContext={currentContext}
|
||||
onContextChange={setCurrentContext}
|
||||
currentExtendedContext={currentExtendedContext}
|
||||
onExtendedContextChange={setCurrentExtendedContext}
|
||||
isMenuOpen={isMenuOpen}
|
||||
onMenuOpenChange={setIsMenuOpen}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { TLComponents } from '@tldraw/tldraw';
|
||||
|
||||
import { RegularToolbar } from './regular/toolbar';
|
||||
import { RegularHelperButtons } from './regular/helperButton';
|
||||
// import { RegularHelpMenu } from './regular/helpMenu';
|
||||
// import { RegularMainMenu } from './regular/mainMenu';
|
||||
import { RegularNavigationPanel } from './regular/navigationPanel';
|
||||
// import { RegularPageMenu } from './regular/pageMenu';
|
||||
// import { RegularQuickActions } from './regular/quickActions';
|
||||
import { RegularStylePanel } from './regular/stylePanel';
|
||||
import { RegularZoomMenu } from './regular/zoomMenu';
|
||||
import { RegularKeyboardShortcutsDialog } from './regular/keyboardShortcutsDialog';
|
||||
// import { RegularActionsMenu } from './regular/actionsMenu';
|
||||
import { RegularContextMenu } from './regular/contextMenu';
|
||||
import { RegularDebugMenu } from './regular/debugMenu';
|
||||
|
||||
import { PresentationToolbar } from './presentation/toolbar';
|
||||
import { PresentationHelperButtons } from './presentation/helperButton';
|
||||
// import { PresentationHelpMenu } from './presentation/helpMenu';
|
||||
// import { PresentationMainMenu } from './presentation/mainMenu';
|
||||
import { PresentationNavigationPanel } from './presentation/navigationPanel';
|
||||
// import { PresentationPageMenu } from './presentation/pageMenu';
|
||||
import { PresentationQuickActions } from './presentation/quickActions';
|
||||
import { PresentationStylePanel } from './presentation/stylePanel';
|
||||
import { PresentationZoomMenu } from './presentation/zoomMenu';
|
||||
import { PresentationKeyboardShortcutsDialog } from './presentation/keyboardShortcutsDialog';
|
||||
import { PresentationActionsMenu } from './presentation/actionsMenu';
|
||||
import { PresentationContextMenu } from './presentation/contextMenu';
|
||||
import { PresentationDebugMenu } from './presentation/debugMenu';
|
||||
import { CCPanel } from './CCPanel';
|
||||
|
||||
export const regularComponentsIndex: TLComponents = {
|
||||
Toolbar: RegularToolbar,
|
||||
InFrontOfTheCanvas: CCPanel,
|
||||
HelperButtons: RegularHelperButtons,
|
||||
// HelpMenu: RegularHelpMenu,
|
||||
// MainMenu: RegularMainMenu,
|
||||
NavigationPanel: RegularNavigationPanel,
|
||||
// PageMenu: RegularPageMenu,
|
||||
// QuickActions: RegularQuickActions,
|
||||
StylePanel: RegularStylePanel,
|
||||
ZoomMenu: RegularZoomMenu,
|
||||
KeyboardShortcutsDialog: RegularKeyboardShortcutsDialog,
|
||||
// ActionsMenu: RegularActionsMenu,
|
||||
ContextMenu: RegularContextMenu,
|
||||
DebugMenu: RegularDebugMenu,
|
||||
};
|
||||
|
||||
export const presentationComponentsIndex: TLComponents = {
|
||||
Toolbar: PresentationToolbar,
|
||||
InFrontOfTheCanvas: CCPanel,
|
||||
HelperButtons: PresentationHelperButtons,
|
||||
// HelpMenu: PresentationHelpMenu,
|
||||
// MainMenu: PresentationMainMenu,
|
||||
NavigationPanel: PresentationNavigationPanel,
|
||||
// PageMenu: PresentationPageMenu,
|
||||
QuickActions: PresentationQuickActions,
|
||||
StylePanel: PresentationStylePanel,
|
||||
ZoomMenu: PresentationZoomMenu,
|
||||
KeyboardShortcutsDialog: PresentationKeyboardShortcutsDialog,
|
||||
ActionsMenu: PresentationActionsMenu,
|
||||
ContextMenu: PresentationContextMenu,
|
||||
DebugMenu: PresentationDebugMenu,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import {
|
||||
DefaultActionsMenu,
|
||||
DefaultActionsMenuContent,
|
||||
} from '@tldraw/tldraw';
|
||||
|
||||
export const PresentationActionsMenu = () => {
|
||||
return (
|
||||
<DefaultActionsMenu>
|
||||
<DefaultActionsMenuContent />
|
||||
</DefaultActionsMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
DefaultContextMenu,
|
||||
DefaultContextMenuContent,
|
||||
TLUiContextMenuProps,
|
||||
} from '@tldraw/tldraw';
|
||||
|
||||
export const PresentationContextMenu = (props: TLUiContextMenuProps) => {
|
||||
return (
|
||||
<div>
|
||||
<DefaultContextMenu {...props}>
|
||||
<DefaultContextMenuContent />
|
||||
</DefaultContextMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
DefaultDebugMenu,
|
||||
DefaultDebugMenuContent,
|
||||
TLUiDebugMenuProps,
|
||||
} from '@tldraw/tldraw';
|
||||
|
||||
export const PresentationDebugMenu = (props: TLUiDebugMenuProps) => {
|
||||
return (
|
||||
<div>
|
||||
<DefaultDebugMenu {...props}>
|
||||
<DefaultDebugMenuContent />
|
||||
</DefaultDebugMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
DefaultHelpMenu,
|
||||
DefaultHelpMenuContent,
|
||||
TLUiHelpMenuProps,
|
||||
} from '@tldraw/tldraw';
|
||||
|
||||
export const PresentationHelpMenu = (props: TLUiHelpMenuProps) => {
|
||||
return (
|
||||
<div>
|
||||
<DefaultHelpMenu {...props}>
|
||||
<DefaultHelpMenuContent />
|
||||
</DefaultHelpMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
DefaultHelperButtons,
|
||||
DefaultHelperButtonsContent,
|
||||
TLUiHelperButtonsProps,
|
||||
} from '@tldraw/tldraw';
|
||||
|
||||
export const PresentationHelperButtons = (props: TLUiHelperButtonsProps) => {
|
||||
return (
|
||||
<div>
|
||||
<DefaultHelperButtons {...props}>
|
||||
<DefaultHelperButtonsContent />
|
||||
</DefaultHelperButtons>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
DefaultKeyboardShortcutsDialog,
|
||||
DefaultKeyboardShortcutsDialogContent,
|
||||
TldrawUiMenuItem,
|
||||
useTools,
|
||||
} from '@tldraw/tldraw';
|
||||
|
||||
export const PresentationKeyboardShortcutsDialog = () => {
|
||||
const tools = useTools();
|
||||
|
||||
return (
|
||||
<DefaultKeyboardShortcutsDialog onClose={() => {}}>
|
||||
<div style={{ backgroundColor: 'white' }}>
|
||||
<TldrawUiMenuItem {...tools['microphone']} />
|
||||
<TldrawUiMenuItem {...tools['cc-slide']} />
|
||||
<TldrawUiMenuItem {...tools['sticker']} />
|
||||
</div>
|
||||
<DefaultKeyboardShortcutsDialogContent />
|
||||
</DefaultKeyboardShortcutsDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
DefaultMainMenu,
|
||||
DefaultMainMenuContent,
|
||||
TLUiMainMenuProps,
|
||||
} from '@tldraw/tldraw';
|
||||
|
||||
export const PresentationMainMenu = (props: TLUiMainMenuProps) => {
|
||||
return (
|
||||
<div>
|
||||
{/* <DefaultMainMenu {...props}>
|
||||
<DefaultMainMenuContent />
|
||||
</DefaultMainMenu> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import {
|
||||
DefaultNavigationPanel,
|
||||
} from '@tldraw/tldraw';
|
||||
|
||||
export const PresentationNavigationPanel = () => {
|
||||
return (
|
||||
<div>
|
||||
{/* <DefaultNavigationPanel/> */}
|
||||
</div>
|
||||
)
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import {
|
||||
DefaultPageMenu,
|
||||
} from '@tldraw/tldraw';
|
||||
|
||||
export const PresentationPageMenu = () => {
|
||||
return (
|
||||
<div>
|
||||
{/* <DefaultPageMenu/> */}
|
||||
</div>
|
||||
)
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
DefaultQuickActions,
|
||||
DefaultQuickActionsContent,
|
||||
TLUiQuickActionsProps,
|
||||
} from '@tldraw/tldraw';
|
||||
|
||||
export const PresentationQuickActions = (props: TLUiQuickActionsProps) => {
|
||||
return (
|
||||
<div>
|
||||
{/* <DefaultQuickActions {...props}>
|
||||
<DefaultQuickActionsContent />
|
||||
</DefaultQuickActions> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
DefaultStylePanel,
|
||||
TLUiStylePanelProps,
|
||||
TldrawUiButton
|
||||
} from '@tldraw/tldraw';
|
||||
|
||||
import { SnapshotToolbar } from '../../toolbars/snapshotToolbar';
|
||||
import { UserToolbar } from '../../toolbars/userToolbar';
|
||||
import { ToolsToolbar } from '../../toolbars/toolsToolbar';
|
||||
|
||||
export const PresentationStylePanel = (props: TLUiStylePanelProps) => {
|
||||
const emojiButtonStyle = {
|
||||
fontSize: '22px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
};
|
||||
|
||||
const toolbarStyle = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
padding: '0 8px',
|
||||
};
|
||||
|
||||
return (
|
||||
<DefaultStylePanel {...props}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0px',
|
||||
padding: '0px',
|
||||
position: 'relative',
|
||||
top: '0px',
|
||||
left: '0',
|
||||
zIndex: 1000,
|
||||
background: 'var(--color-panel)',
|
||||
borderRadius: 'var(--radius-2)',
|
||||
boxShadow: 'var(--shadow-2)',
|
||||
width: '100%',
|
||||
}}>
|
||||
<UserToolbar>
|
||||
{({ handleLogout, handleNavUserHome }) => (
|
||||
<div style={toolbarStyle}>
|
||||
<TldrawUiButton type="icon" title="Logout" onClick={handleLogout} style={emojiButtonStyle}>
|
||||
🚪
|
||||
</TldrawUiButton>
|
||||
<TldrawUiButton type="icon" title="Home" onClick={handleNavUserHome} style={emojiButtonStyle}>
|
||||
🏠
|
||||
</TldrawUiButton>
|
||||
</div>
|
||||
)}
|
||||
</UserToolbar>
|
||||
<SnapshotToolbar>
|
||||
{({ resetToBlankCanvas }) => (
|
||||
<div style={toolbarStyle}>
|
||||
<TldrawUiButton type="icon" title="Reset Canvas" onClick={resetToBlankCanvas} style={emojiButtonStyle}>
|
||||
🔄
|
||||
</TldrawUiButton>
|
||||
</div>
|
||||
)}
|
||||
</SnapshotToolbar>
|
||||
<ToolsToolbar>
|
||||
{({ handlePutUserNode, handleAddCalendar: toolbarHandleAddCalendar }) => (
|
||||
<div style={toolbarStyle}>
|
||||
<TldrawUiButton type="icon" title="Add User Node" onClick={handlePutUserNode} style={emojiButtonStyle}>
|
||||
👤
|
||||
</TldrawUiButton>
|
||||
<TldrawUiButton type="icon" title="Add Calendar" onClick={toolbarHandleAddCalendar} style={emojiButtonStyle}>
|
||||
📅
|
||||
</TldrawUiButton>
|
||||
</div>
|
||||
)}
|
||||
</ToolsToolbar>
|
||||
</div>
|
||||
</DefaultStylePanel>
|
||||
)
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
DefaultToolbar,
|
||||
TldrawUiMenuItem,
|
||||
} from '@tldraw/tldraw';
|
||||
import { StickerDropdown } from '../../StickerDropdown';
|
||||
|
||||
export const PresentationToolbar = () => {
|
||||
const [isStickerSelected, setIsStickerSelected] = useState(false);
|
||||
|
||||
const handleStickerSelect = () => {
|
||||
setIsStickerSelected(!isStickerSelected);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DefaultToolbar>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<TldrawUiMenuItem
|
||||
id="sticker"
|
||||
label="Sticker"
|
||||
icon="sticker-icon"
|
||||
onSelect={handleStickerSelect}
|
||||
isSelected={isStickerSelected}
|
||||
/>
|
||||
{isStickerSelected && <StickerDropdown />}
|
||||
</div>
|
||||
</DefaultToolbar>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
DefaultZoomMenu,
|
||||
DefaultZoomMenuContent,
|
||||
TLUiZoomMenuProps,
|
||||
} from '@tldraw/tldraw';
|
||||
|
||||
export const PresentationZoomMenu = (props: TLUiZoomMenuProps) => {
|
||||
return (
|
||||
<div>
|
||||
{/* <DefaultZoomMenu {...props}>
|
||||
<DefaultZoomMenuContent />
|
||||
</DefaultZoomMenu> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import {
|
||||
DefaultActionsMenu,
|
||||
DefaultActionsMenuContent,
|
||||
} from '@tldraw/tldraw';
|
||||
|
||||
export const RegularActionsMenu = () => {
|
||||
return (
|
||||
<DefaultActionsMenu>
|
||||
<DefaultActionsMenuContent />
|
||||
</DefaultActionsMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
DefaultContextMenu,
|
||||
DefaultContextMenuContent,
|
||||
TLUiContextMenuProps,
|
||||
} from '@tldraw/tldraw';
|
||||
|
||||
export const RegularContextMenu = (props: TLUiContextMenuProps) => {
|
||||
return (
|
||||
<DefaultContextMenu {...props}>
|
||||
<DefaultContextMenuContent />
|
||||
</DefaultContextMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
DefaultDebugMenu,
|
||||
DefaultDebugMenuContent,
|
||||
TLUiDebugMenuProps,
|
||||
} from '@tldraw/tldraw';
|
||||
|
||||
export const RegularDebugMenu = (props: TLUiDebugMenuProps) => {
|
||||
return (
|
||||
<DefaultDebugMenu {...props}>
|
||||
<DefaultDebugMenuContent />
|
||||
</DefaultDebugMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
DefaultHelpMenu,
|
||||
DefaultHelpMenuContent,
|
||||
TLUiHelpMenuProps,
|
||||
} from '@tldraw/tldraw';
|
||||
|
||||
export const RegularHelpMenu = (props: TLUiHelpMenuProps) => {
|
||||
return (
|
||||
<DefaultHelpMenu {...props}>
|
||||
<DefaultHelpMenuContent />
|
||||
</DefaultHelpMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
DefaultHelperButtons,
|
||||
DefaultHelperButtonsContent,
|
||||
TLUiHelperButtonsProps,
|
||||
} from '@tldraw/tldraw';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
export const RegularHelperButtons = (props: TLUiHelperButtonsProps) => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: '80px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 200,
|
||||
pointerEvents: 'none',
|
||||
'& > *': {
|
||||
pointerEvents: 'all'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DefaultHelperButtons {...props}>
|
||||
<DefaultHelperButtonsContent />
|
||||
</DefaultHelperButtons>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
DefaultKeyboardShortcutsDialog,
|
||||
DefaultKeyboardShortcutsDialogContent,
|
||||
TldrawUiMenuItem,
|
||||
useTools,
|
||||
} from '@tldraw/tldraw';
|
||||
|
||||
export const RegularKeyboardShortcutsDialog = () => {
|
||||
const tools = useTools();
|
||||
return (
|
||||
<DefaultKeyboardShortcutsDialog onClose={() => {}}>
|
||||
<div style={{ backgroundColor: 'thistle' }}>
|
||||
<TldrawUiMenuItem {...tools['microphone']} />
|
||||
<TldrawUiMenuItem {...tools['cc-slide']} />
|
||||
<TldrawUiMenuItem {...tools['sticker']} />
|
||||
</div>
|
||||
<DefaultKeyboardShortcutsDialogContent />
|
||||
</DefaultKeyboardShortcutsDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
DefaultMainMenu,
|
||||
DefaultMainMenuContent,
|
||||
TLUiMainMenuProps,
|
||||
} from '@tldraw/tldraw';
|
||||
|
||||
export const RegularMainMenu = (props: TLUiMainMenuProps) => {
|
||||
return (
|
||||
<DefaultMainMenu {...props}>
|
||||
<DefaultMainMenuContent />
|
||||
</DefaultMainMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import {
|
||||
DefaultNavigationPanel,
|
||||
} from '@tldraw/tldraw';
|
||||
|
||||
export const RegularNavigationPanel = () => {
|
||||
return (
|
||||
<DefaultNavigationPanel/>
|
||||
)
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import {
|
||||
DefaultPageMenu,
|
||||
} from '@tldraw/tldraw';
|
||||
|
||||
export const RegularPageMenu = () => {
|
||||
return (
|
||||
<DefaultPageMenu/>
|
||||
)
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
DefaultQuickActions,
|
||||
DefaultQuickActionsContent,
|
||||
TLUiQuickActionsProps,
|
||||
} from '@tldraw/tldraw';
|
||||
|
||||
export const RegularQuickActions = (props: TLUiQuickActionsProps) => {
|
||||
return (
|
||||
<DefaultQuickActions {...props}>
|
||||
<DefaultQuickActionsContent />
|
||||
</DefaultQuickActions>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
DefaultStylePanel,
|
||||
DefaultStylePanelContent,
|
||||
TLUiStylePanelProps,
|
||||
useRelevantStyles
|
||||
} from '@tldraw/tldraw';
|
||||
|
||||
export const RegularStylePanel = (props: TLUiStylePanelProps) => {
|
||||
return (
|
||||
<DefaultStylePanel {...props}>
|
||||
<DefaultStylePanelContent styles={useRelevantStyles()} />
|
||||
</DefaultStylePanel>
|
||||
)
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
DefaultToolbar,
|
||||
TldrawUiMenuItem,
|
||||
DefaultToolbarContent
|
||||
} from '@tldraw/tldraw';
|
||||
import { StickerDropdown } from '../../StickerDropdown';
|
||||
|
||||
export const RegularToolbar = () => {
|
||||
const [isStickerSelected, setIsStickerSelected] = useState(false)
|
||||
const handleStickerSelect = () => {
|
||||
setIsStickerSelected(!isStickerSelected)
|
||||
}
|
||||
|
||||
return (
|
||||
<DefaultToolbar>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<TldrawUiMenuItem
|
||||
id="sticker"
|
||||
label="Sticker"
|
||||
icon="sticker-icon"
|
||||
onSelect={handleStickerSelect}
|
||||
isSelected={isStickerSelected}
|
||||
/>
|
||||
{isStickerSelected && <StickerDropdown />}
|
||||
</div>
|
||||
<DefaultToolbarContent />
|
||||
</DefaultToolbar>
|
||||
)
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
DefaultZoomMenu,
|
||||
DefaultZoomMenuContent,
|
||||
TLUiZoomMenuProps,
|
||||
} from '@tldraw/tldraw';
|
||||
|
||||
export const RegularZoomMenu = (props: TLUiZoomMenuProps) => {
|
||||
return (
|
||||
<DefaultZoomMenu {...props}>
|
||||
<DefaultZoomMenuContent />
|
||||
</DefaultZoomMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,375 @@
|
||||
import React, { useEffect, useRef, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { TldrawUiButton } from '@tldraw/tldraw';
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
styled,
|
||||
ThemeProvider,
|
||||
createTheme,
|
||||
useMediaQuery
|
||||
} from '@mui/material';
|
||||
import {
|
||||
PushPin as PushPinIcon,
|
||||
PushPinOutlined as PushPinOutlinedIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Category as ShapesIcon,
|
||||
Slideshow as SlidesIcon,
|
||||
YouTube as YouTubeIcon,
|
||||
AccountTree as GraphIcon,
|
||||
Search as SearchIcon,
|
||||
Navigation as NavigationIcon,
|
||||
Save as NodeIcon,
|
||||
Assignment as ExamIcon
|
||||
} from '@mui/icons-material';
|
||||
import { CCShapesPanel } from './CCShapesPanel';
|
||||
import { CCSlidesPanel } from './CCSlidesPanel';
|
||||
import { CCYoutubePanel } from './CCYoutubePanel';
|
||||
import { CCGraphPanel } from './CCGraphPanel';
|
||||
import { CCExamMarkerPanel } from './CCExamMarkerPanel';
|
||||
import { CCSearchPanel } from './CCSearchPanel'
|
||||
import { PANEL_DIMENSIONS, Z_INDICES } from './panel-styles';
|
||||
import './panel.css';
|
||||
// import { CCNavigationPanel } from './navigation/CCNavigationPanel';
|
||||
import { BaseContext, ViewContext } from '../../../../../types/navigation';
|
||||
// import { CCNodeSnapshotPanel } from './navigation/CCNodeSnapshotPanel';
|
||||
import { useTLDraw } from '../../../../../contexts/TLDrawContext';
|
||||
|
||||
export const PANEL_TYPES = {
|
||||
default: [
|
||||
{ id: 'navigation', label: 'Navigation', order: 10 },
|
||||
{ id: 'node-snapshot', label: 'Node', order: 20 },
|
||||
{ id: 'cc-shapes', label: 'Shapes', order: 30 },
|
||||
{ id: 'slides', label: 'Slides', order: 40 },
|
||||
{ id: 'youtube', label: 'YouTube', order: 50 },
|
||||
{ id: 'graph', label: 'Graph', order: 60 },
|
||||
{ id: 'search', label: 'Search', order: 70 },
|
||||
],
|
||||
examMarker: [
|
||||
{ id: 'exam-marker', label: 'Exam Marker', order: 10 },
|
||||
],
|
||||
} as const;
|
||||
|
||||
export type PanelType = typeof PANEL_TYPES.default[number]['id'] | typeof PANEL_TYPES.examMarker[number]['id'];
|
||||
|
||||
interface BasePanelProps {
|
||||
initialPanelType?: PanelType;
|
||||
examMarkerProps?: React.ComponentProps<typeof CCExamMarkerPanel>;
|
||||
isExpanded?: boolean;
|
||||
isPinned?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
onPinnedChange?: (pinned: boolean) => void;
|
||||
currentContext?: BaseContext;
|
||||
onContextChange?: (context: BaseContext) => void;
|
||||
currentExtendedContext?: ViewContext;
|
||||
onExtendedContextChange?: (context: ViewContext) => void;
|
||||
isMenuOpen?: boolean;
|
||||
onMenuOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const PanelTypeButton = styled(Button)(() => ({
|
||||
textTransform: 'none',
|
||||
padding: '6px 12px',
|
||||
gap: '8px',
|
||||
backgroundColor: 'var(--color-panel)',
|
||||
color: 'var(--color-text)',
|
||||
border: '1px solid transparent',
|
||||
transition: 'border-color 200ms ease',
|
||||
justifyContent: 'space-between',
|
||||
minWidth: '200px',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--color-panel)',
|
||||
borderColor: 'var(--color-text)',
|
||||
},
|
||||
'& .MuiSvgIcon-root': {
|
||||
fontSize: '1.25rem',
|
||||
color: 'inherit',
|
||||
}
|
||||
}));
|
||||
|
||||
const StyledMenuItem = styled(MenuItem)(() => ({
|
||||
gap: '8px',
|
||||
padding: '8px 16px',
|
||||
transition: 'background-color 200ms ease',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--color-hover)',
|
||||
'& .MuiListItemIcon-root': {
|
||||
color: 'var(--color-selected)',
|
||||
}
|
||||
},
|
||||
'& .MuiListItemIcon-root': {
|
||||
color: 'var(--color-text)',
|
||||
minWidth: '32px',
|
||||
transition: 'color 200ms ease',
|
||||
'& .MuiSvgIcon-root': {
|
||||
fontSize: '1.25rem',
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
export const BasePanel: React.FC<BasePanelProps> = ({
|
||||
initialPanelType = 'cc-shapes',
|
||||
examMarkerProps,
|
||||
isExpanded: controlledIsExpanded,
|
||||
isPinned: controlledIsPinned,
|
||||
onExpandedChange,
|
||||
onPinnedChange,
|
||||
isMenuOpen = false,
|
||||
onMenuOpenChange = () => {},
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const { tldrawPreferences } = useTLDraw();
|
||||
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
|
||||
const [menuAnchorEl, setMenuAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
|
||||
// Create a dynamic theme based on TLDraw preferences
|
||||
const theme = useMemo(() => {
|
||||
let mode: 'light' | 'dark';
|
||||
|
||||
if (tldrawPreferences?.colorScheme === 'system') {
|
||||
mode = prefersDarkMode ? 'dark' : 'light';
|
||||
} else {
|
||||
mode = tldrawPreferences?.colorScheme === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode,
|
||||
divider: 'var(--color-divider)',
|
||||
},
|
||||
});
|
||||
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
|
||||
|
||||
const isExamMarkerRoute = location.pathname === '/exam-marker';
|
||||
const availablePanels = isExamMarkerRoute ? PANEL_TYPES.examMarker : PANEL_TYPES.default;
|
||||
|
||||
const [currentPanelType, setCurrentPanelType] = React.useState<PanelType>(
|
||||
isExamMarkerRoute ? 'exam-marker' : initialPanelType
|
||||
);
|
||||
|
||||
// Use controlled state if provided, otherwise use internal state
|
||||
const [internalIsExpanded, setInternalIsExpanded] = React.useState(false);
|
||||
const [internalIsPinned, setInternalIsPinned] = React.useState(false);
|
||||
|
||||
const isExpanded = controlledIsExpanded ?? internalIsExpanded;
|
||||
const isPinned = controlledIsPinned ?? internalIsPinned;
|
||||
|
||||
const handleExpandedChange = (expanded: boolean) => {
|
||||
setInternalIsExpanded(expanded);
|
||||
onExpandedChange?.(expanded);
|
||||
};
|
||||
|
||||
const handlePinToggle = () => {
|
||||
const newPinned = !isPinned;
|
||||
setInternalIsPinned(newPinned);
|
||||
onPinnedChange?.(newPinned);
|
||||
};
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const dimensions = PANEL_DIMENSIONS[currentPanelType as keyof typeof PANEL_DIMENSIONS];
|
||||
|
||||
// Handle click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
// Don't close if pinned
|
||||
if (isPinned) return;
|
||||
|
||||
// Check if click is outside panel
|
||||
const isClickOutside = panelRef.current && !panelRef.current.contains(event.target as Node);
|
||||
|
||||
// Check if click is not on a panel-related element
|
||||
const target = event.target as HTMLElement;
|
||||
const isPanelElement = target.closest('.panel-root, .panel-handle, .tlui-button');
|
||||
|
||||
if (isClickOutside && !isPanelElement) {
|
||||
handleExpandedChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isExpanded) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isExpanded, isPinned]);
|
||||
|
||||
const getIconForPanel = (panelId: PanelType) => {
|
||||
switch (panelId) {
|
||||
case 'cc-shapes':
|
||||
return <ShapesIcon />;
|
||||
case 'slides':
|
||||
return <SlidesIcon />;
|
||||
case 'youtube':
|
||||
return <YouTubeIcon />;
|
||||
case 'graph':
|
||||
return <GraphIcon />;
|
||||
case 'search':
|
||||
return <SearchIcon />;
|
||||
case 'navigation':
|
||||
return <NavigationIcon />;
|
||||
case 'node-snapshot':
|
||||
return <NodeIcon />;
|
||||
case 'exam-marker':
|
||||
return <ExamIcon />;
|
||||
default:
|
||||
return <ShapesIcon />;
|
||||
}
|
||||
};
|
||||
|
||||
const getDescriptionForPanel = (panelId: PanelType) => {
|
||||
switch (panelId) {
|
||||
case 'cc-shapes':
|
||||
return 'Add shapes and elements to your canvas';
|
||||
case 'slides':
|
||||
return 'Manage presentation slides';
|
||||
case 'youtube':
|
||||
return 'Embed YouTube videos';
|
||||
case 'graph':
|
||||
return 'View and manage graph connections';
|
||||
case 'search':
|
||||
return 'Search through your content';
|
||||
case 'navigation':
|
||||
return 'Navigate through different contexts';
|
||||
case 'node-snapshot':
|
||||
return 'Manage node snapshots';
|
||||
case 'exam-marker':
|
||||
return 'Mark and grade exams';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const renderCurrentPanel = () => {
|
||||
if (isExamMarkerRoute && currentPanelType === 'exam-marker') {
|
||||
return examMarkerProps ? <CCExamMarkerPanel {...examMarkerProps} /> : null;
|
||||
}
|
||||
|
||||
switch (currentPanelType) {
|
||||
case 'cc-shapes':
|
||||
return <CCShapesPanel />;
|
||||
case 'slides':
|
||||
return <CCSlidesPanel />;
|
||||
case 'youtube':
|
||||
return <CCYoutubePanel />;
|
||||
case 'graph':
|
||||
return <CCGraphPanel />;
|
||||
case 'search':
|
||||
return <CCSearchPanel />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle menu button click
|
||||
const handleMenuClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setMenuAnchorEl(event.currentTarget);
|
||||
onMenuOpenChange(true);
|
||||
};
|
||||
|
||||
// Handle menu close
|
||||
const handleMenuClose = () => {
|
||||
setMenuAnchorEl(null);
|
||||
onMenuOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isExpanded && (
|
||||
<div
|
||||
className="panel-handle"
|
||||
onClick={() => handleExpandedChange(true)}
|
||||
onTouchEnd={(e) => {
|
||||
e.stopPropagation();
|
||||
handleExpandedChange(true);
|
||||
}}
|
||||
>
|
||||
›
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExpanded && (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="panel-root"
|
||||
style={{
|
||||
top: dimensions.topOffset,
|
||||
height: `calc(100% - ${dimensions.bottomOffset})`,
|
||||
width: dimensions.width,
|
||||
zIndex: Z_INDICES.PANEL,
|
||||
}}
|
||||
>
|
||||
<div className="panel-header">
|
||||
<ThemeProvider theme={theme}>
|
||||
<PanelTypeButton
|
||||
onClick={handleMenuClick}
|
||||
endIcon={<ExpandMoreIcon />}
|
||||
startIcon={getIconForPanel(currentPanelType)}
|
||||
>
|
||||
{availablePanels.find(p => p.id === currentPanelType)?.label}
|
||||
</PanelTypeButton>
|
||||
|
||||
<Menu
|
||||
anchorEl={menuAnchorEl}
|
||||
open={isMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
PaperProps={{
|
||||
elevation: 8,
|
||||
sx: {
|
||||
border: '1px solid var(--color-divider)',
|
||||
boxShadow: 'var(--shadow-popup)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{[...availablePanels]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(type => (
|
||||
<StyledMenuItem
|
||||
key={type.id}
|
||||
onClick={() => {
|
||||
setCurrentPanelType(type.id as PanelType);
|
||||
handleMenuClose();
|
||||
}}
|
||||
selected={currentPanelType === type.id}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{getIconForPanel(type.id as PanelType)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={type.label}
|
||||
secondary={getDescriptionForPanel(type.id as PanelType)}
|
||||
primaryTypographyProps={{
|
||||
sx: { color: 'var(--color-text)' }
|
||||
}}
|
||||
secondaryTypographyProps={{
|
||||
sx: { color: 'var(--color-text-secondary)' }
|
||||
}}
|
||||
/>
|
||||
</StyledMenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</ThemeProvider>
|
||||
|
||||
<div className="panel-header-actions">
|
||||
<TldrawUiButton
|
||||
type="icon"
|
||||
onClick={handlePinToggle}
|
||||
className="pin-button"
|
||||
>
|
||||
{isPinned ? <PushPinIcon /> : <PushPinOutlinedIcon />}
|
||||
</TldrawUiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-content">
|
||||
{renderCurrentPanel()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,495 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Button, Typography, Divider, Stack } from '@mui/material';
|
||||
import { Editor, exportToBlob, TLPageId, Box as TLBox } from '@tldraw/tldraw';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { Pdf } from '../../../../../pages/tldraw/CCExamMarker/types';
|
||||
import { logger } from '../../../../../debugConfig';
|
||||
|
||||
interface CCExamMarkerPanelProps {
|
||||
editor: Editor | null;
|
||||
currentView: 'exam-and-markscheme' | 'student-responses';
|
||||
onViewChange: (view: 'exam-and-markscheme' | 'student-responses') => void;
|
||||
currentStudentIndex: number;
|
||||
totalStudents: number;
|
||||
onPreviousStudent: () => void;
|
||||
onNextStudent: () => void;
|
||||
getCurrentPdf: () => Pdf | null;
|
||||
}
|
||||
|
||||
export const CCExamMarkerPanel: React.FC<CCExamMarkerPanelProps> = ({
|
||||
editor,
|
||||
currentView,
|
||||
onViewChange,
|
||||
currentStudentIndex,
|
||||
totalStudents,
|
||||
onPreviousStudent,
|
||||
onNextStudent,
|
||||
getCurrentPdf,
|
||||
}) => {
|
||||
const [exportProgress, setExportProgress] = useState<number | null>(null);
|
||||
|
||||
const exportPdf = async (
|
||||
editor: Editor,
|
||||
{ name, source, pages }: Pdf,
|
||||
onProgress: (progress: number) => void,
|
||||
startPage?: number,
|
||||
endPage?: number,
|
||||
studentIndex?: number
|
||||
) => {
|
||||
logger.debug('cc-exam-marker', '📤 Starting PDF export', {
|
||||
name,
|
||||
startPage,
|
||||
endPage,
|
||||
studentIndex,
|
||||
currentView,
|
||||
totalPages: pages.length
|
||||
});
|
||||
|
||||
const pdfPages = pages.slice(startPage, endPage);
|
||||
logger.debug('cc-exam-marker', '📄 Selected pages for export', {
|
||||
pdfPages: pdfPages.length,
|
||||
pageIndices: pdfPages.map((_, i) => (startPage || 0) + i)
|
||||
});
|
||||
|
||||
const totalThings = pdfPages.length * 2 + 2;
|
||||
let progressCount = 0;
|
||||
const tickProgress = () => {
|
||||
progressCount++;
|
||||
onProgress(progressCount / totalThings);
|
||||
};
|
||||
|
||||
const sourcePdf = await PDFDocument.load(source);
|
||||
tickProgress();
|
||||
|
||||
// Create a new PDF document for the selected pages
|
||||
const newPdf = await PDFDocument.create();
|
||||
|
||||
// Copy pages from source PDF
|
||||
const pageIndices = pdfPages.map((_, i) => (startPage || 0) + i);
|
||||
const copiedPages = await newPdf.copyPages(sourcePdf, pageIndices);
|
||||
copiedPages.forEach(page => newPdf.addPage(page));
|
||||
tickProgress();
|
||||
|
||||
// Store current page to restore later
|
||||
const currentPageId = editor.getCurrentPageId();
|
||||
logger.debug('cc-exam-marker', '📍 Current page before export', { currentPageId });
|
||||
|
||||
// Switch to the correct page based on context
|
||||
const targetPageId = (studentIndex !== undefined
|
||||
? `page:student-response-${studentIndex}`
|
||||
: currentView === 'exam-and-markscheme'
|
||||
? 'page:exam-page'
|
||||
: 'page:mark-scheme-page') as TLPageId;
|
||||
|
||||
logger.debug('cc-exam-marker', '🎯 Switching to target page', { targetPageId });
|
||||
editor.setCurrentPage(targetPageId);
|
||||
|
||||
// Get all shape IDs that are not page shapes (i.e., annotations)
|
||||
const pageShapeIds = new Set(pages.map(page => page.shapeId));
|
||||
const allShapeIds = Array.from(editor.getCurrentPageShapeIds()).filter(id => !pageShapeIds.has(id));
|
||||
|
||||
logger.debug('cc-exam-marker', '📝 Found shapes on current page', {
|
||||
totalShapes: editor.getCurrentPageShapeIds().size,
|
||||
pageShapes: pageShapeIds.size,
|
||||
annotationShapes: allShapeIds.length
|
||||
});
|
||||
|
||||
// For each page, draw annotations on top
|
||||
for (let i = 0; i < pdfPages.length; i++) {
|
||||
const page = pdfPages[i];
|
||||
const pdfPage = newPdf.getPages()[i];
|
||||
const {bounds} = page;
|
||||
|
||||
logger.debug('cc-exam-marker', `📄 Processing page ${i + 1}/${pdfPages.length}`, {
|
||||
bounds,
|
||||
pageIndex: i,
|
||||
globalPageIndex: (startPage || 0) + i
|
||||
});
|
||||
|
||||
// Get shapes that intersect with this page using editor's bounds checking
|
||||
const shapesInBounds = allShapeIds.filter((id) => {
|
||||
const shape = editor.getShape(id);
|
||||
if (!shape || shape.isLocked) return false;
|
||||
|
||||
// @ts-expect-error - annotationManager is added to editor in CCPdfEditor
|
||||
const annotationManager = editor.annotationManager;
|
||||
const annotationData = annotationManager.getAnnotationData(id);
|
||||
if (!annotationData) return false;
|
||||
|
||||
// Filter by student index if provided
|
||||
if (studentIndex !== undefined && annotationData.studentIndex !== studentIndex) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For exam/markscheme view, only include those annotations
|
||||
if (studentIndex === undefined && annotationData.studentIndex !== undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For individual student exports, use the annotation's original page index
|
||||
// For full exports, use the stored page index
|
||||
const adjustedPageIndex = annotationData.pageIndex;
|
||||
|
||||
// Check if this shape belongs to this page index
|
||||
if (adjustedPageIndex !== i) {
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.debug('cc-exam-marker', `🔍 Found matching annotation`, {
|
||||
shapeId: id,
|
||||
annotationData,
|
||||
adjustedPageIndex,
|
||||
currentPageIndex: i,
|
||||
bounds: editor.getShapePageBounds(id)
|
||||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
logger.debug('cc-exam-marker', `✨ Found shapes for page ${i + 1}`, {
|
||||
shapesInBounds: shapesInBounds.length,
|
||||
pageIndex: i,
|
||||
globalPageIndex: (startPage || 0) + i
|
||||
});
|
||||
|
||||
if (shapesInBounds.length === 0) {
|
||||
tickProgress();
|
||||
tickProgress();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Export the annotations as PNG
|
||||
const exportedPng = await exportToBlob({
|
||||
editor,
|
||||
ids: shapesInBounds,
|
||||
format: 'png',
|
||||
opts: {
|
||||
background: false,
|
||||
// Create a new bounds that's relative to the current page
|
||||
bounds: new TLBox(
|
||||
bounds.x,
|
||||
0, // Reset to 0 since we want annotations relative to current page
|
||||
bounds.width,
|
||||
bounds.height
|
||||
),
|
||||
padding: 0,
|
||||
scale: 1
|
||||
},
|
||||
});
|
||||
|
||||
tickProgress();
|
||||
|
||||
// Draw the annotations on the PDF page
|
||||
const pngImage = await newPdf.embedPng(await exportedPng.arrayBuffer());
|
||||
const pdfWidth = pdfPage.getWidth();
|
||||
const pdfHeight = pdfPage.getHeight();
|
||||
|
||||
pdfPage.drawImage(pngImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: pdfWidth,
|
||||
height: pdfHeight,
|
||||
});
|
||||
|
||||
tickProgress();
|
||||
}
|
||||
|
||||
// Restore original page
|
||||
logger.debug('cc-exam-marker', '🔄 Restoring original page', { currentPageId });
|
||||
editor.setCurrentPage(currentPageId);
|
||||
|
||||
const pdfBytes = await newPdf.save();
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([pdfBytes], { type: 'application/pdf' })
|
||||
);
|
||||
tickProgress();
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
logger.debug('cc-exam-marker', '✅ PDF export completed', { name });
|
||||
};
|
||||
|
||||
const handleExportCurrentView = async () => {
|
||||
if (!editor) return;
|
||||
const currentPdf = getCurrentPdf();
|
||||
if (!currentPdf) return;
|
||||
|
||||
setExportProgress(0);
|
||||
try {
|
||||
if (currentView === 'student-responses') {
|
||||
// For student responses, we need to handle each student's annotations separately
|
||||
const pagesPerStudent = currentPdf.pages.length / totalStudents;
|
||||
let currentProgress = 0;
|
||||
|
||||
// Create a new PDF with all pages
|
||||
const sourcePdf = await PDFDocument.load(currentPdf.source);
|
||||
const newPdf = await PDFDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(sourcePdf, Array.from({ length: currentPdf.pages.length }, (_, i) => i));
|
||||
copiedPages.forEach(page => newPdf.addPage(page));
|
||||
|
||||
// For each student, export their annotations onto their pages
|
||||
for (let studentIndex = 0; studentIndex < totalStudents; studentIndex++) {
|
||||
const startPage = studentIndex * pagesPerStudent;
|
||||
|
||||
// Switch to the student's page to get their annotations
|
||||
const targetPageId = `page:student-response-${studentIndex}` as TLPageId;
|
||||
editor.setCurrentPage(targetPageId);
|
||||
|
||||
// Get all annotations for this student
|
||||
const pageShapeIds = new Set(currentPdf.pages.map(page => page.shapeId));
|
||||
const allShapeIds = Array.from(editor.getCurrentPageShapeIds()).filter(id => !pageShapeIds.has(id));
|
||||
|
||||
// Process each page for this student
|
||||
for (let i = 0; i < pagesPerStudent; i++) {
|
||||
const pageIndex = startPage + i;
|
||||
const page = currentPdf.pages[pageIndex];
|
||||
const pdfPage = newPdf.getPages()[pageIndex];
|
||||
|
||||
// Get shapes for this page
|
||||
const shapesInBounds = allShapeIds.filter((id) => {
|
||||
const shape = editor.getShape(id);
|
||||
if (!shape || shape.isLocked) return false;
|
||||
|
||||
// @ts-expect-error - annotationManager is added to editor in CCPdfEditor
|
||||
const annotationManager = editor.annotationManager;
|
||||
const annotationData = annotationManager.getAnnotationData(id);
|
||||
if (!annotationData) return false;
|
||||
|
||||
return annotationData.studentIndex === studentIndex && annotationData.pageIndex === i;
|
||||
});
|
||||
|
||||
if (shapesInBounds.length > 0) {
|
||||
// Export and draw annotations
|
||||
const exportedPng = await exportToBlob({
|
||||
editor,
|
||||
ids: shapesInBounds,
|
||||
format: 'png',
|
||||
opts: {
|
||||
background: false,
|
||||
bounds: new TLBox(
|
||||
page.bounds.x,
|
||||
0,
|
||||
page.bounds.width,
|
||||
page.bounds.height
|
||||
),
|
||||
padding: 0,
|
||||
scale: 1
|
||||
},
|
||||
});
|
||||
|
||||
const pngImage = await newPdf.embedPng(await exportedPng.arrayBuffer());
|
||||
pdfPage.drawImage(pngImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: pdfPage.getWidth(),
|
||||
height: pdfPage.getHeight(),
|
||||
});
|
||||
}
|
||||
|
||||
currentProgress++;
|
||||
setExportProgress(currentProgress / (totalStudents * pagesPerStudent));
|
||||
}
|
||||
}
|
||||
|
||||
// Save the combined PDF
|
||||
const pdfBytes = await newPdf.save();
|
||||
const url = URL.createObjectURL(new Blob([pdfBytes], { type: 'application/pdf' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = currentPdf.name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
// For exam/mark scheme view, use the original export logic
|
||||
await exportPdf(editor, currentPdf, setExportProgress);
|
||||
}
|
||||
} finally {
|
||||
setExportProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportCurrentStudent = async () => {
|
||||
if (!editor || currentView !== 'student-responses') return;
|
||||
const currentPdf = getCurrentPdf();
|
||||
if (!currentPdf) return;
|
||||
|
||||
const pagesPerStudent = currentPdf.pages.length / totalStudents;
|
||||
const startPage = currentStudentIndex * pagesPerStudent;
|
||||
const endPage = startPage + pagesPerStudent;
|
||||
|
||||
setExportProgress(0);
|
||||
try {
|
||||
await exportPdf(
|
||||
editor,
|
||||
{
|
||||
...currentPdf,
|
||||
name: `Student_${currentStudentIndex + 1}_Response.pdf`,
|
||||
},
|
||||
setExportProgress,
|
||||
Math.floor(startPage),
|
||||
Math.floor(endPage),
|
||||
currentStudentIndex
|
||||
);
|
||||
} finally {
|
||||
setExportProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchExport = async () => {
|
||||
if (!editor || currentView !== 'student-responses') return;
|
||||
const currentPdf = getCurrentPdf();
|
||||
if (!currentPdf) return;
|
||||
|
||||
setExportProgress(0);
|
||||
try {
|
||||
const pagesPerStudent = currentPdf.pages.length / totalStudents;
|
||||
let currentProgress = 0;
|
||||
|
||||
for (let studentIndex = 0; studentIndex < totalStudents; studentIndex++) {
|
||||
const startPage = studentIndex * pagesPerStudent;
|
||||
const endPage = startPage + pagesPerStudent;
|
||||
|
||||
await exportPdf(
|
||||
editor,
|
||||
{
|
||||
...currentPdf,
|
||||
name: `Student_${studentIndex + 1}_Response.pdf`,
|
||||
},
|
||||
setExportProgress,
|
||||
Math.floor(startPage),
|
||||
Math.floor(endPage),
|
||||
studentIndex
|
||||
);
|
||||
|
||||
currentProgress++;
|
||||
setExportProgress(currentProgress / totalStudents);
|
||||
}
|
||||
} finally {
|
||||
setExportProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
Exam Marker
|
||||
</Typography>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
View Mode
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant={currentView === 'exam-and-markscheme' ? 'contained' : 'outlined'}
|
||||
onClick={() => onViewChange('exam-and-markscheme')}
|
||||
>
|
||||
Exam & Mark Scheme
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant={currentView === 'student-responses' ? 'contained' : 'outlined'}
|
||||
onClick={() => onViewChange('student-responses')}
|
||||
>
|
||||
Student Responses
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{currentView === 'student-responses' && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
Student Navigation
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
onClick={onPreviousStudent}
|
||||
disabled={currentStudentIndex === 0}
|
||||
>
|
||||
Previous Student
|
||||
</Button>
|
||||
<Typography variant="body2" align="center">
|
||||
Student {currentStudentIndex + 1} of {totalStudents}
|
||||
</Typography>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
onClick={onNextStudent}
|
||||
disabled={currentStudentIndex === totalStudents - 1}
|
||||
>
|
||||
Next Student
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
Actions
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={!editor || !getCurrentPdf() || exportProgress !== null}
|
||||
onClick={handleExportCurrentView}
|
||||
>
|
||||
{exportProgress !== null
|
||||
? `Exporting... ${Math.round(exportProgress * 100)}%`
|
||||
: `Export ${currentView === 'exam-and-markscheme' ? 'All' : 'Combined Responses'}`}
|
||||
</Button>
|
||||
|
||||
{currentView === 'student-responses' && (
|
||||
<>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
disabled={!editor || !getCurrentPdf() || exportProgress !== null}
|
||||
onClick={handleExportCurrentStudent}
|
||||
>
|
||||
{exportProgress !== null
|
||||
? `Exporting... ${Math.round(exportProgress * 100)}%`
|
||||
: 'Export Current Student'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
disabled={!editor || !getCurrentPdf() || exportProgress !== null}
|
||||
onClick={handleBatchExport}
|
||||
>
|
||||
{exportProgress !== null
|
||||
? `Exporting... ${Math.round(exportProgress * 100)}%`
|
||||
: 'Export All as Separate Files'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 'auto' }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
Statistics
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Total Pages: {getCurrentPdf()?.pages.length || 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react';
|
||||
import { useEditor } from '@tldraw/tldraw';
|
||||
import { createGraphShape, createUserNodeFromProfile } from '../../../cc-base/shape-helpers/graph-helpers';
|
||||
import { ccGraphShapeProps, getDefaultCCUserNodeProps } from '../../../cc-base/cc-graph/cc-graph-props';
|
||||
import { useNeoUser } from '../../../../../contexts/NeoUserContext';
|
||||
import { logger } from '../../../../../debugConfig';
|
||||
import './panel.css';
|
||||
import { GraphShapeType } from '../../../cc-base/cc-graph/cc-graph-types';
|
||||
|
||||
export const CCGraphPanel = () => {
|
||||
const editor = useEditor();
|
||||
const { userNode } = useNeoUser();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const graphShapeTypes = Object.keys(ccGraphShapeProps);
|
||||
|
||||
const handleShapeSelect = (shapeType: GraphShapeType) => {
|
||||
if (shapeType === 'cc-user-node') {
|
||||
if (!userNode) {
|
||||
logger.warn('graph-panel', '⚠️ Cannot create user node - no user data available')
|
||||
return;
|
||||
}
|
||||
const defaultProps = getDefaultCCUserNodeProps();
|
||||
createUserNodeFromProfile(editor, {
|
||||
...defaultProps,
|
||||
...userNode
|
||||
});
|
||||
} else {
|
||||
createGraphShape(editor, shapeType);
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel-container">
|
||||
<div className="panel-section">
|
||||
<button
|
||||
className="shape-button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
Add Graph Node
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="panel-dropdown">
|
||||
{graphShapeTypes.map((shapeType) => (
|
||||
<button
|
||||
key={shapeType}
|
||||
className="shape-button"
|
||||
onClick={() => handleShapeSelect(shapeType as GraphShapeType)}
|
||||
>
|
||||
{shapeType.replace('cc-', '').replace('-node', '')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,300 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react'
|
||||
import { useEditor } from '@tldraw/tldraw'
|
||||
import {
|
||||
TextField,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Paper,
|
||||
Button,
|
||||
Tabs,
|
||||
Tab,
|
||||
ThemeProvider,
|
||||
createTheme,
|
||||
useMediaQuery,
|
||||
styled
|
||||
} from '@mui/material'
|
||||
import SearchIcon from '@mui/icons-material/Search'
|
||||
import AddBoxIcon from '@mui/icons-material/AddBox'
|
||||
import LanguageIcon from '@mui/icons-material/Language'
|
||||
import { SearchResult } from '../../../../../services/tldraw/searchService'
|
||||
import { createSearchShape } from '../../../cc-base/shape-helpers/search-helpers'
|
||||
import { createWebBrowserShape } from '../../../cc-base/shape-helpers/web-browser-helpers'
|
||||
import { SearchService } from '../../../../../services/tldraw/searchService'
|
||||
import { CCWebBrowserShapeUtil } from '../../../cc-base/cc-web-browser/CCWebBrowserUtil'
|
||||
import { useTLDraw } from '../../../../../contexts/TLDrawContext'
|
||||
|
||||
const StyledTextField = styled(TextField)(() => ({
|
||||
'& .MuiInputBase-root': {
|
||||
backgroundColor: 'var(--color-panel)',
|
||||
color: 'var(--color-text)',
|
||||
'& fieldset': {
|
||||
borderColor: 'var(--color-divider)',
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: 'var(--color-text)',
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: 'var(--color-selected)',
|
||||
},
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
'&::placeholder': {
|
||||
color: 'var(--color-text-secondary)',
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledButton = styled(Button)(() => ({
|
||||
textTransform: 'none',
|
||||
backgroundColor: 'var(--color-panel)',
|
||||
color: 'var(--color-text)',
|
||||
border: '1px solid var(--color-divider)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--color-hover)',
|
||||
borderColor: 'var(--color-text)',
|
||||
},
|
||||
'&.Mui-disabled': {
|
||||
backgroundColor: 'var(--color-muted)',
|
||||
color: 'var(--color-text-disabled)',
|
||||
borderColor: 'var(--color-divider)',
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledIconButton = styled(IconButton)(() => ({
|
||||
color: 'var(--color-text)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--color-hover)',
|
||||
},
|
||||
'&.Mui-disabled': {
|
||||
color: 'var(--color-text-disabled)',
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledPaper = styled(Paper)(() => ({
|
||||
backgroundColor: 'var(--color-panel)',
|
||||
border: '1px solid var(--color-divider)',
|
||||
'& .MuiListItem-root': {
|
||||
borderBottom: '1px solid var(--color-divider)',
|
||||
'&:last-child': {
|
||||
borderBottom: 'none',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledTabs = styled(Tabs)(() => ({
|
||||
'& .MuiTab-root': {
|
||||
color: 'var(--color-text-secondary)',
|
||||
textTransform: 'none',
|
||||
'&.Mui-selected': {
|
||||
color: 'var(--color-selected)',
|
||||
},
|
||||
},
|
||||
'& .MuiTabs-indicator': {
|
||||
backgroundColor: 'var(--color-selected)',
|
||||
},
|
||||
}));
|
||||
|
||||
export const CCSearchPanel: React.FC = () => {
|
||||
const editor = useEditor()
|
||||
const { tldrawPreferences } = useTLDraw()
|
||||
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)')
|
||||
const [query, setQuery] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [activeTab, setActiveTab] = useState(0)
|
||||
|
||||
const theme = useMemo(() => {
|
||||
let mode: 'light' | 'dark';
|
||||
|
||||
if (tldrawPreferences?.colorScheme === 'system') {
|
||||
mode = prefersDarkMode ? 'dark' : 'light';
|
||||
} else {
|
||||
mode = tldrawPreferences?.colorScheme === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode,
|
||||
divider: 'var(--color-divider)',
|
||||
},
|
||||
});
|
||||
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
|
||||
|
||||
const handleSearch = useCallback(async () => {
|
||||
if (!query.trim()) return
|
||||
|
||||
setIsSearching(true)
|
||||
try {
|
||||
const searchResults = await SearchService.search(query)
|
||||
setResults(searchResults)
|
||||
} catch (error) {
|
||||
console.error('Search error:', error)
|
||||
setResults([])
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}, [query])
|
||||
|
||||
const handleCreateSearchShape = useCallback(() => {
|
||||
createSearchShape(editor, { query })
|
||||
}, [editor, query])
|
||||
|
||||
const handleCreateBrowserShape = useCallback(() => {
|
||||
const processedUrl = url.startsWith('http') ? url : `https://${url}`
|
||||
const { isEmbeddable } = CCWebBrowserShapeUtil.isEmbeddableUrl(processedUrl)
|
||||
|
||||
if (isEmbeddable) {
|
||||
editor.createShape({
|
||||
type: 'embed',
|
||||
props: { url: processedUrl },
|
||||
})
|
||||
} else {
|
||||
createWebBrowserShape(editor, { url: processedUrl })
|
||||
}
|
||||
}, [editor, url])
|
||||
|
||||
const handleCreateFromResult = useCallback((result: SearchResult) => {
|
||||
const { isEmbeddable } = CCWebBrowserShapeUtil.isEmbeddableUrl(result.url)
|
||||
|
||||
if (isEmbeddable) {
|
||||
editor.createShape({
|
||||
type: 'embed',
|
||||
props: { url: result.url },
|
||||
})
|
||||
} else {
|
||||
createWebBrowserShape(editor, { url: result.url })
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (activeTab === 0) {
|
||||
handleSearch()
|
||||
} else {
|
||||
handleCreateBrowserShape()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: '8px', gap: '8px' }}>
|
||||
<StyledTabs
|
||||
value={activeTab}
|
||||
onChange={(_, newValue) => setActiveTab(newValue)}
|
||||
>
|
||||
<Tab label="Search" />
|
||||
<Tab label="Browser" />
|
||||
</StyledTabs>
|
||||
|
||||
{activeTab === 0 ? (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<StyledTextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Search..."
|
||||
disabled={isSearching}
|
||||
/>
|
||||
<StyledIconButton
|
||||
onClick={handleSearch}
|
||||
disabled={isSearching || !query.trim()}
|
||||
>
|
||||
<SearchIcon />
|
||||
</StyledIconButton>
|
||||
</div>
|
||||
|
||||
<StyledButton
|
||||
startIcon={<AddBoxIcon />}
|
||||
onClick={handleCreateSearchShape}
|
||||
fullWidth
|
||||
>
|
||||
Add Search Box
|
||||
</StyledButton>
|
||||
|
||||
<StyledPaper sx={{ flex: 1, overflow: 'auto' }}>
|
||||
<List>
|
||||
{results.map((result, index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
gap: 1,
|
||||
padding: '12px',
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={result.title}
|
||||
secondary={result.content}
|
||||
primaryTypographyProps={{
|
||||
sx: {
|
||||
fontWeight: 'bold',
|
||||
fontSize: '0.9rem',
|
||||
color: 'var(--color-selected)',
|
||||
mb: 0.5
|
||||
}
|
||||
}}
|
||||
secondaryTypographyProps={{
|
||||
sx: {
|
||||
fontSize: '0.8rem',
|
||||
color: 'var(--color-text)',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<StyledButton
|
||||
size="small"
|
||||
onClick={() => handleCreateFromResult(result)}
|
||||
fullWidth
|
||||
>
|
||||
Open in Browser
|
||||
</StyledButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</StyledPaper>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<StyledTextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Enter URL..."
|
||||
/>
|
||||
<StyledIconButton
|
||||
onClick={handleCreateBrowserShape}
|
||||
disabled={!url.trim()}
|
||||
>
|
||||
<LanguageIcon />
|
||||
</StyledIconButton>
|
||||
</div>
|
||||
<StyledButton
|
||||
startIcon={<AddBoxIcon />}
|
||||
onClick={handleCreateBrowserShape}
|
||||
disabled={!url.trim()}
|
||||
fullWidth
|
||||
>
|
||||
Add Web Browser
|
||||
</StyledButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { useEditor } from '@tldraw/tldraw';
|
||||
import { CC_SHAPE_CONFIGS } from '../../../cc-base/cc-configs';
|
||||
import { createSlideshowAtCenter, handleSlideshowFileUpload } from '../../../cc-base/shape-helpers/slideshow-helpers';
|
||||
import { createCalendarShapeAtCenter } from '../../../cc-base/shape-helpers/calendar-helpers';
|
||||
import { createSettingsShapeAtCenter } from '../../../cc-base/shape-helpers/settings-helpers';
|
||||
import { createLiveTranscriptionShapeAtCenter } from '../../../cc-base/shape-helpers/transcription-helpers';
|
||||
import './panel.css';
|
||||
|
||||
export const CCShapesPanel: React.FC = () => {
|
||||
const editor = useEditor();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleCreateShape = (shapeType: keyof typeof CC_SHAPE_CONFIGS, slidePattern?: string, numSlides?: number) => {
|
||||
if (!editor) return;
|
||||
|
||||
switch (shapeType) {
|
||||
case 'cc-calendar':
|
||||
createCalendarShapeAtCenter(editor);
|
||||
break;
|
||||
case 'cc-settings':
|
||||
createSettingsShapeAtCenter(editor);
|
||||
break;
|
||||
case 'cc-live-transcription':
|
||||
createLiveTranscriptionShapeAtCenter(editor);
|
||||
break;
|
||||
case 'cc-slideshow':
|
||||
createSlideshowAtCenter(editor, slidePattern, numSlides);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!editor || !event.target.files || event.target.files.length === 0) return;
|
||||
|
||||
try {
|
||||
await handleSlideshowFileUpload(editor, event.target.files[0], () => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'An unknown error occurred');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel-container">
|
||||
<div className="panel-section">
|
||||
<button className="shape-button" onClick={() => handleCreateShape('cc-calendar')}>
|
||||
Calendar Shape
|
||||
</button>
|
||||
<button className="shape-button" onClick={() => handleCreateShape('cc-settings')}>
|
||||
Settings Shape
|
||||
</button>
|
||||
<button className="shape-button" onClick={() => handleCreateShape('cc-live-transcription')}>
|
||||
Live Transcription
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="panel-divider" />
|
||||
|
||||
<div className="panel-section">
|
||||
<div className="panel-section-title">Slideshow Patterns</div>
|
||||
<button className="shape-button" onClick={() => handleCreateShape('cc-slideshow', 'horizontal', Number(CC_SHAPE_CONFIGS['cc-slideshow'].defaultProps.numSlides ?? 3))}>
|
||||
Horizontal Slideshow
|
||||
</button>
|
||||
<button className="shape-button" onClick={() => handleCreateShape('cc-slideshow', 'vertical', Number(CC_SHAPE_CONFIGS['cc-slideshow'].defaultProps.numSlides ?? 3))}>
|
||||
Vertical Slideshow
|
||||
</button>
|
||||
<button className="shape-button" onClick={() => handleCreateShape('cc-slideshow', 'grid', Number(CC_SHAPE_CONFIGS['cc-slideshow'].defaultProps.numSlides ?? 3))}>
|
||||
Grid Slideshow
|
||||
</button>
|
||||
<button className="shape-button" onClick={() => handleCreateShape('cc-slideshow', 'radial')}>
|
||||
Radial Slideshow
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="panel-divider" />
|
||||
|
||||
<div className="panel-section">
|
||||
<div className="panel-section-title">Import Office Documents</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pptx,.docx,.pdf"
|
||||
onChange={handleFileUpload}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<button className="shape-button" onClick={() => fileInputRef.current?.click()}>
|
||||
Upload Document
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import React from 'react'
|
||||
import { useEditor, TldrawUiButton } from '@tldraw/tldraw'
|
||||
import {
|
||||
useSlideShows,
|
||||
useCurrentSlide,
|
||||
moveToSlide,
|
||||
getSlideLabel
|
||||
} from '../../../cc-base/cc-slideshow/useSlideShow'
|
||||
import { CCSlideShape } from '../../../cc-base/cc-slideshow/CCSlideShapeUtil'
|
||||
import { CCSlideShowShape } from '../../../cc-base/cc-slideshow/CCSlideShowShapeUtil'
|
||||
import { useTLDraw } from '../../../../../contexts/TLDrawContext'
|
||||
import { logger } from '../../../../../debugConfig'
|
||||
import { CCSlideLayoutBinding } from '../../../cc-base/cc-slideshow/CCSlideLayoutBindingUtil'
|
||||
import './panel.css'
|
||||
|
||||
export const CCSlidesPanel: React.FC = () => {
|
||||
const editor = useEditor()
|
||||
const slideshows = useSlideShows()
|
||||
const currentSlide = useCurrentSlide()
|
||||
const { presentationMode, togglePresentationMode, presentationService } = useTLDraw()
|
||||
|
||||
const handleSlideClick = (slide: CCSlideShape) => {
|
||||
logger.info('selection', '🖱️ Slide clicked in panel', {
|
||||
slideId: slide.id,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
moveToSlide(editor, slide, presentationMode)
|
||||
}
|
||||
|
||||
const handleTogglePresentation = () => {
|
||||
logger.info('presentation', '🔄 Toggling presentation mode from slides panel')
|
||||
togglePresentationMode(editor)
|
||||
}
|
||||
|
||||
const handleZoomToSlideshow = (slideshow: CCSlideShowShape) => {
|
||||
if (presentationMode && presentationService) {
|
||||
presentationService.zoomToShape(slideshow)
|
||||
}
|
||||
}
|
||||
|
||||
const renderSlideshow = (slideshow: CCSlideShowShape) => {
|
||||
const bindings = editor
|
||||
.getBindingsFromShape<CCSlideLayoutBinding>(slideshow, 'cc-slide-layout')
|
||||
.filter(b => !b.props.placeholder)
|
||||
.sort((a, b) => (a.props.index > b.props.index ? 1 : -1))
|
||||
|
||||
return (
|
||||
<div key={slideshow.id} className="slideshow-container">
|
||||
<div className="slideshow-header">
|
||||
<div className="slideshow-title">
|
||||
<h3>{slideshow.props.title || 'Untitled Slideshow'}</h3>
|
||||
<span className="slide-count">
|
||||
{bindings.length} slides
|
||||
</span>
|
||||
</div>
|
||||
{presentationMode && (
|
||||
<TldrawUiButton
|
||||
type="icon"
|
||||
className="zoom-button"
|
||||
data-testid="zoom-to-slideshow"
|
||||
onClick={() => handleZoomToSlideshow(slideshow)}
|
||||
title="Zoom to slideshow"
|
||||
>
|
||||
🔍
|
||||
</TldrawUiButton>
|
||||
)}
|
||||
</div>
|
||||
<div className="slides-list">
|
||||
{bindings.map((binding, index) => {
|
||||
const slide = editor.getShape(binding.toId) as CCSlideShape
|
||||
if (!slide) return null
|
||||
|
||||
const isCurrentSlide = currentSlide?.id === slide.id
|
||||
return (
|
||||
<div
|
||||
key={slide.id}
|
||||
className={`slide-item ${isCurrentSlide ? 'selected' : ''}`}
|
||||
onClick={() => handleSlideClick(slide)}
|
||||
>
|
||||
<span className="slide-number">{index + 1}</span>
|
||||
<span className="slide-title">
|
||||
{getSlideLabel(slide, index)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel-container">
|
||||
<div className="slides-panel-tools">
|
||||
<TldrawUiButton
|
||||
type="normal"
|
||||
className="shape-button"
|
||||
data-active={presentationMode}
|
||||
data-testid="toggle-presentation"
|
||||
onClick={handleTogglePresentation}
|
||||
>
|
||||
{presentationMode ? 'Exit Presentation' : 'Present'}
|
||||
</TldrawUiButton>
|
||||
</div>
|
||||
|
||||
{slideshows.length === 0 ? (
|
||||
<div className="panel-empty-state">
|
||||
<p>No slideshows yet</p>
|
||||
<p>Create a slideshow to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
slideshows.map(renderSlideshow)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { useEditor } from '@tldraw/tldraw';
|
||||
import { CC_SHAPE_CONFIGS } from '../../../cc-base/cc-configs';
|
||||
import { createYoutubeShapeAtCenter } from '../../../cc-base/shape-helpers/youtube-helpers';
|
||||
import './panel.css';
|
||||
|
||||
export const CCYoutubePanel: React.FC = () => {
|
||||
const editor = useEditor();
|
||||
const [videoUrl, setVideoUrl] = React.useState<string>(CC_SHAPE_CONFIGS['cc-youtube-embed'].defaultProps.video_url as string);
|
||||
|
||||
return (
|
||||
<div className="panel-container">
|
||||
<div className="panel-section">
|
||||
<input
|
||||
type="text"
|
||||
value={videoUrl}
|
||||
onChange={(e) => setVideoUrl(e.target.value)}
|
||||
onPaste={(e) => {
|
||||
e.preventDefault();
|
||||
const pastedText = e.clipboardData.getData('text');
|
||||
setVideoUrl(pastedText);
|
||||
}}
|
||||
placeholder="Enter YouTube URL"
|
||||
className="panel-input"
|
||||
/>
|
||||
<button
|
||||
onClick={() => editor && createYoutubeShapeAtCenter(editor, videoUrl)}
|
||||
className="shape-button"
|
||||
>
|
||||
Add YouTube Video
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,264 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useEditor, TLPageId, useValue } from '@tldraw/tldraw';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
styled,
|
||||
ThemeProvider,
|
||||
createTheme,
|
||||
useMediaQuery
|
||||
} from '@mui/material';
|
||||
import { useTLDraw } from '../../../../../../contexts/TLDrawContext';
|
||||
import {
|
||||
FileCopy as PageIcon,
|
||||
Add as AddIcon,
|
||||
MoreVert as MoreVertIcon,
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
FileCopy as FileCopyIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
const PageSection = styled(Box)(() => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
color: 'var(--color-text)',
|
||||
}));
|
||||
|
||||
const PageListItem = styled(ListItem, {
|
||||
shouldForwardProp: (prop) => prop !== 'isSelected',
|
||||
})<{ isSelected?: boolean }>(({ isSelected }) => ({
|
||||
borderRadius: '4px',
|
||||
backgroundColor: isSelected ? 'var(--color-selected-background)' : 'transparent',
|
||||
transition: 'background-color 200ms ease, transform 200ms ease, box-shadow 200ms ease',
|
||||
'&:hover': {
|
||||
backgroundColor: isSelected ? 'var(--color-selected-hover)' : 'var(--color-hover)',
|
||||
transform: 'translateX(4px)',
|
||||
'& .MuiListItemIcon-root': {
|
||||
color: 'var(--color-selected)',
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
'& .MuiIconButton-root': {
|
||||
opacity: 1,
|
||||
transform: 'scale(1)',
|
||||
},
|
||||
},
|
||||
cursor: 'pointer',
|
||||
'& .MuiListItemIcon-root': {
|
||||
color: isSelected ? 'var(--color-selected)' : 'var(--color-text)',
|
||||
transition: 'color 200ms ease, transform 200ms ease',
|
||||
'& .MuiSvgIcon-root': {
|
||||
fontSize: '1.25rem',
|
||||
},
|
||||
},
|
||||
'& .MuiIconButton-root': {
|
||||
opacity: 0,
|
||||
transform: 'scale(0.8)',
|
||||
transition: 'opacity 200ms ease, transform 200ms ease, background-color 200ms ease',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--color-hover)',
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledIconButton = styled(IconButton)(() => ({
|
||||
color: 'var(--color-text)',
|
||||
transition: 'background-color 200ms ease, transform 200ms ease, color 200ms ease',
|
||||
'&:hover': {
|
||||
color: 'var(--color-selected)',
|
||||
backgroundColor: 'var(--color-hover)',
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
'& .MuiSvgIcon-root': {
|
||||
fontSize: '1.25rem',
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledMenuItem = styled(MenuItem)(() => ({
|
||||
gap: '8px',
|
||||
transition: 'background-color 200ms ease, color 200ms ease',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--color-hover)',
|
||||
'& .MuiListItemIcon-root': {
|
||||
color: 'var(--color-selected)',
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
},
|
||||
'& .MuiListItemIcon-root': {
|
||||
color: 'var(--color-text)',
|
||||
minWidth: '32px',
|
||||
transition: 'color 200ms ease, transform 200ms ease',
|
||||
'& .MuiSvgIcon-root': {
|
||||
fontSize: '1.25rem',
|
||||
},
|
||||
},
|
||||
'&.Mui-disabled': {
|
||||
'& .MuiListItemIcon-root': {
|
||||
color: 'var(--color-text-disabled)',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export const PageComponent = () => {
|
||||
const editor = useEditor();
|
||||
const { tldrawPreferences } = useTLDraw();
|
||||
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
|
||||
const [menuAnchor, setMenuAnchor] = React.useState<null | { element: HTMLElement; pageId: TLPageId }>(null);
|
||||
|
||||
// Create a dynamic theme based on TLDraw preferences
|
||||
const theme = useMemo(() => {
|
||||
let mode: 'light' | 'dark';
|
||||
|
||||
// Determine mode based on TLDraw preferences
|
||||
if (tldrawPreferences?.colorScheme === 'system') {
|
||||
mode = prefersDarkMode ? 'dark' : 'light';
|
||||
} else {
|
||||
mode = tldrawPreferences?.colorScheme === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode,
|
||||
divider: 'var(--color-divider)',
|
||||
},
|
||||
});
|
||||
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
|
||||
|
||||
// Subscribe to page changes using useValue
|
||||
const pages = useValue('pages', () => editor.getPages(), [editor]);
|
||||
const currentPageId = useValue('currentPageId', () => editor.getCurrentPageId(), [editor]);
|
||||
|
||||
const handlePageSelect = useCallback((pageId: TLPageId) => {
|
||||
editor.setCurrentPage(pageId);
|
||||
}, [editor]);
|
||||
|
||||
const handleCreatePage = useCallback(() => {
|
||||
editor.createPage({
|
||||
name: `Page ${editor.getPages().length + 1}`
|
||||
});
|
||||
}, [editor]);
|
||||
|
||||
const handlePageMenuOpen = useCallback((event: React.MouseEvent<HTMLElement>, pageId: TLPageId) => {
|
||||
event.stopPropagation();
|
||||
setMenuAnchor({ element: event.currentTarget, pageId });
|
||||
}, []);
|
||||
|
||||
const handlePageMenuClose = useCallback(() => {
|
||||
setMenuAnchor(null);
|
||||
}, []);
|
||||
|
||||
const handleRenamePage = useCallback(() => {
|
||||
if (!menuAnchor) return;
|
||||
|
||||
const page = editor.getPage(menuAnchor.pageId);
|
||||
if (!page) return;
|
||||
|
||||
const newName = window.prompt('Enter new page name:', page.name);
|
||||
if (newName) {
|
||||
editor.renamePage(menuAnchor.pageId, newName);
|
||||
}
|
||||
handlePageMenuClose();
|
||||
}, [editor, menuAnchor]);
|
||||
|
||||
const handleDuplicatePage = useCallback(() => {
|
||||
if (!menuAnchor) return;
|
||||
editor.duplicatePage(menuAnchor.pageId);
|
||||
handlePageMenuClose();
|
||||
}, [editor, menuAnchor]);
|
||||
|
||||
const handleDeletePage = useCallback(() => {
|
||||
if (!menuAnchor) return;
|
||||
|
||||
if (pages.length <= 1) {
|
||||
alert('Cannot delete the last page');
|
||||
return;
|
||||
}
|
||||
|
||||
editor.deletePage(menuAnchor.pageId);
|
||||
handlePageMenuClose();
|
||||
}, [editor, menuAnchor, pages.length]);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<PageSection>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography variant="subtitle2" sx={{ color: 'var(--color-text-secondary)' }}>Pages</Typography>
|
||||
<StyledIconButton size="small" onClick={handleCreatePage}>
|
||||
<AddIcon fontSize="small" />
|
||||
</StyledIconButton>
|
||||
</Box>
|
||||
|
||||
<List dense>
|
||||
{pages.map((page) => (
|
||||
<PageListItem
|
||||
key={page.id}
|
||||
isSelected={page.id === currentPageId}
|
||||
onClick={() => handlePageSelect(page.id)}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<PageIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={page.name}
|
||||
primaryTypographyProps={{
|
||||
variant: 'body2',
|
||||
noWrap: true,
|
||||
sx: { color: 'var(--color-text)' }
|
||||
}}
|
||||
/>
|
||||
<StyledIconButton
|
||||
size="small"
|
||||
onClick={(e) => handlePageMenuOpen(e, page.id)}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</StyledIconButton>
|
||||
</PageListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Menu
|
||||
anchorEl={menuAnchor?.element}
|
||||
open={Boolean(menuAnchor)}
|
||||
onClose={handlePageMenuClose}
|
||||
PaperProps={{
|
||||
elevation: 8,
|
||||
sx: {
|
||||
border: '1px solid var(--color-divider)',
|
||||
boxShadow: 'var(--shadow-popup)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<StyledMenuItem onClick={handleRenamePage}>
|
||||
<ListItemIcon>
|
||||
<EditIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Rename</ListItemText>
|
||||
</StyledMenuItem>
|
||||
<StyledMenuItem onClick={handleDuplicatePage}>
|
||||
<ListItemIcon>
|
||||
<FileCopyIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Duplicate</ListItemText>
|
||||
</StyledMenuItem>
|
||||
<StyledMenuItem
|
||||
onClick={handleDeletePage}
|
||||
disabled={pages.length <= 1}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Delete</ListItemText>
|
||||
</StyledMenuItem>
|
||||
</Menu>
|
||||
</PageSection>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,430 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { Box, Typography, ListItemText, ListItemIcon, styled, Select, MenuItem, FormControl, InputLabel, ThemeProvider, createTheme, useMediaQuery } from '@mui/material';
|
||||
import {
|
||||
AccountCircle as AccountCircleIcon,
|
||||
CalendarToday as CalendarIcon,
|
||||
School as TeachingIcon,
|
||||
Business as BusinessIcon,
|
||||
AccountTree as DepartmentIcon,
|
||||
Class as ClassIcon,
|
||||
Dashboard as DashboardIcon,
|
||||
Settings as SettingsIcon,
|
||||
History as HistoryIcon,
|
||||
Book as JournalIcon,
|
||||
Event as PlannerIcon,
|
||||
ViewDay as DayIcon,
|
||||
ViewWeek as WeekIcon,
|
||||
ViewModule as MonthIcon,
|
||||
ViewAgenda as YearIcon,
|
||||
Schedule as TimetableIcon,
|
||||
Group as StaffIcon,
|
||||
School as TeachersIcon,
|
||||
MenuBook as SubjectsIcon,
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
BaseContext,
|
||||
ExtendedContext,
|
||||
ViewContext,
|
||||
ViewDefinition,
|
||||
CalendarExtendedContext,
|
||||
TeacherExtendedContext
|
||||
} from '../../../../../../types/navigation';
|
||||
import { NAVIGATION_CONTEXTS } from '../../../../../../config/navigationContexts';
|
||||
import { useNavigationStore } from '../../../../../../stores/navigationStore';
|
||||
import { useNeoUser } from '../../../../../../contexts/NeoUserContext';
|
||||
import { CalendarNavigation } from '../../../../../../components/navigation/extended/CalendarNavigation';
|
||||
import { TeacherNavigation } from '../../../../../../components/navigation/extended/TeacherNavigation';
|
||||
import { useTLDraw } from '../../../../../../contexts/TLDrawContext';
|
||||
import { logger } from '../../../../../../debugConfig';
|
||||
|
||||
const PanelContainer = styled(Box)(() => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
padding: '16px',
|
||||
gap: '16px',
|
||||
overflow: 'auto',
|
||||
color: 'var(--color-text)',
|
||||
}));
|
||||
|
||||
const menuProps = {
|
||||
PaperProps: {
|
||||
elevation: 8,
|
||||
sx: {
|
||||
border: '1px solid var(--color-divider)',
|
||||
boxShadow: 'var(--shadow-popup)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface CCNavigationPanelProps {
|
||||
currentContext: BaseContext;
|
||||
onContextChange: (context: BaseContext) => void;
|
||||
currentExtendedContext?: ViewContext;
|
||||
onExtendedContextChange?: (context: ViewContext) => void;
|
||||
}
|
||||
|
||||
export const CCNavigationPanel: React.FC<CCNavigationPanelProps> = ({
|
||||
currentContext,
|
||||
onContextChange,
|
||||
currentExtendedContext,
|
||||
onExtendedContextChange,
|
||||
}) => {
|
||||
const {
|
||||
context: navigationContext,
|
||||
setBaseContext,
|
||||
setExtendedContext,
|
||||
isLoading,
|
||||
error,
|
||||
} = useNavigationStore();
|
||||
|
||||
const { userDbName, workerDbName } = useNeoUser();
|
||||
const { tldrawPreferences } = useTLDraw();
|
||||
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
|
||||
|
||||
// Create a dynamic theme based on TLDraw preferences
|
||||
const theme = useMemo(() => {
|
||||
let mode: 'light' | 'dark';
|
||||
|
||||
// Determine mode based on TLDraw preferences
|
||||
if (tldrawPreferences?.colorScheme === 'system') {
|
||||
mode = prefersDarkMode ? 'dark' : 'light';
|
||||
} else {
|
||||
mode = tldrawPreferences?.colorScheme === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode,
|
||||
divider: 'var(--color-divider)',
|
||||
},
|
||||
});
|
||||
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
|
||||
|
||||
const getDefaultViewForContext = (context: BaseContext): ViewContext => {
|
||||
switch (context) {
|
||||
case 'calendar':
|
||||
return 'overview';
|
||||
case 'teaching':
|
||||
return 'overview';
|
||||
case 'school':
|
||||
return 'overview';
|
||||
case 'department':
|
||||
return 'overview';
|
||||
case 'class':
|
||||
return 'overview';
|
||||
default:
|
||||
return 'overview';
|
||||
}
|
||||
};
|
||||
|
||||
const isValidExtendedContextForBase = (extendedContext: ExtendedContext, baseContext: BaseContext): boolean => {
|
||||
const contextDef = NAVIGATION_CONTEXTS[baseContext];
|
||||
return contextDef?.views?.some(view => view.id === extendedContext) ?? false;
|
||||
};
|
||||
|
||||
// Sync with navigation store's main context
|
||||
useEffect(() => {
|
||||
if (navigationContext.main !== 'profile' && navigationContext.main !== 'institute') {
|
||||
return;
|
||||
}
|
||||
|
||||
const contextDef = NAVIGATION_CONTEXTS[navigationContext.base];
|
||||
if (!contextDef) return;
|
||||
|
||||
// Update local state to match navigation store
|
||||
if (navigationContext.base !== currentContext) {
|
||||
onContextChange(navigationContext.base);
|
||||
|
||||
// When base context changes, set appropriate default view
|
||||
const defaultView = getDefaultViewForContext(navigationContext.base);
|
||||
if (isValidViewContext(defaultView)) {
|
||||
onExtendedContextChange?.(defaultView);
|
||||
}
|
||||
}
|
||||
|
||||
// If current extended context is not valid for this base context, reset to default
|
||||
if (currentExtendedContext && !isValidExtendedContextForBase(currentExtendedContext, navigationContext.base)) {
|
||||
const defaultView = getDefaultViewForContext(navigationContext.base);
|
||||
if (isValidViewContext(defaultView)) {
|
||||
onExtendedContextChange?.(defaultView);
|
||||
}
|
||||
}
|
||||
}, [navigationContext.main, navigationContext.base, currentContext, currentExtendedContext, onContextChange, onExtendedContextChange]);
|
||||
|
||||
const handleContextChange = async (newContext: BaseContext) => {
|
||||
logger.debug('navigation-panel', '🔄 Starting context change', {
|
||||
from: currentContext,
|
||||
to: newContext
|
||||
});
|
||||
|
||||
try {
|
||||
// Get default view for new context
|
||||
const defaultView = getDefaultViewForContext(newContext);
|
||||
logger.debug('navigation-panel', '📍 Determined default view', {
|
||||
context: newContext,
|
||||
defaultView
|
||||
});
|
||||
|
||||
// Use unified context switch with both base and extended contexts
|
||||
const contextUpdate = {
|
||||
base: newContext,
|
||||
extended: isValidViewContext(defaultView) ? defaultView : undefined
|
||||
};
|
||||
|
||||
logger.debug('navigation-panel', '🚀 Initiating context switch', contextUpdate);
|
||||
|
||||
await setBaseContext(newContext, userDbName, workerDbName);
|
||||
|
||||
logger.debug('navigation-panel', '✅ Context switch successful', {
|
||||
context: newContext,
|
||||
view: defaultView
|
||||
});
|
||||
|
||||
// Update local state
|
||||
onContextChange(newContext);
|
||||
if (isValidViewContext(defaultView)) {
|
||||
await setExtendedContext(defaultView, userDbName, workerDbName);
|
||||
onExtendedContextChange?.(defaultView);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('navigation-panel', '❌ Failed to change context', {
|
||||
error,
|
||||
attemptedContext: newContext
|
||||
});
|
||||
console.error('Failed to change context:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExtendedContextChange = async (newContext: ViewContext) => {
|
||||
logger.debug('navigation-panel', '🔄 Starting extended context change', {
|
||||
from: currentExtendedContext,
|
||||
to: newContext
|
||||
});
|
||||
|
||||
try {
|
||||
// Validate that the new context is valid for current base context
|
||||
if (!isValidExtendedContextForBase(newContext, currentContext)) {
|
||||
logger.warn('navigation-panel', '⚠️ Invalid extended context combination', {
|
||||
baseContext: currentContext,
|
||||
attemptedExtendedContext: newContext
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const contextUpdate = { extended: newContext };
|
||||
logger.debug('navigation-panel', '🚀 Initiating extended context switch', contextUpdate);
|
||||
|
||||
// Use unified context switch for extended context only
|
||||
await setExtendedContext(newContext, userDbName, workerDbName);
|
||||
|
||||
logger.debug('navigation-panel', '✅ Extended context switch successful', {
|
||||
newView: newContext
|
||||
});
|
||||
|
||||
// Update local state
|
||||
onExtendedContextChange?.(newContext);
|
||||
} catch (error) {
|
||||
logger.error('navigation-panel', '❌ Failed to change extended context', {
|
||||
error,
|
||||
attemptedContext: newContext
|
||||
});
|
||||
console.error('Failed to change extended context:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Add helper function to validate ViewContext
|
||||
const isValidViewContext = (context: ExtendedContext): context is ViewContext => {
|
||||
const validViewContexts: ViewContext[] = [
|
||||
// Common views
|
||||
'overview',
|
||||
// User views
|
||||
'settings', 'history', 'journal', 'planner',
|
||||
// Calendar views
|
||||
'day', 'week', 'month', 'year',
|
||||
// Teaching views
|
||||
'timetable', 'classes', 'lessons',
|
||||
// School views
|
||||
'departments', 'staff',
|
||||
// Department views
|
||||
'teachers', 'subjects',
|
||||
// Class views
|
||||
'students'
|
||||
];
|
||||
return validViewContexts.includes(context as ViewContext);
|
||||
};
|
||||
|
||||
const getContextIcon = (contextType: string) => {
|
||||
const iconProps = {
|
||||
sx: {
|
||||
color: 'var(--color-text)',
|
||||
}
|
||||
};
|
||||
|
||||
switch (contextType) {
|
||||
case 'profile':
|
||||
return <AccountCircleIcon {...iconProps} />;
|
||||
case 'calendar':
|
||||
return <CalendarIcon {...iconProps} />;
|
||||
case 'teaching':
|
||||
return <TeachingIcon {...iconProps} />;
|
||||
case 'school':
|
||||
return <BusinessIcon {...iconProps} />;
|
||||
case 'department':
|
||||
return <DepartmentIcon {...iconProps} />;
|
||||
case 'class':
|
||||
return <ClassIcon {...iconProps} />;
|
||||
case 'overview':
|
||||
return <DashboardIcon {...iconProps} />;
|
||||
case 'settings':
|
||||
return <SettingsIcon {...iconProps} />;
|
||||
case 'history':
|
||||
return <HistoryIcon {...iconProps} />;
|
||||
case 'journal':
|
||||
return <JournalIcon {...iconProps} />;
|
||||
case 'planner':
|
||||
return <PlannerIcon {...iconProps} />;
|
||||
case 'day':
|
||||
return <DayIcon {...iconProps} />;
|
||||
case 'week':
|
||||
return <WeekIcon {...iconProps} />;
|
||||
case 'month':
|
||||
return <MonthIcon {...iconProps} />;
|
||||
case 'year':
|
||||
return <YearIcon {...iconProps} />;
|
||||
case 'timetable':
|
||||
return <TimetableIcon {...iconProps} />;
|
||||
case 'staff':
|
||||
return <StaffIcon {...iconProps} />;
|
||||
case 'teachers':
|
||||
return <TeachersIcon {...iconProps} />;
|
||||
case 'subjects':
|
||||
return <SubjectsIcon {...iconProps} />;
|
||||
default:
|
||||
return <AccountCircleIcon {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const renderContextDropdown = () => {
|
||||
const items = navigationContext.main === 'profile' ? [
|
||||
{ id: 'profile', label: 'Profile' },
|
||||
{ id: 'calendar', label: 'Calendar' },
|
||||
{ id: 'teaching', label: 'Teaching' }
|
||||
] : [
|
||||
{ id: 'school', label: 'School' },
|
||||
{ id: 'department', label: 'Department' },
|
||||
{ id: 'class', label: 'Class' }
|
||||
];
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<FormControl fullWidth variant="outlined" size="small">
|
||||
<InputLabel>Context</InputLabel>
|
||||
<Select
|
||||
value={currentContext}
|
||||
onChange={(e) => handleContextChange(e.target.value as BaseContext)}
|
||||
label="Context"
|
||||
MenuProps={menuProps}
|
||||
>
|
||||
{items.map(item => (
|
||||
<MenuItem key={item.id} value={item.id}>
|
||||
<ListItemIcon>
|
||||
{getContextIcon(item.id)}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={item.label} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const renderExtendedContextDropdown = () => {
|
||||
const contextDef = NAVIGATION_CONTEXTS[currentContext];
|
||||
if (!contextDef?.views?.length) return null;
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<FormControl fullWidth variant="outlined" size="small">
|
||||
<InputLabel>View</InputLabel>
|
||||
<Select
|
||||
value={currentExtendedContext || contextDef.views[0].id}
|
||||
onChange={(e) => handleExtendedContextChange(e.target.value as ViewContext)}
|
||||
label="View"
|
||||
MenuProps={menuProps}
|
||||
>
|
||||
{contextDef.views.map((view: ViewDefinition) => (
|
||||
<MenuItem key={view.id} value={view.id}>
|
||||
<ListItemIcon>
|
||||
{getContextIcon(view.id)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={view.label}
|
||||
secondary={view.description}
|
||||
/>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const renderContextSpecificNavigation = () => {
|
||||
if (!currentExtendedContext) return null;
|
||||
|
||||
switch (currentContext) {
|
||||
case 'calendar':
|
||||
return (
|
||||
<CalendarNavigation
|
||||
activeView={currentExtendedContext as CalendarExtendedContext}
|
||||
onViewChange={(view) => handleExtendedContextChange(view)}
|
||||
/>
|
||||
);
|
||||
case 'teaching':
|
||||
return (
|
||||
<TeacherNavigation
|
||||
activeView={currentExtendedContext as TeacherExtendedContext}
|
||||
onViewChange={(view) => handleExtendedContextChange(view)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<PanelContainer>
|
||||
{renderContextDropdown()}
|
||||
{renderExtendedContextDropdown()}
|
||||
{renderContextSpecificNavigation()}
|
||||
|
||||
{error && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
mt: 2,
|
||||
color: 'var(--color-error)'
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
mt: 2,
|
||||
color: 'var(--color-text-secondary)'
|
||||
}}
|
||||
>
|
||||
Loading...
|
||||
</Typography>
|
||||
)}
|
||||
</PanelContainer>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,230 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Box, Typography, styled, Button, ThemeProvider, createTheme, useMediaQuery } from '@mui/material';
|
||||
import { Save as SaveIcon, RestartAlt as ResetIcon } from '@mui/icons-material';
|
||||
import { useEditor, useToasts, loadSnapshot } from '@tldraw/tldraw';
|
||||
import { useNavigationStore } from '../../../../../../stores/navigationStore';
|
||||
import { UserNeoDBService } from '../../../../../../services/graph/userNeoDBService';
|
||||
import { PageComponent } from '../components/pageComponent';
|
||||
import { logger } from '../../../../../../debugConfig';
|
||||
import { useTLDraw } from '../../../../../../contexts/TLDrawContext';
|
||||
import { NavigationSnapshotService } from '../../../../../../services/tldraw/snapshotService';
|
||||
import { blankCanvasSnapshot } from '../../../../../tldraw/assets';
|
||||
|
||||
const CurrentNodeSection = styled(Box)(() => ({
|
||||
padding: '8px',
|
||||
backgroundColor: 'var(--color-panel)',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '8px',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--color-hover)',
|
||||
}
|
||||
}));
|
||||
|
||||
const NodeInfoContainer = styled(Box)(() => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
marginBottom: '12px'
|
||||
}));
|
||||
|
||||
const ActionButton = styled(Button)(() => ({
|
||||
textTransform: 'none',
|
||||
padding: '6px 16px',
|
||||
gap: '8px',
|
||||
backgroundColor: 'var(--color-panel)',
|
||||
color: 'var(--color-text)',
|
||||
border: '1px solid transparent',
|
||||
transition: 'border-color 200ms ease',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--color-panel)',
|
||||
borderColor: 'var(--color-text)',
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: 'var(--color-panel)',
|
||||
},
|
||||
'& .MuiSvgIcon-root': {
|
||||
fontSize: '1.25rem',
|
||||
color: 'inherit',
|
||||
transition: 'transform 200ms ease',
|
||||
},
|
||||
'&:hover .MuiSvgIcon-root': {
|
||||
transform: 'scale(1.1) rotate(-10deg)',
|
||||
},
|
||||
'&.Mui-disabled': {
|
||||
backgroundColor: 'var(--color-muted)',
|
||||
color: 'var(--color-text-disabled)',
|
||||
borderColor: 'transparent',
|
||||
}
|
||||
}));
|
||||
|
||||
const ButtonContainer = styled(Box)(() => ({
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
width: '100%'
|
||||
}));
|
||||
|
||||
export const CCNodeSnapshotPanel: React.FC = () => {
|
||||
const editor = useEditor();
|
||||
const { addToast } = useToasts();
|
||||
const { context: navigationContext, isLoading, error } = useNavigationStore();
|
||||
const { tldrawPreferences } = useTLDraw();
|
||||
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Create a dynamic theme based on TLDraw preferences
|
||||
const theme = useMemo(() => {
|
||||
let mode: 'light' | 'dark';
|
||||
|
||||
// Determine mode based on TLDraw preferences
|
||||
if (tldrawPreferences?.colorScheme === 'system') {
|
||||
mode = prefersDarkMode ? 'dark' : 'light';
|
||||
} else {
|
||||
mode = tldrawPreferences?.colorScheme === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode,
|
||||
divider: 'var(--color-divider)',
|
||||
},
|
||||
});
|
||||
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
|
||||
|
||||
const handleResetCanvas = useCallback(() => {
|
||||
try {
|
||||
loadSnapshot(editor.store, blankCanvasSnapshot);
|
||||
addToast({
|
||||
title: 'Canvas reset',
|
||||
description: 'The canvas has been reset to blank.',
|
||||
icon: 'reset-zoom',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('cc-node-snapshot-panel', '❌ Failed to reset canvas', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
addToast({
|
||||
title: 'Error',
|
||||
description: 'Failed to reset canvas',
|
||||
icon: 'warning-triangle',
|
||||
});
|
||||
}
|
||||
}, [editor.store, addToast]);
|
||||
|
||||
const handleSaveSnapshot = useCallback(async () => {
|
||||
if (isSaving) return;
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
if (!navigationContext.node?.id) {
|
||||
logger.error('cc-node-snapshot-panel', '❌ No current node available for saving');
|
||||
addToast({
|
||||
title: 'Error',
|
||||
description: 'No current node available for saving',
|
||||
icon: 'warning-triangle',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('cc-node-snapshot-panel', '💾 Saving snapshot', {
|
||||
id: navigationContext.node.id,
|
||||
type: navigationContext.node.type
|
||||
});
|
||||
|
||||
const dbName = UserNeoDBService.getNodeDatabaseName(navigationContext.node);
|
||||
await NavigationSnapshotService.saveNodeSnapshotToDatabase(navigationContext.node.id, dbName, editor.store);
|
||||
|
||||
addToast({
|
||||
title: 'Snapshot saved',
|
||||
description: 'Your snapshot has been saved successfully.',
|
||||
icon: 'check',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('cc-node-snapshot-panel', '❌ Failed to save snapshot', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
|
||||
addToast({
|
||||
title: 'Error',
|
||||
description: error instanceof Error ? error.message : 'Failed to save snapshot',
|
||||
icon: 'warning-triangle',
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [editor, navigationContext.node, addToast, isSaving]);
|
||||
|
||||
const renderCurrentNode = () => {
|
||||
if (!navigationContext.node) return null;
|
||||
|
||||
return (
|
||||
<CurrentNodeSection>
|
||||
<NodeInfoContainer>
|
||||
<Typography variant="subtitle2" sx={{ color: 'var(--color-text-secondary)' }}>
|
||||
Current Node
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: 'var(--color-text)' }}>
|
||||
{navigationContext.node.label || navigationContext.node.id}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'var(--color-text-secondary)' }}>
|
||||
{navigationContext.node.type}
|
||||
</Typography>
|
||||
</NodeInfoContainer>
|
||||
<ButtonContainer>
|
||||
<ActionButton
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={handleSaveSnapshot}
|
||||
disabled={isLoading}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
Save Snapshot
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<ResetIcon />}
|
||||
onClick={handleResetCanvas}
|
||||
disabled={isLoading}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
Reset Canvas
|
||||
</ActionButton>
|
||||
</ButtonContainer>
|
||||
</CurrentNodeSection>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{renderCurrentNode()}
|
||||
<PageComponent />
|
||||
|
||||
{error && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
mt: 2,
|
||||
color: 'var(--color-error)'
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
mt: 2,
|
||||
color: 'var(--color-text-secondary)'
|
||||
}}
|
||||
>
|
||||
Loading...
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
export const PANEL_DIMENSIONS = {
|
||||
'navigation': {
|
||||
width: '300px',
|
||||
topOffset: `0px`,
|
||||
bottomOffset: '0px',
|
||||
},
|
||||
'node-snapshot': {
|
||||
width: '300px',
|
||||
topOffset: `0px`,
|
||||
bottomOffset: '0px',
|
||||
},
|
||||
'cc-shapes': {
|
||||
width: '300px',
|
||||
topOffset: `0px`,
|
||||
bottomOffset: '0px',
|
||||
},
|
||||
'slides': {
|
||||
width: '300px',
|
||||
topOffset: `0px`,
|
||||
bottomOffset: '0px',
|
||||
},
|
||||
'youtube': {
|
||||
width: '300px',
|
||||
topOffset: `0px`,
|
||||
bottomOffset: '0px',
|
||||
},
|
||||
'graph': {
|
||||
width: '300px',
|
||||
topOffset: `0px`,
|
||||
bottomOffset: '0px',
|
||||
},
|
||||
'exam-marker': {
|
||||
width: '300px',
|
||||
topOffset: `0px`,
|
||||
bottomOffset: '0px',
|
||||
},
|
||||
'search': {
|
||||
width: '300px',
|
||||
topOffset: `0px`,
|
||||
bottomOffset: '0px',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Z-index constants for panel layering
|
||||
export const Z_INDICES = {
|
||||
HANDLE: 999,
|
||||
PANEL: 1000,
|
||||
} as const;
|
||||
@@ -0,0 +1,271 @@
|
||||
/* Base Panel Layout */
|
||||
.panel-root {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
background: var(--color-panel);
|
||||
border-right: 1px solid var(--color-divider);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
.panel-handle {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 24px;
|
||||
height: 48px;
|
||||
background: var(--color-panel);
|
||||
border: 1px solid var(--color-divider);
|
||||
border-left: none;
|
||||
border-radius: 0 4px 4px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
color: var(--color-text);
|
||||
z-index: var(--layer-panels);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
.panel-handle:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panel-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.panel-type-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-divider);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.panel-content::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pin-button {
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.pin-button:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.pin-button.pinned {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Common Panel Styles */
|
||||
.panel-container {
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
-ms-overflow-style: none; /* Hide scrollbar for IE and Edge */
|
||||
scrollbar-width: none; /* Hide scrollbar for Firefox */
|
||||
}
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.panel-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Input Styles */
|
||||
.panel-input {
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-muted-1);
|
||||
font-size: 14px;
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-text);
|
||||
width: 100%;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.panel-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-selected);
|
||||
}
|
||||
|
||||
.panel-input::placeholder {
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
/* Button Styles */
|
||||
.shape-button {
|
||||
padding: 8px 12px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background-color: var(--color-panel);
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.shape-button:hover {
|
||||
background-color: var(--color-hover);
|
||||
}
|
||||
|
||||
/* Section Styles */
|
||||
.panel-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panel-section-title {
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.panel-divider {
|
||||
border-top: 1px solid var(--color-divider);
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
/* Dropdown/Flyout Menu */
|
||||
.panel-dropdown {
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: 0;
|
||||
background-color: var(--color-panel);
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
z-index: 1000;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
/* Slides Panel Specific */
|
||||
.slides-panel-tools {
|
||||
flex-shrink: 0; /* Prevent tools from shrinking */
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding: 0 0 8px 0;
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
}
|
||||
|
||||
.slideshow-container {
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: 4px;
|
||||
overflow: visible;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0; /* Prevent container from shrinking */
|
||||
}
|
||||
|
||||
.slideshow-header {
|
||||
background: var(--color-muted);
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
}
|
||||
|
||||
.slideshow-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.slideshow-title h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.slide-count {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.slides-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.slide-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
transition: background-color 0.2s;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.slide-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.slide-number {
|
||||
min-width: 20px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.slide-title {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.slide-item:hover {
|
||||
background-color: var(--color-hover);
|
||||
}
|
||||
|
||||
.slide-item.selected {
|
||||
background-color: var(--color-selected);
|
||||
color: var(--color-selected-contrast);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.panel-empty-state {
|
||||
text-align: center;
|
||||
color: var(--color-text-2);
|
||||
padding: 16px;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import { Select, MenuItem, SelectChangeEvent } from '@mui/material';
|
||||
import { ViewColumn, ViewList, ViewModule, ViewQuilt, ViewStream } from '@mui/icons-material';
|
||||
import '../panel.css'; // Import the CSS file from parent directory
|
||||
|
||||
// Add panel type icons mapping
|
||||
const PANEL_TYPE_ICONS = {
|
||||
'default': <ViewQuilt />,
|
||||
'list': <ViewList />,
|
||||
'grid': <ViewModule />,
|
||||
'column': <ViewColumn />,
|
||||
'stream': <ViewStream />
|
||||
};
|
||||
|
||||
interface BasePanelProps {
|
||||
title?: string;
|
||||
showTypeSelector?: boolean;
|
||||
type?: keyof typeof PANEL_TYPE_ICONS;
|
||||
handleTypeChange?: (event: SelectChangeEvent) => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const BasePanel: React.FC<BasePanelProps> = ({
|
||||
title,
|
||||
showTypeSelector = false,
|
||||
type = 'default',
|
||||
handleTypeChange,
|
||||
children
|
||||
}) => {
|
||||
return (
|
||||
<div className="panel-root">
|
||||
<div className="panel-header">
|
||||
{title && <div className="panel-section-title">{title}</div>}
|
||||
<div className="panel-header-actions">
|
||||
{showTypeSelector && (
|
||||
<Select
|
||||
value={type}
|
||||
onChange={handleTypeChange}
|
||||
size="small"
|
||||
className="panel-type-select"
|
||||
sx={{
|
||||
'.MuiSelect-select': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '4px 8px',
|
||||
},
|
||||
'.MuiOutlinedInput-notchedOutline': {
|
||||
border: 'none'
|
||||
},
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||
border: 'none'
|
||||
},
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||
border: 'none'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Object.entries(PANEL_TYPE_ICONS).map(([value, icon]) => (
|
||||
<MenuItem
|
||||
key={value}
|
||||
value={value}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{value.charAt(0).toUpperCase() + value.slice(1)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel-content">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// ... existing code ...
|
||||
@@ -0,0 +1,15 @@
|
||||
import { TLUiOverrides } from '@tldraw/tldraw';
|
||||
import { toolsPresentationUiOverrides } from './presentation/tools';
|
||||
import { actionsPresentationUiOverrides } from './presentation/actions';
|
||||
import { toolsRegularUiOverrides } from './regular/tools';
|
||||
import { actionsRegularUiOverrides } from './regular/actions';
|
||||
|
||||
export const regularUiOverridesIndex: TLUiOverrides = {
|
||||
...actionsRegularUiOverrides,
|
||||
...toolsRegularUiOverrides,
|
||||
};
|
||||
|
||||
export const presentationUiOverridesIndex: TLUiOverrides = {
|
||||
...actionsPresentationUiOverrides,
|
||||
...toolsPresentationUiOverrides,
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import { TLUiOverrides, computed, TLParentId } from '@tldraw/tldraw'
|
||||
import {
|
||||
moveToSlide,
|
||||
$currentSlideShow,
|
||||
getSlideShowsFromPage,
|
||||
moveToSlideShow
|
||||
} from '../../../cc-base/cc-slideshow/useSlideShow'
|
||||
import { CCSlideShape } from '../../../cc-base/cc-slideshow/CCSlideShapeUtil'
|
||||
import { logger } from '../../../../../debugConfig'
|
||||
|
||||
export const actionsPresentationUiOverrides: TLUiOverrides = {
|
||||
actions(editor, actions) {
|
||||
// Add debug mode toggle
|
||||
actions['toggle-debug-mode'] = {
|
||||
id: 'toggle-debug-mode',
|
||||
label: 'Toggle Debug Mode',
|
||||
kbd: 'ctrl+shift+d',
|
||||
readonlyOk: true,
|
||||
onSelect() {
|
||||
editor.updateInstanceState({ isDebugMode: !editor.getInstanceState().isDebugMode })
|
||||
},
|
||||
}
|
||||
|
||||
// Navigation actions
|
||||
actions['next-slide'] = {
|
||||
id: 'next-slide',
|
||||
label: 'Next slide',
|
||||
kbd: 'right',
|
||||
onSelect() {
|
||||
const currentShow = $currentSlideShow.get()
|
||||
if (!currentShow) {
|
||||
logger.warn('navigation', '⚠️ No current slideshow found for next-slide action')
|
||||
return
|
||||
}
|
||||
|
||||
const nextIndex = (currentShow.props.currentSlideIndex + 1) % currentShow.props.slides.length
|
||||
const nextSlide = editor.getShape(currentShow.props.slides[nextIndex] as TLParentId) as CCSlideShape
|
||||
|
||||
if (nextSlide) {
|
||||
logger.info('navigation', '⌨️ Next slide shortcut (presentation)', {
|
||||
fromIndex: currentShow.props.currentSlideIndex,
|
||||
toIndex: nextIndex,
|
||||
slideId: nextSlide.id,
|
||||
slideshowId: currentShow.id,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
// Always pass true in presentation mode
|
||||
moveToSlide(editor, nextSlide, true)
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
actions['previous-slide'] = {
|
||||
id: 'previous-slide',
|
||||
label: 'Previous slide',
|
||||
kbd: 'left',
|
||||
onSelect() {
|
||||
const currentShow = $currentSlideShow.get()
|
||||
if (!currentShow) {
|
||||
logger.warn('navigation', '⚠️ No current slideshow found for previous-slide action')
|
||||
return
|
||||
}
|
||||
|
||||
const prevIndex = (currentShow.props.currentSlideIndex - 1 + currentShow.props.slides.length) % currentShow.props.slides.length
|
||||
const prevSlide = editor.getShape(currentShow.props.slides[prevIndex] as TLParentId) as CCSlideShape
|
||||
|
||||
if (prevSlide) {
|
||||
logger.info('navigation', '⌨️ Previous slide shortcut (presentation)', {
|
||||
fromIndex: currentShow.props.currentSlideIndex,
|
||||
toIndex: prevIndex,
|
||||
slideId: prevSlide.id,
|
||||
slideshowId: currentShow.id,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
// Always pass true in presentation mode
|
||||
moveToSlide(editor, prevSlide, true)
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const $slideshows = computed('slideshows', () => getSlideShowsFromPage(editor));
|
||||
|
||||
actions['next-slideshow'] = {
|
||||
id: 'next-slideshow',
|
||||
label: 'Next slideshow',
|
||||
kbd: 'ctrl+right',
|
||||
onSelect() {
|
||||
const slideshows = $slideshows.get();
|
||||
const currentSlideshow = $currentSlideShow.get();
|
||||
const index = slideshows.findIndex((show) => show.id === currentSlideshow?.id);
|
||||
const nextSlideshow = slideshows[index + 1] ?? currentSlideshow ?? slideshows[0];
|
||||
if (nextSlideshow) {
|
||||
editor.stopCameraAnimation();
|
||||
moveToSlideShow(editor, nextSlideshow, true);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
actions['previous-slideshow'] = {
|
||||
id: 'previous-slideshow',
|
||||
label: 'Previous slideshow',
|
||||
kbd: 'ctrl+left',
|
||||
onSelect() {
|
||||
const slideshows = $slideshows.get();
|
||||
const currentSlideshow = $currentSlideShow.get();
|
||||
const index = slideshows.findIndex((show) => show.id === currentSlideshow?.id);
|
||||
const previousSlideshow = slideshows[index - 1] ?? currentSlideshow ?? slideshows[slideshows.length - 1];
|
||||
if (previousSlideshow) {
|
||||
editor.stopCameraAnimation();
|
||||
moveToSlideShow(editor, previousSlideshow, true);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return actions;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { TLUiOverrides } from '@tldraw/tldraw'
|
||||
|
||||
export const toolsPresentationUiOverrides: TLUiOverrides = {
|
||||
tools(editor, tools) {
|
||||
const commonTools = {
|
||||
microphone: {
|
||||
id: 'microphone',
|
||||
icon: 'microphone',
|
||||
label: 'Microphone',
|
||||
kbd: 'm',
|
||||
onSelect: () => editor.setCurrentTool('microphone'),
|
||||
},
|
||||
slide: {
|
||||
id: 'cc-slide',
|
||||
icon: 'group',
|
||||
label: 'Slide',
|
||||
kbd: 's',
|
||||
onSelect: () => editor.setCurrentTool('cc-slide'),
|
||||
},
|
||||
}
|
||||
|
||||
const stickerTools = {
|
||||
heartSticker: {
|
||||
id: 'heartSticker',
|
||||
icon: '❤️',
|
||||
label: 'Heart Sticker',
|
||||
kbd: 'h',
|
||||
onSelect: () => editor.setCurrentTool('heartSticker'),
|
||||
},
|
||||
starSticker: {
|
||||
id: 'starSticker',
|
||||
icon: '⭐',
|
||||
label: 'Star Sticker',
|
||||
kbd: 't',
|
||||
onSelect: () => editor.setCurrentTool('starSticker'),
|
||||
},
|
||||
smileySticker: {
|
||||
id: 'smileySticker',
|
||||
icon: '😊',
|
||||
label: 'Smiley Sticker',
|
||||
kbd: 'y',
|
||||
onSelect: () => editor.setCurrentTool('smileySticker'),
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
...tools,
|
||||
...commonTools,
|
||||
...stickerTools,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { TLUiOverrides, computed, TLParentId } from '@tldraw/tldraw'
|
||||
import {
|
||||
moveToSlide,
|
||||
$currentSlideShow,
|
||||
getSlideShowsFromPage,
|
||||
moveToSlideShow
|
||||
} from '../../../cc-base/cc-slideshow/useSlideShow'
|
||||
import { CCSlideShape } from '../../../cc-base/cc-slideshow/CCSlideShapeUtil'
|
||||
import { logger } from '../../../../../debugConfig'
|
||||
|
||||
export const actionsRegularUiOverrides: TLUiOverrides = {
|
||||
actions(editor, actions) {
|
||||
// Add debug mode toggle
|
||||
actions['toggle-debug-mode'] = {
|
||||
id: 'toggle-debug-mode',
|
||||
label: 'Toggle Debug Mode',
|
||||
kbd: 'ctrl+shift+d',
|
||||
readonlyOk: true,
|
||||
onSelect() {
|
||||
editor.updateInstanceState({ isDebugMode: !editor.getInstanceState().isDebugMode })
|
||||
},
|
||||
}
|
||||
|
||||
// Navigation actions
|
||||
actions['next-slide'] = {
|
||||
id: 'next-slide',
|
||||
label: 'Next slide',
|
||||
kbd: 'right',
|
||||
onSelect() {
|
||||
const currentShow = $currentSlideShow.get()
|
||||
if (!currentShow) {
|
||||
logger.warn('navigation', '⚠️ No current slideshow found for next-slide action')
|
||||
return
|
||||
}
|
||||
|
||||
const nextIndex = (currentShow.props.currentSlideIndex + 1) % currentShow.props.slides.length
|
||||
const nextSlide = editor.getShape(currentShow.props.slides[nextIndex] as TLParentId) as CCSlideShape
|
||||
|
||||
if (nextSlide) {
|
||||
logger.info('navigation', '⌨️ Next slide shortcut (regular)', {
|
||||
fromIndex: currentShow.props.currentSlideIndex,
|
||||
toIndex: nextIndex,
|
||||
slideId: nextSlide.id,
|
||||
slideshowId: currentShow.id,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
moveToSlide(editor, nextSlide, false)
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
actions['previous-slide'] = {
|
||||
id: 'previous-slide',
|
||||
label: 'Previous slide',
|
||||
kbd: 'left',
|
||||
onSelect() {
|
||||
const currentShow = $currentSlideShow.get()
|
||||
if (!currentShow) {
|
||||
logger.warn('navigation', '⚠️ No current slideshow found for previous-slide action')
|
||||
return
|
||||
}
|
||||
|
||||
const prevIndex = (currentShow.props.currentSlideIndex - 1 + currentShow.props.slides.length) % currentShow.props.slides.length
|
||||
const prevSlide = editor.getShape(currentShow.props.slides[prevIndex] as TLParentId) as CCSlideShape
|
||||
|
||||
if (prevSlide) {
|
||||
logger.info('navigation', '⌨️ Previous slide shortcut (regular)', {
|
||||
fromIndex: currentShow.props.currentSlideIndex,
|
||||
toIndex: prevIndex,
|
||||
slideId: prevSlide.id,
|
||||
slideshowId: currentShow.id,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
moveToSlide(editor, prevSlide, false)
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const $slideshows = computed('slideshows', () => getSlideShowsFromPage(editor));
|
||||
|
||||
actions['next-slideshow'] = {
|
||||
id: 'next-slideshow',
|
||||
label: 'Next slideshow',
|
||||
kbd: 'ctrl+right',
|
||||
onSelect() {
|
||||
const slideshows = $slideshows.get();
|
||||
const currentSlideshow = $currentSlideShow.get();
|
||||
const index = slideshows.findIndex((show) => show.id === currentSlideshow?.id);
|
||||
const nextSlideshow = slideshows[index + 1] ?? currentSlideshow ?? slideshows[0];
|
||||
if (nextSlideshow) {
|
||||
editor.stopCameraAnimation();
|
||||
moveToSlideShow(editor, nextSlideshow, false);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
actions['previous-slideshow'] = {
|
||||
id: 'previous-slideshow',
|
||||
label: 'Previous slideshow',
|
||||
kbd: 'ctrl+left',
|
||||
onSelect() {
|
||||
const slideshows = $slideshows.get();
|
||||
const currentSlideshow = $currentSlideShow.get();
|
||||
const index = slideshows.findIndex((show) => show.id === currentSlideshow?.id);
|
||||
const previousSlideshow = slideshows[index - 1] ?? currentSlideshow ?? slideshows[slideshows.length - 1];
|
||||
if (previousSlideshow) {
|
||||
editor.stopCameraAnimation();
|
||||
moveToSlideShow(editor, previousSlideshow, false);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return actions;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { TLUiOverrides } from '@tldraw/tldraw'
|
||||
|
||||
export const toolsRegularUiOverrides: TLUiOverrides = {
|
||||
tools(editor, tools) {
|
||||
const commonTools = {
|
||||
microphone: {
|
||||
id: 'microphone',
|
||||
icon: 'microphone',
|
||||
label: 'Microphone',
|
||||
kbd: 'm',
|
||||
onSelect: () => editor.setCurrentTool('microphone'),
|
||||
},
|
||||
slide: {
|
||||
id: 'cc-slide',
|
||||
icon: 'group',
|
||||
label: 'Slide',
|
||||
kbd: 's',
|
||||
onSelect: () => editor.setCurrentTool('cc-slide'),
|
||||
},
|
||||
}
|
||||
|
||||
const stickerTools = {
|
||||
heartSticker: {
|
||||
id: 'heartSticker',
|
||||
icon: '❤️',
|
||||
label: 'Heart Sticker',
|
||||
kbd: 'h',
|
||||
onSelect: () => editor.setCurrentTool('heartSticker'),
|
||||
},
|
||||
starSticker: {
|
||||
id: 'starSticker',
|
||||
icon: '⭐',
|
||||
label: 'Star Sticker',
|
||||
kbd: 't',
|
||||
onSelect: () => editor.setCurrentTool('starSticker'),
|
||||
},
|
||||
smileySticker: {
|
||||
id: 'smileySticker',
|
||||
icon: '😊',
|
||||
label: 'Smiley Sticker',
|
||||
kbd: 'y',
|
||||
onSelect: () => editor.setCurrentTool('smileySticker'),
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
...tools,
|
||||
...commonTools,
|
||||
...stickerTools,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useCallback, ReactNode } from 'react';
|
||||
import { useEditor, loadSnapshot, useToasts } from '@tldraw/tldraw';
|
||||
import { blankCanvasSnapshot } from '../../../tldraw/assets';
|
||||
|
||||
export function SnapshotToolbar({
|
||||
children
|
||||
}: {
|
||||
children: (props: { resetToBlankCanvas: () => void }) => ReactNode
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const { addToast } = useToasts();
|
||||
|
||||
const resetToBlankCanvas = useCallback(() => {
|
||||
loadSnapshot(editor.store, blankCanvasSnapshot);
|
||||
addToast({
|
||||
title: 'Canvas reset',
|
||||
description: 'The canvas has been reset to blank.',
|
||||
icon: 'reset-zoom',
|
||||
});
|
||||
}, [editor, addToast]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{children({ resetToBlankCanvas })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { useEditor } from '@tldraw/tldraw'
|
||||
import { useNeoUser } from '../../../../contexts/NeoUserContext'
|
||||
import { graphState } from '../../../../utils/tldraw/cc-base/cc-graph/graphStateUtil';
|
||||
import { AllNodeShapes } from '../../../../utils/tldraw/cc-base/cc-graph/cc-graph-shapes';
|
||||
import { createUserNodeFromProfile } from '../../../../utils/tldraw/cc-base/shape-helpers/graph-helpers';
|
||||
|
||||
export function ToolsToolbar({ children }: { children: (props: {
|
||||
handlePutUserNode: () => void,
|
||||
handleAddCalendar: () => void
|
||||
}) => ReactNode }) {
|
||||
const editor = useEditor();
|
||||
const { userNode } = useNeoUser()
|
||||
|
||||
const handlePutUserNode = () => {
|
||||
if (!userNode) {
|
||||
console.error("User node is not available");
|
||||
return;
|
||||
}
|
||||
const existingNode = graphState.getNode(userNode.unique_id);
|
||||
if (!existingNode) {
|
||||
console.log("Adding user node to graphState:", userNode);
|
||||
const centerX = editor.getViewportScreenCenter().x
|
||||
const centerY = editor.getViewportScreenCenter().y
|
||||
|
||||
// Create the user node using the helper function
|
||||
const newShapeId = createUserNodeFromProfile(
|
||||
editor,
|
||||
userNode,
|
||||
centerX,
|
||||
centerY
|
||||
);
|
||||
|
||||
if (newShapeId) {
|
||||
// Get the shape's bounds
|
||||
const {bounds} = editor.getShapeGeometry(newShapeId);
|
||||
console.log("Getting bounds for user shape:", newShapeId);
|
||||
console.log("Updating shape with width:", bounds.w, "and height:", bounds.h);
|
||||
|
||||
// Get the created shape
|
||||
const shape = editor.getShape(newShapeId);
|
||||
if (shape) {
|
||||
// Add to graphState with the correct dimensions
|
||||
graphState.addNode({
|
||||
...shape,
|
||||
props: {
|
||||
...shape.props,
|
||||
w: bounds.w,
|
||||
h: bounds.h
|
||||
}
|
||||
} as AllNodeShapes);
|
||||
graphState.setEditor(editor);
|
||||
graphState.updateShapesWithDagre();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`Node with id ${userNode.unique_id} already exists on the canvas.`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCalendar = () => {
|
||||
editor.setCurrentTool('calendar');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{children({ handlePutUserNode, handleAddCalendar })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../../../contexts/AuthContext';
|
||||
import { ReactNode } from 'react';
|
||||
import { logger } from '../../../../debugConfig';
|
||||
|
||||
export function UserToolbar({ children }: { children: (props: { handleLogout: () => Promise<void>, handleNavUserHome: () => void }) => ReactNode }) {
|
||||
const { signOut } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
logger.info('user-toolbar', '🚪 User logged out, navigating to home');
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
logger.error('user-toolbar', '❌ Logout failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavUserHome = () => {
|
||||
logger.info('user-toolbar', '🏠 Navigating to single player page');
|
||||
navigate('/single-player');
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{children({ handleLogout, handleNavUserHome })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user