416 lines
15 KiB
TypeScript
416 lines
15 KiB
TypeScript
'use client';
|
|
|
|
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 '@openmaic/dsl';
|
|
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;
|