useTimelineDrag.ts 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. import { useRef, useCallback } from "react";
  2. import { useClockStore } from "../store/useClockStore";
  3. import type { ClockBlock } from "../assets/types";
  4. export function useTimelineDrag() {
  5. const { duration, blocks, moveBlocks, updateBlock, selectBlocks, selectedBlockIds, setAlignmentTick } = useClockStore();
  6. const dragState = useRef<{
  7. blockId: string;
  8. mode: "move" | "resize";
  9. startX: number;
  10. laneWidth: number;
  11. shiftKey: boolean;
  12. wasSelected: boolean;
  13. groupOrigStarts: Map<string, number>;
  14. origDuration: number;
  15. } | null>(null);
  16. const pxToTick = useCallback((laneWidth: number, px: number) =>
  17. Math.round((px / laneWidth) * duration),
  18. [duration]);
  19. const onPointerDownBlock = (e: React.PointerEvent, block: ClockBlock, mode: "move" | "resize", laneElement: HTMLElement | null) => {
  20. e.stopPropagation();
  21. if (!laneElement) return;
  22. (e.target as HTMLElement).setPointerCapture(e.pointerId);
  23. const wasSelected = selectedBlockIds.has(block.id);
  24. const groupIds = mode === "move" && wasSelected ? selectedBlockIds : new Set([block.id]);
  25. // Convert object to map for quick lookup
  26. const blockValues = Object.values(blocks);
  27. dragState.current = {
  28. blockId: block.id,
  29. mode,
  30. startX: e.clientX,
  31. laneWidth: laneElement.clientWidth,
  32. shiftKey: e.shiftKey,
  33. wasSelected,
  34. groupOrigStarts: new Map(blockValues.filter((b) => groupIds.has(b.id)).map((b) => [b.id, b.start])),
  35. origDuration: block.duration,
  36. };
  37. };
  38. const onPointerMove = (e: React.PointerEvent) => {
  39. const drag = dragState.current;
  40. if (!drag) return;
  41. const deltaTicks = pxToTick(drag.laneWidth, e.clientX - drag.startX);
  42. if (drag.mode === "resize") {
  43. updateBlock(drag.blockId, { duration: Math.max(1, drag.origDuration + deltaTicks) });
  44. setAlignmentTick(null);
  45. return;
  46. }
  47. // Group move clamping
  48. let clampDelta = deltaTicks;
  49. drag.groupOrigStarts.forEach((origStart) => {
  50. const proposed = origStart + clampDelta;
  51. if (proposed < 0) clampDelta = Math.max(clampDelta, -origStart);
  52. if (proposed > duration - 1) clampDelta = Math.min(clampDelta, duration - 1 - origStart);
  53. });
  54. // Batch update via store
  55. const updates: Record<string, { start: number }> = {};
  56. drag.groupOrigStarts.forEach((origStart, blockId) => {
  57. updates[blockId] = { start: origStart + clampDelta };
  58. });
  59. moveBlocks(updates);
  60. // Alignment logic
  61. const draggedNewStart = updates[drag.blockId].start;
  62. const match = Object.values(blocks).some((b) => !drag.groupOrigStarts.has(b.id) && b.start === draggedNewStart);
  63. setAlignmentTick(match ? draggedNewStart : null);
  64. };
  65. const onPointerUp = (e: React.PointerEvent) => {
  66. const drag = dragState.current;
  67. if (!drag) return;
  68. setAlignmentTick(null);
  69. dragState.current = null;
  70. const moved = Math.abs(e.clientX - drag.startX) >= 3;
  71. if (drag.mode === "move" && !moved) {
  72. if (drag.shiftKey) {
  73. selectBlocks((prev) => {
  74. const next = new Set(prev);
  75. next.has(drag.blockId) ? next.delete(drag.blockId) : next.add(drag.blockId);
  76. return next;
  77. });
  78. } else {
  79. selectBlocks(new Set([drag.blockId]));
  80. }
  81. } else if (drag.mode === "move" && moved && !drag.wasSelected) {
  82. selectBlocks(new Set([drag.blockId]));
  83. }
  84. };
  85. return { onPointerDownBlock, onPointerMove, onPointerUp, pxToTick };
  86. }