ShapeCreateCanvas.tsx 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
  2. import { useKeyboardStore } from '@/lib/store/keyboard';
  3. import { useCanvasStore, useSceneSelector } from '@/lib/store';
  4. import type { CreateCustomShapeData } from '@/lib/types/edit';
  5. import type { SlideContent } from '@/lib/types/stage';
  6. import type { SlideTheme } from '@/lib/types/slides';
  7. import { toast } from 'sonner';
  8. interface ShapeCreateCanvasProps {
  9. onCreated: (data: CreateCustomShapeData) => void;
  10. }
  11. export function ShapeCreateCanvas({ onCreated }: ShapeCreateCanvasProps) {
  12. const ctrlOrShiftKeyActive = useKeyboardStore((state) => state.ctrlOrShiftKeyActive());
  13. const setCreatingCustomShapeState = useCanvasStore.use.setCreatingCustomShapeState();
  14. const theme = useSceneSelector<SlideContent, SlideTheme>((content) => content.canvas.theme);
  15. const shapeCanvasRef = useRef<HTMLDivElement>(null);
  16. const [isMouseDown, setIsMouseDown] = useState(false);
  17. const [offset, setOffset] = useState({ x: 0, y: 0 });
  18. const [mousePosition, setMousePosition] = useState<[number, number] | null>(null);
  19. const [points, setPoints] = useState<[number, number][]>([]);
  20. const [closed, setClosed] = useState(false);
  21. const close = useCallback(() => {
  22. setCreatingCustomShapeState(false);
  23. }, [setCreatingCustomShapeState]);
  24. const getCreateData = useCallback(
  25. (closeShape = true) => {
  26. const xList = points.map((item) => item[0]);
  27. const yList = points.map((item) => item[1]);
  28. const minX = Math.min(...xList);
  29. const minY = Math.min(...yList);
  30. const maxX = Math.max(...xList);
  31. const maxY = Math.max(...yList);
  32. const formatedPoints = points.map((point) => {
  33. return [point[0] - minX, point[1] - minY];
  34. });
  35. let pathStr = '';
  36. for (let i = 0; i < formatedPoints.length; i++) {
  37. const point = formatedPoints[i];
  38. if (i === 0) pathStr += `M ${point[0]} ${point[1]} `;
  39. else pathStr += `L ${point[0]} ${point[1]} `;
  40. }
  41. if (closeShape) pathStr += 'Z';
  42. const start: [number, number] = [minX + offset.x, minY + offset.y];
  43. const end: [number, number] = [maxX + offset.x, maxY + offset.y];
  44. const viewBox: [number, number] = [maxX - minX, maxY - minY];
  45. return {
  46. start,
  47. end,
  48. path: pathStr,
  49. viewBox,
  50. };
  51. },
  52. [points, offset],
  53. );
  54. const create = useCallback(() => {
  55. onCreated({
  56. ...getCreateData(false),
  57. fill: 'rgba(0, 0, 0, 0)',
  58. outline: {
  59. width: 2,
  60. color: theme.themeColors[0],
  61. style: 'solid',
  62. },
  63. });
  64. close();
  65. }, [onCreated, getCreateData, theme, close]);
  66. useEffect(() => {
  67. if (!shapeCanvasRef.current) return;
  68. const { x, y } = shapeCanvasRef.current.getBoundingClientRect();
  69. setOffset({ x, y });
  70. // Show instruction toast
  71. toast.info(
  72. 'Click to draw any shape, close the path to finish, press ESC or right-click to cancel, press ENTER to finish early',
  73. );
  74. const handleKeyDown = (e: KeyboardEvent) => {
  75. const key = e.key.toUpperCase();
  76. if (key === 'ESCAPE') close();
  77. if (key === 'ENTER') create();
  78. };
  79. document.addEventListener('keydown', handleKeyDown);
  80. return () => {
  81. document.removeEventListener('keydown', handleKeyDown);
  82. };
  83. }, [close, create]);
  84. const getPoint = (e: React.MouseEvent | MouseEvent, custom = false) => {
  85. let pageX = e.pageX - offset.x;
  86. let pageY = e.pageY - offset.y;
  87. if (custom) return { pageX, pageY };
  88. if (ctrlOrShiftKeyActive && points.length) {
  89. const [lastPointX, lastPointY] = points[points.length - 1];
  90. if (Math.abs(lastPointX - pageX) - Math.abs(lastPointY - pageY) > 0) {
  91. pageY = lastPointY;
  92. } else pageX = lastPointX;
  93. }
  94. return { pageX, pageY };
  95. };
  96. const updateMousePosition = (e: React.MouseEvent) => {
  97. if (isMouseDown) {
  98. const { pageX, pageY } = getPoint(e, true);
  99. setPoints([...points, [pageX, pageY]]);
  100. setMousePosition(null);
  101. return;
  102. }
  103. const { pageX, pageY } = getPoint(e);
  104. setMousePosition([pageX, pageY]);
  105. if (points.length >= 2) {
  106. const [firstPointX, firstPointY] = points[0];
  107. if (Math.abs(firstPointX - pageX) < 5 && Math.abs(firstPointY - pageY) < 5) {
  108. setClosed(true);
  109. } else setClosed(false);
  110. } else setClosed(false);
  111. };
  112. const path = useMemo(() => {
  113. let d = '';
  114. for (let i = 0; i < points.length; i++) {
  115. const point = points[i];
  116. if (i === 0) d += `M ${point[0]} ${point[1]} `;
  117. else d += `L ${point[0]} ${point[1]} `;
  118. }
  119. if (points.length && mousePosition) {
  120. d += `L ${mousePosition[0]} ${mousePosition[1]}`;
  121. }
  122. return d;
  123. }, [points, mousePosition]);
  124. const addPoint = (e: React.MouseEvent) => {
  125. const { pageX, pageY } = getPoint(e);
  126. setIsMouseDown(true);
  127. if (closed) {
  128. onCreated(getCreateData());
  129. } else {
  130. setPoints([...points, [pageX, pageY]]);
  131. }
  132. const handleMouseUp = () => {
  133. setIsMouseDown(false);
  134. document.removeEventListener('mouseup', handleMouseUp);
  135. };
  136. document.addEventListener('mouseup', handleMouseUp);
  137. };
  138. return (
  139. <div
  140. ref={shapeCanvasRef}
  141. className="shape-create-canvas absolute top-0 left-0 w-full h-full z-[2] cursor-crosshair"
  142. onMouseDown={(e) => {
  143. e.stopPropagation();
  144. addPoint(e);
  145. }}
  146. onMouseMove={updateMousePosition}
  147. onContextMenu={(e) => {
  148. e.stopPropagation();
  149. e.preventDefault();
  150. close();
  151. }}
  152. >
  153. <svg className="w-full h-full overflow-visible">
  154. <path
  155. d={path}
  156. stroke="#d14424"
  157. fill={closed ? 'rgba(226, 83, 77, 0.15)' : 'none'}
  158. strokeWidth="2"
  159. />
  160. </svg>
  161. </div>
  162. );
  163. }