| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587 |
- /**
- * Canvas Element Operations Hook
- *
- * Provides convenient element CRUD methods to avoid repetitive definitions in each component
- *
- * @example
- * function MyComponent() {
- * const { addElement, updateElement, deleteElement } = useCanvasOperations();
- *
- * const handleAdd = () => {
- * addElement({
- * id: 'new-1',
- * type: 'text',
- * // ...
- * });
- * };
- * }
- */
- import { useSceneData, useSceneSelector } from '@/lib/contexts/scene-context';
- import {
- useCanvasStore,
- type SpotlightOptions,
- type HighlightOverlayOptions,
- } from '@/lib/store/canvas';
- import type { SlideContent } from '@/lib/types/stage';
- import type { PPTElement, Slide } from '@/lib/types/slides';
- import { useCallback, useMemo } from 'react';
- import { useHistorySnapshot } from '@/lib/hooks/use-history-snapshot';
- import { toast } from 'sonner';
- import { ElementAlignCommands, ElementOrderCommands } from '@/lib/types/edit';
- import { getElementListRange } from '@/lib/utils/element';
- import { useOrderElement } from './use-order-element';
- import { nanoid } from 'nanoid';
- type PPTElementKey = keyof PPTElement;
- interface RemovePropData {
- id: string;
- propName: PPTElementKey | PPTElementKey[];
- }
- interface UpdateElementData {
- id: string | string[];
- props: Partial<PPTElement>;
- slideId?: string;
- }
- export function useCanvasOperations() {
- const { updateSceneData } = useSceneData<SlideContent>();
- const currentSlide = useSceneSelector<SlideContent, Slide>((content) => content.canvas);
- const activeElementIdList = useCanvasStore.use.activeElementIdList();
- const activeElementList = useMemo(
- () => currentSlide.elements.filter((el) => activeElementIdList.includes(el.id)),
- [currentSlide.elements, activeElementIdList],
- );
- const activeGroupElementId = useCanvasStore.use.activeGroupElementId();
- const setActiveElementIdList = useCanvasStore.use.setActiveElementIdList();
- const handleElementId = useCanvasStore.use.handleElementId();
- const hiddenElementIdList = useCanvasStore.use.hiddenElementIdList();
- const viewportSize = useCanvasStore.use.viewportSize();
- const viewportRatio = useCanvasStore.use.viewportRatio();
- const _setEditorareaFocus = useCanvasStore.use.setEditorAreaFocus();
- const { addHistorySnapshot } = useHistorySnapshot();
- const { moveUpElement, moveDownElement, moveTopElement, moveBottomElement } = useOrderElement();
- /**
- * Add element(s)
- * @param element Single element or element array
- * @param autoSelect Whether to auto-select newly added elements (default true)
- */
- const addElement = useCallback(
- (element: PPTElement | PPTElement[], autoSelect = true) => {
- const elements = Array.isArray(element) ? element : [element];
- updateSceneData((draft) => {
- draft.canvas.elements.push(...elements);
- });
- // Auto-select newly added elements
- if (autoSelect) {
- const newIds = elements.map((el) => el.id);
- setActiveElementIdList(newIds);
- }
- },
- [updateSceneData, setActiveElementIdList],
- );
- // Delete all selected elements
- // If a group member is selected for independent operation, delete that element first. Otherwise delete all selected elements.
- // If elementId is provided, only delete that element
- const deleteElement = (elementId?: string) => {
- let newElementList: PPTElement[] = [];
- if (elementId) {
- // Delete specified element
- newElementList = currentSlide.elements.filter((el) => el.id !== elementId);
- setActiveElementIdList(activeElementIdList.filter((id) => id !== elementId));
- } else {
- // Original logic: delete selected elements
- if (!activeElementIdList.length) return;
- if (activeGroupElementId) {
- newElementList = currentSlide.elements.filter((el) => el.id !== activeGroupElementId);
- } else {
- newElementList = currentSlide.elements.filter((el) => !activeElementIdList.includes(el.id));
- }
- setActiveElementIdList([]);
- }
- updateSlide({ elements: newElementList });
- addHistorySnapshot();
- };
- // Delete all elements on the page (regardless of selection)
- const deleteAllElements = () => {
- if (!currentSlide.elements.length) return;
- setActiveElementIdList([]);
- updateSlide({ elements: [] });
- addHistorySnapshot();
- };
- /**
- * Update element properties
- * @param props Properties to update
- */
- const updateElement = useCallback(
- (data: UpdateElementData) => {
- const { id, props } = data;
- const elementIds = Array.isArray(id) ? id : [id];
- updateSceneData((draft) => {
- draft.canvas.elements.forEach((el) => {
- if (elementIds.includes(el.id)) {
- Object.assign(el, props);
- }
- });
- });
- },
- [updateSceneData],
- );
- /**
- * Update slide content
- */
- const updateSlide = useCallback(
- (props: Partial<Slide>) => {
- updateSceneData((draft) => {
- Object.assign(draft.canvas, props);
- });
- },
- [updateSceneData],
- );
- /**
- * Remove element properties
- */
- const removeElementProps = useCallback(
- (data: RemovePropData) => {
- const { id, propName } = data;
- const elementIds = Array.isArray(id) ? id : [id];
- const propNames = Array.isArray(propName) ? propName : [propName];
- updateSceneData((draft) => {
- draft.canvas.elements.forEach((el) => {
- if (elementIds.includes(el.id)) {
- propNames.forEach((name) => {
- delete el[name];
- });
- }
- });
- });
- },
- [updateSceneData],
- );
- // Copy selected element data to clipboard
- const copyElement = () => {
- // if (!activeElementIdList.length) return
- // const text = JSON.stringify({
- // type: 'elements',
- // data: activeElementList,
- // })
- // copyText(text).then(() => {
- // setEditorareaFocus(true)
- // })
- toast.warning('Not implemented');
- };
- // Copy and delete selected elements (cut)
- const cutElement = () => {
- // copyElement()
- // deleteElement()
- toast.warning('Not implemented');
- };
- // Attempt to paste element data from clipboard
- const pasteElement = () => {
- // readClipboard().then(text => {
- // pasteTextClipboardData(text)
- // }).catch(err => toast.warning(err))
- toast.warning('Not implemented');
- };
- // Copy and immediately paste selected elements
- const _quickCopyElement = () => {
- copyElement();
- pasteElement();
- };
- // Lock selected elements and clear selection state
- const lockElement = () => {
- const newElementList: PPTElement[] = JSON.parse(JSON.stringify(currentSlide.elements));
- for (const element of newElementList) {
- if (activeElementIdList.includes(element.id)) element.lock = true;
- }
- updateSlide({ elements: newElementList });
- setActiveElementIdList([]);
- addHistorySnapshot();
- };
- /**
- * Unlock an element and set it as the current selection
- * @param handleElement The element to unlock
- */
- const unlockElement = (handleElement: PPTElement) => {
- const newElementList: PPTElement[] = JSON.parse(JSON.stringify(currentSlide.elements));
- if (handleElement.groupId) {
- const groupElementIdList = [];
- for (const element of newElementList) {
- if (element.groupId === handleElement.groupId) {
- element.lock = false;
- groupElementIdList.push(element.id);
- }
- }
- updateSlide({ elements: newElementList });
- setActiveElementIdList(groupElementIdList);
- } else {
- for (const element of newElementList) {
- if (element.id === handleElement.id) {
- element.lock = false;
- break;
- }
- }
- updateSlide({ elements: newElementList });
- setActiveElementIdList([handleElement.id]);
- }
- addHistorySnapshot();
- };
- // Select all elements on the current page
- const selectAllElements = () => {
- const unlockedElements = currentSlide.elements.filter(
- (el) => !el.lock && !hiddenElementIdList.includes(el.id),
- );
- const newActiveElementIdList = unlockedElements.map((el) => el.id);
- setActiveElementIdList(newActiveElementIdList);
- };
- // Select a specific element
- const selectElement = (id: string) => {
- if (handleElementId === id) return;
- if (hiddenElementIdList.includes(id)) return;
- const lockedElements = currentSlide.elements.filter((el) => el.lock);
- if (lockedElements.some((el) => el.id === id)) return;
- setActiveElementIdList([id]);
- };
- /**
- * Align all selected elements to the canvas
- * @param command Alignment direction
- */
- const alignElementToCanvas = (command: ElementAlignCommands) => {
- const viewportWidth = viewportSize;
- const viewportHeight = viewportSize * viewportRatio;
- const { minX, maxX, minY, maxY } = getElementListRange(activeElementList);
- const newElementList: PPTElement[] = JSON.parse(JSON.stringify(currentSlide.elements));
- for (const element of newElementList) {
- if (!activeElementIdList.includes(element.id)) continue;
- // Center horizontally and vertically
- if (command === ElementAlignCommands.CENTER) {
- const offsetY = minY + (maxY - minY) / 2 - viewportHeight / 2;
- const offsetX = minX + (maxX - minX) / 2 - viewportWidth / 2;
- element.top = element.top - offsetY;
- element.left = element.left - offsetX;
- }
- // Align to top
- if (command === ElementAlignCommands.TOP) {
- const offsetY = minY - 0;
- element.top = element.top - offsetY;
- }
- // Center vertically
- else if (command === ElementAlignCommands.VERTICAL) {
- const offsetY = minY + (maxY - minY) / 2 - viewportHeight / 2;
- element.top = element.top - offsetY;
- }
- // Align to bottom
- else if (command === ElementAlignCommands.BOTTOM) {
- const offsetY = maxY - viewportHeight;
- element.top = element.top - offsetY;
- }
- // Align to left
- else if (command === ElementAlignCommands.LEFT) {
- const offsetX = minX - 0;
- element.left = element.left - offsetX;
- }
- // Center horizontally
- else if (command === ElementAlignCommands.HORIZONTAL) {
- const offsetX = minX + (maxX - minX) / 2 - viewportWidth / 2;
- element.left = element.left - offsetX;
- }
- // Align to right
- else if (command === ElementAlignCommands.RIGHT) {
- const offsetX = maxX - viewportWidth;
- element.left = element.left - offsetX;
- }
- }
- updateSlide({ elements: newElementList });
- addHistorySnapshot();
- };
- /**
- * Adjust element z-order
- * @param element The element to reorder
- * @param command Reorder command: move up, move down, bring to front, send to back
- */
- const orderElement = (element: PPTElement, command: ElementOrderCommands) => {
- let newElementList;
- if (command === ElementOrderCommands.UP)
- newElementList = moveUpElement(currentSlide.elements, element);
- else if (command === ElementOrderCommands.DOWN)
- newElementList = moveDownElement(currentSlide.elements, element);
- else if (command === ElementOrderCommands.TOP)
- newElementList = moveTopElement(currentSlide.elements, element);
- else if (command === ElementOrderCommands.BOTTOM)
- newElementList = moveBottomElement(currentSlide.elements, element);
- if (!newElementList) return;
- updateSlide({ elements: newElementList });
- addHistorySnapshot();
- };
- /**
- * Check if current selected elements can be grouped
- */
- const _canCombine = useMemo(() => {
- if (activeElementList.length < 2) return false;
- const firstGroupId = activeElementList[0].groupId;
- if (!firstGroupId) return true;
- const inSameGroup = activeElementList.every((el) => el.groupId && el.groupId === firstGroupId);
- return !inSameGroup;
- }, [activeElementList]);
- /**
- * Group current selected elements: assign the same group ID to all selected elements
- */
- const combineElements = () => {
- if (!activeElementList.length) return;
- // Create a new element list for subsequent operations
- let newElementList: PPTElement[] = JSON.parse(JSON.stringify(currentSlide.elements));
- // Generate group ID
- const groupId = nanoid(10);
- // Collect elements to be grouped and assign the unique group ID
- const combineElementList: PPTElement[] = [];
- for (const element of newElementList) {
- if (activeElementIdList.includes(element.id)) {
- element.groupId = groupId;
- combineElementList.push(element);
- }
- }
- // Ensure all group members have consecutive z-order levels:
- // First find the highest z-level member, remove all group members from the element list,
- // then insert the collected group members back at the appropriate position based on the highest level
- const combineElementMaxLevel = newElementList.findIndex(
- (_element) => _element.id === combineElementList[combineElementList.length - 1].id,
- );
- const combineElementIdList = combineElementList.map((_element) => _element.id);
- newElementList = newElementList.filter(
- (_element) => !combineElementIdList.includes(_element.id),
- );
- const insertLevel = combineElementMaxLevel - combineElementList.length + 1;
- newElementList.splice(insertLevel, 0, ...combineElementList);
- updateSlide({ elements: newElementList });
- addHistorySnapshot();
- };
- /**
- * Ungroup elements: remove the group ID from selected elements
- */
- const uncombineElements = () => {
- if (!activeElementList.length) return;
- const hasElementInGroup = activeElementList.some((item) => item.groupId);
- if (!hasElementInGroup) return;
- const newElementList: PPTElement[] = JSON.parse(JSON.stringify(currentSlide.elements));
- for (const element of newElementList) {
- if (activeElementIdList.includes(element.id) && element.groupId) delete element.groupId;
- }
- updateSlide({ elements: newElementList });
- // After ungrouping, reset active element state
- // Default to the currently handled element, or empty if none exists
- const handleElementIdList = handleElementId ? [handleElementId] : [];
- setActiveElementIdList(handleElementIdList);
- addHistorySnapshot();
- };
- /**
- * Update background
- * @param background New background settings
- */
- const updateBackground = useCallback(
- (background: SlideContent['canvas']['background']) => {
- updateSceneData((draft) => {
- draft.canvas.background = background;
- });
- },
- [updateSceneData],
- );
- /**
- * Update theme
- * @param theme Theme settings (partial)
- */
- const updateTheme = useCallback(
- (theme: Partial<SlideContent['canvas']['theme']>) => {
- updateSceneData((draft) => {
- draft.canvas.theme = {
- ...draft.canvas.theme,
- ...theme,
- };
- });
- },
- [updateSceneData],
- );
- /**
- * Spotlight focus on an element
- * @param elementId Element ID
- * @param options Spotlight options
- */
- const spotlightElement = useCallback((elementId: string, options?: SpotlightOptions) => {
- useCanvasStore.getState().setSpotlight(elementId, options);
- }, []);
- /**
- * Clear spotlight
- */
- const clearSpotlight = useCallback(() => {
- useCanvasStore.getState().clearSpotlight();
- }, []);
- /**
- * Highlight elements
- * @param elementIds Element ID list
- * @param options Highlight options
- */
- const highlightElements = useCallback(
- (elementIds: string[], options?: HighlightOverlayOptions) => {
- useCanvasStore.getState().setHighlight(elementIds, options);
- },
- [],
- );
- /**
- * Clear highlight
- */
- const clearHighlight = useCallback(() => {
- useCanvasStore.getState().clearHighlight();
- }, []);
- /**
- * Laser pointer effect
- * @param elementId Element ID
- * @param options Laser pointer options
- */
- const laserElement = useCallback(
- (elementId: string, options?: { color?: string; duration?: number }) => {
- useCanvasStore.getState().setLaser(elementId, options);
- },
- [],
- );
- /**
- * Clear laser pointer
- */
- const clearLaser = useCallback(() => {
- useCanvasStore.getState().clearLaser();
- }, []);
- /**
- * Zoom an element
- * @param elementId Element ID
- * @param scale Zoom scale
- */
- const zoomElement = useCallback((elementId: string, scale: number) => {
- useCanvasStore.getState().setZoom(elementId, scale);
- }, []);
- /**
- * Clear zoom
- */
- const clearZoom = useCallback(() => {
- useCanvasStore.getState().clearZoom();
- }, []);
- /**
- * Clear all teaching effects (spotlight + highlight + laser + zoom)
- */
- const clearAllEffects = useCallback(() => {
- useCanvasStore.getState().clearSpotlight();
- useCanvasStore.getState().clearHighlight();
- useCanvasStore.getState().clearLaser();
- useCanvasStore.getState().clearZoom();
- }, []);
- return {
- // Basic operations
- addElement,
- deleteElement,
- deleteAllElements,
- updateElement,
- updateSlide,
- removeElementProps,
- copyElement,
- pasteElement,
- cutElement,
- // Advanced operations
- lockElement,
- unlockElement,
- selectAllElements,
- selectElement,
- alignElementToCanvas,
- orderElement,
- combineElements,
- uncombineElements,
- // Canvas operations
- updateBackground,
- updateTheme,
- // Teaching features
- spotlightElement,
- clearSpotlight,
- highlightElements,
- clearHighlight,
- laserElement,
- clearLaser,
- zoomElement,
- clearZoom,
- clearAllEffects,
- };
- }
- // Export type
- export type CanvasOperations = ReturnType<typeof useCanvasOperations>;
|