muthuk1's picture
Convert OpenMAIC from Next.js to React (Vite)
f56a29b verified
import { useRef, useState, useEffect } from 'react';
import { useCanvasStore } from '@/lib/store/canvas';
import { useSceneSelector } from '@/lib/contexts/scene-context';
import { useKeyboardStore } from '@/lib/store/keyboard';
import { useViewportSize } from './hooks/useViewportSize';
import { useSelectElement } from './hooks/useSelectElement';
import { useDragElement } from './hooks/useDragElement';
import { useRotateElement } from './hooks/useRotateElement';
import { useMouseSelection } from './hooks/useMouseSelection';
import { useScaleElement } from './hooks/useScaleElement';
import { useDragLineElement } from './hooks/useDragLineElement';
import { useMoveShapeKeypoint } from './hooks/useMoveShapeKeypoint';
import { useInsertFromCreateSelection } from './hooks/useInsertFromCreateSelection';
import { useDrop } from './hooks/useDrop';
import { AlignmentLine } from './AlignmentLine';
import { MouseSelection } from './MouseSelection';
import { ViewportBackground } from './ViewportBackground';
import { EditableElement } from './EditableElement';
import { Operate } from './Operate';
import { MultiSelectOperate } from './Operate/MultiSelectOperate';
import { ElementCreateSelection } from './ElementCreateSelection';
import { ShapeCreateCanvas } from './ShapeCreateCanvas';
import { Ruler } from './Ruler';
import { GridLines } from './GridLines';
import type { PPTElement } from '@/lib/types/slides';
import type { AlignmentLineProps } from '@/lib/types/edit';
import type { ContextmenuItem } from './EditableElement';
import type { SlideContent } from '@/lib/types/stage';
import { useCanvasOperations } from '@/lib/hooks/use-canvas-operations';
import {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubTrigger,
ContextMenuSubContent,
ContextMenuShortcut,
ContextMenuItem,
} from '@/components/ui/context-menu';
export interface CanvasProps {
editable?: boolean;
}
/**
* Canvas component
*
* Architecture:
* - Slide data (elements, background) → Scene Context (from stageStore)
* - Local element list → useRef + useState (for drag/scale/rotate operations)
* - Canvas UI state (selection, toolbar) → Canvas Store
* - Keyboard state → Keyboard Store
*
* Usage:
* <SceneProvider>
* <Canvas />
* </SceneProvider>
*/
export function Canvas(_props: CanvasProps) {
const canvasRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
// Subscribe to specific parts for performance optimization
const elements = useSceneSelector<SlideContent, PPTElement[]>(
(content) => content.canvas.elements,
);
// Canvas UI state
const canvasScale = useCanvasStore.use.canvasScale();
const activeElementIdList = useCanvasStore.use.activeElementIdList();
const activeGroupElementId = useCanvasStore.use.activeGroupElementId();
const handleElementId = useCanvasStore.use.handleElementId();
const hiddenElementIdList = useCanvasStore.use.hiddenElementIdList();
const creatingElement = useCanvasStore.use.creatingElement();
const creatingCustomShape = useCanvasStore.use.creatingCustomShape();
const showRuler = useCanvasStore.use.showRuler();
const gridLineSize = useCanvasStore.use.gridLineSize();
const setActiveElementIdList = useCanvasStore.use.setActiveElementIdList();
const setGridLineSize = useCanvasStore.use.setGridLineSize();
const setRulerState = useCanvasStore.use.setRulerState();
// Keyboard state
const spaceKeyState = useKeyboardStore((state) => state.spaceKeyState);
const [alignmentLines, setAlignmentLines] = useState<AlignmentLineProps[]>([]);
const [linkDialogVisible, setLinkDialogVisible] = useState(false);
// Local element list for drag/scale/rotate operations
const elementListRef = useRef<PPTElement[]>(elements || []);
const [elementList, setElementList] = useState<PPTElement[]>(elements || []);
// Sync store elements to local state
useEffect(() => {
const newElements = elements ? JSON.parse(JSON.stringify(elements)) : [];
elementListRef.current = newElements;
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync store elements to local state
setElementList(newElements);
}, [elements]);
// Viewport size and positioning
const { viewportStyles, dragViewport } = useViewportSize(canvasRef);
// Initialize drop handler
useDrop(canvasRef);
// Element drag (with alignment snapping)
const { dragElement } = useDragElement(elementListRef, setElementList, setAlignmentLines);
// Element selection
const { selectElement } = useSelectElement(elementListRef, dragElement);
// Mouse selection
const { mouseSelection, mouseSelectionVisible, mouseSelectionQuadrant, updateMouseSelection } =
useMouseSelection(elementListRef, viewportRef);
// Element operations
const { scaleElement, scaleMultiElement } = useScaleElement(
elementListRef,
setElementList,
setAlignmentLines,
);
const { rotateElement } = useRotateElement(
elementListRef,
setElementList,
viewportRef,
canvasScale,
);
const { dragLineElement } = useDragLineElement(elementListRef, setElementList);
const { moveShapeKeypoint } = useMoveShapeKeypoint(elementListRef, setElementList, canvasScale);
// Create element from selection
const { insertElementFromCreateSelection } = useInsertFromCreateSelection(viewportRef);
// Click on blank canvas area: clear active elements
const handleClickBlankArea = (e: React.MouseEvent) => {
// Check if the click target is a context menu element (menu content in Portal)
const target = e.target as HTMLElement;
if (
target.closest('[data-slot="context-menu-content"]') ||
target.closest('[data-slot="context-menu-sub-content"]') ||
target.closest('[data-slot="context-menu-item"]') ||
target.closest('[data-slot="context-menu-sub-trigger"]')
) {
return; // Skip blank area handling if clicking on context menu
}
if (activeElementIdList.length) {
setActiveElementIdList([]);
}
if (!spaceKeyState) {
updateMouseSelection(e);
} else {
dragViewport(e);
}
};
// Double-click blank area to insert text
const handleDblClick = (_e: React.MouseEvent) => {
if (activeElementIdList.length || creatingElement || creatingCustomShape) return;
if (!viewportRef.current) return;
const _viewportRect = viewportRef.current.getBoundingClientRect();
// TODO: implement createTextElement (use _viewportRect + e.pageX/Y + canvasScale)
};
const openLinkDialog = () => {
setLinkDialogVisible(true);
};
const { pasteElement, selectAllElements, deleteAllElements } = useCanvasOperations();
const contextmenus = (): ContextmenuItem[] => {
return [
{
text: '粘贴',
subText: 'Ctrl + V',
handler: pasteElement,
},
{
text: '全选',
subText: 'Ctrl + A',
handler: selectAllElements,
},
{
text: '标尺',
subText: showRuler ? '√' : '',
handler: () => setRulerState(!showRuler),
},
{
text: '网格线',
handler: () => setGridLineSize(gridLineSize ? 0 : 50),
children: [
{
text: '无',
subText: gridLineSize === 0 ? '√' : '',
handler: () => setGridLineSize(0),
},
{
text: '小',
subText: gridLineSize === 25 ? '√' : '',
handler: () => setGridLineSize(25),
},
{
text: '中',
subText: gridLineSize === 50 ? '√' : '',
handler: () => setGridLineSize(50),
},
{
text: '大',
subText: gridLineSize === 100 ? '√' : '',
handler: () => setGridLineSize(100),
},
],
},
{
text: '重置当前页',
handler: deleteAllElements,
},
];
};
return (
<ContextMenu>
<ContextMenuTrigger>
<div
className="canvas relative h-full w-full overflow-hidden bg-gray-100 select-none"
ref={canvasRef}
onMouseDown={handleClickBlankArea}
onDoubleClick={handleDblClick}
>
{/* Element creation selection */}
{creatingElement && (
<ElementCreateSelection onCreated={insertElementFromCreateSelection} />
)}
{/* Custom shape creation canvas */}
{creatingCustomShape && (
<ShapeCreateCanvas
onCreated={(_data) => {
// TODO: implement insertCustomShape
}}
/>
)}
{/* Viewport wrapper */}
<div
className="viewport-wrapper absolute shadow-[0_0_0_1px_rgba(0,0,0,0.01),0_0_12px_0_rgba(0,0,0,0.1)]"
style={{
width: `${viewportStyles.width * canvasScale}px`,
height: `${viewportStyles.height * canvasScale}px`,
left: `${viewportStyles.left}px`,
top: `${viewportStyles.top}px`,
}}
>
{/* Operations layer - alignment lines and selection handles */}
<div className="operates absolute top-0 left-0 w-full h-full pointer-events-none">
{/* Alignment lines */}
{alignmentLines.map((line, index) => (
<AlignmentLine
key={`${line.type}-${line.axis.x}-${line.axis.y}-${index}`}
type={line.type}
axis={line.axis}
length={line.length}
canvasScale={canvasScale}
/>
))}
{/* Multi-select operations */}
{activeElementIdList.length > 1 && (
<MultiSelectOperate
elementList={elementList}
scaleMultiElement={scaleMultiElement}
/>
)}
{/* Single element operations */}
{elementList.map(
(element: PPTElement) =>
!hiddenElementIdList.includes(element.id) && (
<Operate
key={element.id}
elementInfo={element}
isSelected={activeElementIdList.includes(element.id)}
isActive={handleElementId === element.id}
isActiveGroupElement={activeGroupElementId === element.id}
isMultiSelect={activeElementIdList.length > 1}
rotateElement={rotateElement}
scaleElement={scaleElement}
dragLineElement={dragLineElement}
moveShapeKeypoint={moveShapeKeypoint}
openLinkDialog={openLinkDialog}
/>
),
)}
<ViewportBackground />
</div>
{/* Viewport - the actual slide canvas */}
<div
ref={viewportRef}
className="viewport absolute top-0 left-0 origin-top-left"
style={{
width: `${viewportStyles.width}px`,
height: `${viewportStyles.height}px`,
transform: `scale(${canvasScale})`,
}}
>
{/* Grid lines */}
{gridLineSize > 0 && <GridLines />}
{/* Mouse selection rectangle */}
{mouseSelectionVisible && (
<MouseSelection
top={mouseSelection.top}
left={mouseSelection.left}
width={mouseSelection.width}
height={mouseSelection.height}
quadrant={mouseSelectionQuadrant}
canvasScale={canvasScale}
/>
)}
{/* Render all elements */}
{elementList.map((element: PPTElement, index: number) =>
!hiddenElementIdList.includes(element.id) ? (
<EditableElement
key={element.id}
elementInfo={element}
elementIndex={index + 1}
isMultiSelect={activeElementIdList.length > 1}
selectElement={selectElement}
openLinkDialog={openLinkDialog}
/>
) : null,
)}
</div>
</div>
{/* Ruler */}
{showRuler && <Ruler viewportStyles={viewportStyles} elementList={elementList} />}
{/* Drag mask when space key is pressed */}
{spaceKeyState && <div className="drag-mask absolute inset-0 cursor-grab" />}
{/* TODO: Add LinkDialog modal */}
{linkDialogVisible && <div>LinkDialog placeholder</div>}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{contextmenus().map((item, index) => {
if (item.divider) {
return <ContextMenuSeparator key={index} />;
}
// If has children, use submenu component
if (item.children && item.children.length > 0) {
return (
<ContextMenuSub key={index}>
<ContextMenuSubTrigger disabled={item.disable} hidden={item.hide}>
{item.text}
{item.subText && <ContextMenuShortcut>{item.subText}</ContextMenuShortcut>}
</ContextMenuSubTrigger>
<ContextMenuSubContent>
{item.children.map((child, childIndex) =>
child.divider ? (
<ContextMenuSeparator key={childIndex} />
) : (
<ContextMenuItem
key={childIndex}
onClick={(e) => {
e.stopPropagation();
child.handler?.();
}}
disabled={child.disable}
hidden={child.hide}
>
{child.text}
{child.subText && (
<ContextMenuShortcut>{child.subText}</ContextMenuShortcut>
)}
</ContextMenuItem>
),
)}
</ContextMenuSubContent>
</ContextMenuSub>
);
}
// Regular menu item
return (
<ContextMenuItem
key={index}
onClick={(e) => {
e.stopPropagation();
item.handler?.();
}}
disabled={item.disable}
hidden={item.hide}
>
{item.text}
{item.subText && <ContextMenuShortcut>{item.subText}</ContextMenuShortcut>}
</ContextMenuItem>
);
})}
</ContextMenuContent>
</ContextMenu>
);
}
export default Canvas;