| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404 |
- import { useCallback } from 'react';
- import { useCanvasStore, useKeyboardStore } from '@/lib/store';
- import { useHistorySnapshot } from '@/lib/hooks/use-history-snapshot';
- import type { PPTElement } from '@/lib/types/slides';
- import type { AlignmentLineProps } from '@/lib/types/edit';
- import { getRectRotatedRange, uniqAlignLines, type AlignLine } from '@/lib/utils/element';
- import { useCanvasOperations } from '@/lib/hooks/use-canvas-operations';
- /**
- * Drag element hook
- *
- * @param elementListRef - Element list ref (holds latest value)
- * @param setElementList - Element list setter (triggers re-render)
- * @param setAlignmentLines - Alignment lines setter
- */
- export function useDragElement(
- elementListRef: React.RefObject<PPTElement[]>,
- setElementList: React.Dispatch<React.SetStateAction<PPTElement[]>>,
- setAlignmentLines: React.Dispatch<React.SetStateAction<AlignmentLineProps[]>>,
- ) {
- const activeElementIdList = useCanvasStore.use.activeElementIdList();
- const activeGroupElementId = useCanvasStore.use.activeGroupElementId();
- const canvasScale = useCanvasStore.use.canvasScale();
- const shiftKeyState = useKeyboardStore((state) => state.shiftKeyState);
- const viewportRatio = useCanvasStore.use.viewportRatio();
- const viewportSize = useCanvasStore.use.viewportSize();
- const updateSlide = useCanvasOperations().updateSlide;
- const { addHistorySnapshot } = useHistorySnapshot();
- const dragElement = useCallback(
- (e: React.MouseEvent | React.TouchEvent, element: PPTElement) => {
- const native = e.nativeEvent;
- const isTouchEvent = native instanceof TouchEvent;
- if (isTouchEvent && !native.changedTouches?.length) return;
- if (!activeElementIdList.includes(element.id)) return;
- let isMouseDown = true;
- const edgeWidth = viewportSize;
- const edgeHeight = viewportSize * viewportRatio;
- const sorptionRange = 5;
- // Save original element list for computing multi-select offsets
- const originElementList: PPTElement[] = JSON.parse(JSON.stringify(elementListRef.current));
- const originActiveElementList = originElementList.filter((el) =>
- activeElementIdList.includes(el.id),
- );
- const elOriginLeft = element.left;
- const elOriginTop = element.top;
- const elOriginWidth = element.width;
- const elOriginHeight = 'height' in element && element.height ? element.height : 0;
- const elOriginRotate = 'rotate' in element && element.rotate ? element.rotate : 0;
- const startPageX = isTouchEvent ? native.changedTouches[0].pageX : native.pageX;
- const startPageY = isTouchEvent ? native.changedTouches[0].pageY : native.pageY;
- let isMisoperation: boolean | null = null;
- const isActiveGroupElement = element.id === activeGroupElementId;
- // Collect alignment snap lines
- // Includes snap positions of other elements on canvas (excluding the target): top/bottom/left/right edges, horizontal/vertical centers
- // Lines and rotated elements need their bounding ranges recalculated
- let horizontalLines: AlignLine[] = [];
- let verticalLines: AlignLine[] = [];
- for (const el of elementListRef.current) {
- if (el.type === 'line') continue;
- if (isActiveGroupElement && el.id === element.id) continue;
- if (!isActiveGroupElement && activeElementIdList.includes(el.id)) continue;
- let left, top, width, height;
- if ('rotate' in el && el.rotate) {
- const { xRange, yRange } = getRectRotatedRange({
- left: el.left,
- top: el.top,
- width: el.width,
- height: el.height,
- rotate: el.rotate,
- });
- left = xRange[0];
- top = yRange[0];
- width = xRange[1] - xRange[0];
- height = yRange[1] - yRange[0];
- } else {
- left = el.left;
- top = el.top;
- width = el.width;
- height = el.height;
- }
- const right = left + width;
- const bottom = top + height;
- const centerX = top + height / 2;
- const centerY = left + width / 2;
- const topLine: AlignLine = { value: top, range: [left, right] };
- const bottomLine: AlignLine = { value: bottom, range: [left, right] };
- const horizontalCenterLine: AlignLine = {
- value: centerX,
- range: [left, right],
- };
- const leftLine: AlignLine = { value: left, range: [top, bottom] };
- const rightLine: AlignLine = { value: right, range: [top, bottom] };
- const verticalCenterLine: AlignLine = {
- value: centerY,
- range: [top, bottom],
- };
- horizontalLines.push(topLine, bottomLine, horizontalCenterLine);
- verticalLines.push(leftLine, rightLine, verticalCenterLine);
- }
- // Canvas viewport edges: four boundaries, horizontal center, vertical center
- const edgeTopLine: AlignLine = { value: 0, range: [0, edgeWidth] };
- const edgeBottomLine: AlignLine = {
- value: edgeHeight,
- range: [0, edgeWidth],
- };
- const edgeHorizontalCenterLine: AlignLine = {
- value: edgeHeight / 2,
- range: [0, edgeWidth],
- };
- const edgeLeftLine: AlignLine = { value: 0, range: [0, edgeHeight] };
- const edgeRightLine: AlignLine = {
- value: edgeWidth,
- range: [0, edgeHeight],
- };
- const edgeVerticalCenterLine: AlignLine = {
- value: edgeWidth / 2,
- range: [0, edgeHeight],
- };
- horizontalLines.push(edgeTopLine, edgeBottomLine, edgeHorizontalCenterLine);
- verticalLines.push(edgeLeftLine, edgeRightLine, edgeVerticalCenterLine);
- // Deduplicate alignment snap lines
- horizontalLines = uniqAlignLines(horizontalLines);
- verticalLines = uniqAlignLines(verticalLines);
- const handleMouseMove = (e: MouseEvent | TouchEvent) => {
- const currentPageX = e instanceof MouseEvent ? e.pageX : e.changedTouches[0].pageX;
- const currentPageY = e instanceof MouseEvent ? e.pageY : e.changedTouches[0].pageY;
- // If mouse movement is too small, consider it a misoperation:
- // null = first move, need to check; true = still in misoperation range; false = moved beyond range
- if (isMisoperation !== false) {
- isMisoperation =
- Math.abs(startPageX - currentPageX) < sorptionRange &&
- Math.abs(startPageY - currentPageY) < sorptionRange;
- }
- if (!isMouseDown || isMisoperation) return;
- let moveX = (currentPageX - startPageX) / canvasScale;
- let moveY = (currentPageY - startPageY) / canvasScale;
- // Lock to horizontal or vertical direction when Shift is held
- if (shiftKeyState) {
- if (Math.abs(moveX) > Math.abs(moveY)) moveY = 0;
- if (Math.abs(moveX) < Math.abs(moveY)) moveX = 0;
- }
- // Base target position
- let targetLeft = elOriginLeft + moveX;
- let targetTop = elOriginTop + moveY;
- // Calculate target element's bounding range on canvas for alignment snapping
- // Must distinguish single-select vs multi-select; single-select further distinguishes line, normal, and rotated elements
- let targetMinX: number, targetMaxX: number, targetMinY: number, targetMaxY: number;
- if (activeElementIdList.length === 1 || isActiveGroupElement) {
- if (elOriginRotate) {
- const { xRange, yRange } = getRectRotatedRange({
- left: targetLeft,
- top: targetTop,
- width: elOriginWidth,
- height: elOriginHeight,
- rotate: elOriginRotate,
- });
- targetMinX = xRange[0];
- targetMaxX = xRange[1];
- targetMinY = yRange[0];
- targetMaxY = yRange[1];
- } else if (element.type === 'line') {
- targetMinX = targetLeft;
- targetMaxX = targetLeft + Math.max(element.start[0], element.end[0]);
- targetMinY = targetTop;
- targetMaxY = targetTop + Math.max(element.start[1], element.end[1]);
- } else {
- targetMinX = targetLeft;
- targetMaxX = targetLeft + elOriginWidth;
- targetMinY = targetTop;
- targetMaxY = targetTop + elOriginHeight;
- }
- } else {
- const leftValues = [];
- const topValues = [];
- const rightValues = [];
- const bottomValues = [];
- for (let i = 0; i < originActiveElementList.length; i++) {
- const element = originActiveElementList[i];
- const left = element.left + moveX;
- const top = element.top + moveY;
- const width = element.width;
- const height = 'height' in element && element.height ? element.height : 0;
- const rotate = 'rotate' in element && element.rotate ? element.rotate : 0;
- if ('rotate' in element && element.rotate) {
- const { xRange, yRange } = getRectRotatedRange({
- left,
- top,
- width,
- height,
- rotate,
- });
- leftValues.push(xRange[0]);
- topValues.push(yRange[0]);
- rightValues.push(xRange[1]);
- bottomValues.push(yRange[1]);
- } else if (element.type === 'line') {
- leftValues.push(left);
- topValues.push(top);
- rightValues.push(left + Math.max(element.start[0], element.end[0]));
- bottomValues.push(top + Math.max(element.start[1], element.end[1]));
- } else {
- leftValues.push(left);
- topValues.push(top);
- rightValues.push(left + width);
- bottomValues.push(top + height);
- }
- }
- targetMinX = Math.min(...leftValues);
- targetMaxX = Math.max(...rightValues);
- targetMinY = Math.min(...topValues);
- targetMaxY = Math.max(...bottomValues);
- }
- const targetCenterX = targetMinX + (targetMaxX - targetMinX) / 2;
- const targetCenterY = targetMinY + (targetMaxY - targetMinY) / 2;
- // Compare alignment snap lines with target position; auto-correct when difference is within threshold
- // Horizontal and vertical directions are calculated separately
- const _alignmentLines: AlignmentLineProps[] = [];
- let isVerticalAdsorbed = false;
- let isHorizontalAdsorbed = false;
- for (let i = 0; i < horizontalLines.length; i++) {
- const { value, range } = horizontalLines[i];
- const min = Math.min(...range, targetMinX, targetMaxX);
- const max = Math.max(...range, targetMinX, targetMaxX);
- if (Math.abs(targetMinY - value) < sorptionRange && !isHorizontalAdsorbed) {
- targetTop = targetTop - (targetMinY - value);
- isHorizontalAdsorbed = true;
- _alignmentLines.push({
- type: 'horizontal',
- axis: { x: min - 50, y: value },
- length: max - min + 100,
- });
- }
- if (Math.abs(targetMaxY - value) < sorptionRange && !isHorizontalAdsorbed) {
- targetTop = targetTop - (targetMaxY - value);
- isHorizontalAdsorbed = true;
- _alignmentLines.push({
- type: 'horizontal',
- axis: { x: min - 50, y: value },
- length: max - min + 100,
- });
- }
- if (Math.abs(targetCenterY - value) < sorptionRange && !isHorizontalAdsorbed) {
- targetTop = targetTop - (targetCenterY - value);
- isHorizontalAdsorbed = true;
- _alignmentLines.push({
- type: 'horizontal',
- axis: { x: min - 50, y: value },
- length: max - min + 100,
- });
- }
- }
- for (let i = 0; i < verticalLines.length; i++) {
- const { value, range } = verticalLines[i];
- const min = Math.min(...range, targetMinY, targetMaxY);
- const max = Math.max(...range, targetMinY, targetMaxY);
- if (Math.abs(targetMinX - value) < sorptionRange && !isVerticalAdsorbed) {
- targetLeft = targetLeft - (targetMinX - value);
- isVerticalAdsorbed = true;
- _alignmentLines.push({
- type: 'vertical',
- axis: { x: value, y: min - 50 },
- length: max - min + 100,
- });
- }
- if (Math.abs(targetMaxX - value) < sorptionRange && !isVerticalAdsorbed) {
- targetLeft = targetLeft - (targetMaxX - value);
- isVerticalAdsorbed = true;
- _alignmentLines.push({
- type: 'vertical',
- axis: { x: value, y: min - 50 },
- length: max - min + 100,
- });
- }
- if (Math.abs(targetCenterX - value) < sorptionRange && !isVerticalAdsorbed) {
- targetLeft = targetLeft - (targetCenterX - value);
- isVerticalAdsorbed = true;
- _alignmentLines.push({
- type: 'vertical',
- axis: { x: value, y: min - 50 },
- length: max - min + 100,
- });
- }
- }
- setAlignmentLines(_alignmentLines);
- let newElements: PPTElement[];
- // In single-select mode or when the active group element is being operated, only update that element's position
- if (activeElementIdList.length === 1 || isActiveGroupElement) {
- newElements = elementListRef.current.map((el) => {
- if (el.id === element.id) {
- return { ...el, left: targetLeft, top: targetTop };
- }
- return el;
- });
- }
- // In multi-select mode, also update positions of other selected elements
- // Their positions are calculated from the movement offset of the handle element
- else {
- const handleElement = elementListRef.current.find((el) => el.id === element.id);
- if (!handleElement) return;
- newElements = elementListRef.current.map((el) => {
- if (activeElementIdList.includes(el.id)) {
- if (el.id === element.id) {
- return { ...el, left: targetLeft, top: targetTop };
- }
- return {
- ...el,
- left: el.left + (targetLeft - handleElement.left),
- top: el.top + (targetTop - handleElement.top),
- };
- }
- return el;
- });
- }
- // Update both ref (latest value) and state (trigger re-render)
- elementListRef.current = newElements;
- setElementList(newElements);
- };
- const handleMouseUp = (e: MouseEvent | TouchEvent) => {
- isMouseDown = false;
- document.ontouchmove = null;
- document.ontouchend = null;
- document.onmousemove = null;
- document.onmouseup = null;
- setAlignmentLines([]);
- const currentPageX = e instanceof MouseEvent ? e.pageX : e.changedTouches[0].pageX;
- const currentPageY = e instanceof MouseEvent ? e.pageY : e.changedTouches[0].pageY;
- if (startPageX === currentPageX && startPageY === currentPageY) return;
- updateSlide({ elements: elementListRef.current });
- addHistorySnapshot();
- };
- if (isTouchEvent) {
- document.ontouchmove = handleMouseMove;
- document.ontouchend = handleMouseUp;
- } else {
- document.onmousemove = handleMouseMove;
- document.onmouseup = handleMouseUp;
- }
- },
- [
- activeElementIdList,
- activeGroupElementId,
- shiftKeyState,
- canvasScale,
- elementListRef,
- setElementList,
- setAlignmentLines,
- updateSlide,
- addHistorySnapshot,
- viewportRatio,
- viewportSize,
- ],
- );
- return {
- dragElement,
- };
- }
|