@buildcores/render-client 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.esm.js CHANGED
@@ -2,47 +2,51 @@ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
2
2
  import { useState, useRef, useCallback, useEffect, createContext, useContext } from 'react';
3
3
 
4
4
  // Helper to extract clientX from mouse or touch events
5
- const getClientX = (e) => {
5
+ const getClientX$1 = (e) => {
6
6
  return "touches" in e ? e.touches[0].clientX : e.clientX;
7
7
  };
8
- // Helper to calculate new video time with circular wrapping
9
- const calculateCircularTime = (startTime, deltaX, sensitivity, duration) => {
10
- const timeDelta = deltaX * sensitivity;
11
- let newTime = startTime + timeDelta;
8
+ // Helper to calculate new frame with circular wrapping
9
+ const calculateCircularFrame = (startFrame, deltaX, sensitivity, totalFrames) => {
10
+ const frameDelta = deltaX * sensitivity;
11
+ let newFrame = startFrame + frameDelta;
12
12
  // Make it circular - wrap around when going past boundaries
13
- newTime = newTime % duration;
14
- if (newTime < 0) {
15
- newTime += duration;
13
+ newFrame = newFrame % totalFrames;
14
+ if (newFrame < 0) {
15
+ newFrame += totalFrames;
16
16
  }
17
- return newTime;
17
+ return newFrame;
18
18
  };
19
- const useVideoScrubbing = (videoRef, options = {}) => {
20
- const { mouseSensitivity = 0.01, touchSensitivity = 0.01 } = options;
19
+ const useSpriteScrubbing = (canvasRef, totalFrames, options = {}) => {
20
+ const { mouseSensitivity = 0.1, touchSensitivity = 0.1, onFrameChange, } = options;
21
21
  const [isDragging, setIsDragging] = useState(false);
22
22
  const [dragStartX, setDragStartX] = useState(0);
23
- const [dragStartTime, setDragStartTime] = useState(0);
23
+ const [dragStartFrame, setDragStartFrame] = useState(0);
24
24
  const hasDragged = useRef(false);
25
+ const currentFrame = useRef(0);
25
26
  // Helper to start dragging (common logic for mouse and touch)
26
27
  const startDrag = useCallback((clientX, event) => {
27
- if (!videoRef.current)
28
+ if (!canvasRef.current)
28
29
  return;
29
30
  setIsDragging(true);
30
31
  setDragStartX(clientX);
31
- setDragStartTime(videoRef.current.currentTime);
32
+ setDragStartFrame(currentFrame.current);
32
33
  hasDragged.current = true;
33
34
  event.preventDefault();
34
- }, [videoRef]);
35
+ }, [canvasRef]);
35
36
  // Helper to handle drag movement (common logic for mouse and touch)
36
37
  const handleDragMove = useCallback((clientX, sensitivity) => {
37
- if (!isDragging || !videoRef.current)
38
+ if (!isDragging || !canvasRef.current)
38
39
  return;
39
40
  const deltaX = clientX - dragStartX;
40
- const duration = videoRef.current.duration || 0;
41
- if (duration > 0) {
42
- const newTime = calculateCircularTime(dragStartTime, deltaX, sensitivity, duration);
43
- videoRef.current.currentTime = newTime;
41
+ const newFrame = calculateCircularFrame(dragStartFrame, deltaX, // Positive for natural "spin right" feel
42
+ sensitivity, totalFrames);
43
+ currentFrame.current = newFrame;
44
+ // Call the frame change callback if provided
45
+ if (onFrameChange) {
46
+ onFrameChange(newFrame);
44
47
  }
45
- }, [isDragging, dragStartX, dragStartTime, videoRef]);
48
+ return newFrame;
49
+ }, [isDragging, dragStartX, dragStartFrame, totalFrames, onFrameChange]);
46
50
  // Helper to end dragging (common logic for mouse and touch)
47
51
  const endDrag = useCallback(() => {
48
52
  setIsDragging(false);
@@ -54,10 +58,10 @@ const useVideoScrubbing = (videoRef, options = {}) => {
54
58
  startDrag(e.touches[0].clientX, e.nativeEvent);
55
59
  }, [startDrag]);
56
60
  const handleDocumentMouseMove = useCallback((e) => {
57
- handleDragMove(getClientX(e), mouseSensitivity);
61
+ return handleDragMove(getClientX$1(e), mouseSensitivity);
58
62
  }, [handleDragMove, mouseSensitivity]);
59
63
  const handleDocumentTouchMove = useCallback((e) => {
60
- handleDragMove(getClientX(e), touchSensitivity);
64
+ return handleDragMove(getClientX$1(e), touchSensitivity);
61
65
  }, [handleDragMove, touchSensitivity]);
62
66
  const handleDocumentMouseUp = useCallback(() => {
63
67
  endDrag();
@@ -91,6 +95,10 @@ const useVideoScrubbing = (videoRef, options = {}) => {
91
95
  handleMouseDown,
92
96
  handleTouchStart,
93
97
  hasDragged,
98
+ currentFrame: currentFrame.current,
99
+ setCurrentFrame: (frame) => {
100
+ currentFrame.current = frame;
101
+ },
94
102
  };
95
103
  };
96
104
 
@@ -311,48 +319,6 @@ function useBouncePatternProgress(enabled = true) {
311
319
  return { value, isBouncing };
312
320
  }
313
321
 
314
- // API Types
315
- /**
316
- * Enum defining all available PC part categories that can be rendered.
317
- *
318
- * Each category represents a different type of computer component that can be
319
- * included in the 3D build visualization.
320
- *
321
- * @example
322
- * ```tsx
323
- * // All available categories
324
- * const categories = [
325
- * PartCategory.CPU, // "CPU"
326
- * PartCategory.GPU, // "GPU"
327
- * PartCategory.RAM, // "RAM"
328
- * PartCategory.Motherboard,// "Motherboard"
329
- * PartCategory.PSU, // "PSU"
330
- * PartCategory.Storage, // "Storage"
331
- * PartCategory.PCCase, // "PCCase"
332
- * PartCategory.CPUCooler, // "CPUCooler"
333
- * ];
334
- * ```
335
- */
336
- var PartCategory;
337
- (function (PartCategory) {
338
- /** Central Processing Unit - The main processor */
339
- PartCategory["CPU"] = "CPU";
340
- /** Graphics Processing Unit - Video card for rendering */
341
- PartCategory["GPU"] = "GPU";
342
- /** Random Access Memory - System memory modules */
343
- PartCategory["RAM"] = "RAM";
344
- /** Main circuit board that connects all components */
345
- PartCategory["Motherboard"] = "Motherboard";
346
- /** Power Supply Unit - Provides power to all components */
347
- PartCategory["PSU"] = "PSU";
348
- /** Storage devices like SSDs, HDDs, NVMe drives */
349
- PartCategory["Storage"] = "Storage";
350
- /** PC Case - The enclosure that houses all components */
351
- PartCategory["PCCase"] = "PCCase";
352
- /** CPU Cooler - Air or liquid cooling for the processor */
353
- PartCategory["CPUCooler"] = "CPUCooler";
354
- })(PartCategory || (PartCategory = {}));
355
-
356
322
  // API Configuration
357
323
  const API_BASE_URL = "https://squid-app-7aeyk.ondigitalocean.app";
358
324
  // API Endpoints
@@ -366,12 +332,16 @@ const buildApiUrl = (endpoint) => {
366
332
  };
367
333
  // API Implementation
368
334
  const renderBuildExperimental = async (request) => {
335
+ const requestWithFormat = {
336
+ ...request,
337
+ format: request.format || "video", // Default to video format
338
+ };
369
339
  const response = await fetch(buildApiUrl(API_ENDPOINTS.RENDER_BUILD_EXPERIMENTAL), {
370
340
  method: "POST",
371
341
  headers: {
372
342
  "Content-Type": "application/json",
373
343
  },
374
- body: JSON.stringify(request),
344
+ body: JSON.stringify(requestWithFormat),
375
345
  });
376
346
  if (!response.ok) {
377
347
  throw new Error(`Render build failed: ${response.status} ${response.statusText}`);
@@ -385,6 +355,33 @@ const renderBuildExperimental = async (request) => {
385
355
  },
386
356
  };
387
357
  };
358
+ const renderSpriteExperimental = async (request) => {
359
+ const requestWithFormat = {
360
+ ...request,
361
+ format: "sprite",
362
+ };
363
+ const response = await fetch(buildApiUrl(API_ENDPOINTS.RENDER_BUILD_EXPERIMENTAL), {
364
+ method: "POST",
365
+ headers: {
366
+ "Content-Type": "application/json",
367
+ },
368
+ body: JSON.stringify(requestWithFormat),
369
+ });
370
+ if (!response.ok) {
371
+ throw new Error(`Render sprite failed: ${response.status} ${response.statusText}`);
372
+ }
373
+ const sprite = await response.blob();
374
+ return {
375
+ sprite,
376
+ metadata: {
377
+ cols: 12, // Default sprite grid - could be returned from API
378
+ rows: 6,
379
+ totalFrames: 72,
380
+ size: sprite.size,
381
+ format: "image/webp",
382
+ },
383
+ };
384
+ };
388
385
  const getAvailableParts = async () => {
389
386
  const response = await fetch(buildApiUrl(API_ENDPOINTS.AVAILABLE_PARTS), {
390
387
  method: "GET",
@@ -398,6 +395,48 @@ const getAvailableParts = async () => {
398
395
  return response.json();
399
396
  };
400
397
 
398
+ // API Types
399
+ /**
400
+ * Enum defining all available PC part categories that can be rendered.
401
+ *
402
+ * Each category represents a different type of computer component that can be
403
+ * included in the 3D build visualization.
404
+ *
405
+ * @example
406
+ * ```tsx
407
+ * // All available categories
408
+ * const categories = [
409
+ * PartCategory.CPU, // "CPU"
410
+ * PartCategory.GPU, // "GPU"
411
+ * PartCategory.RAM, // "RAM"
412
+ * PartCategory.Motherboard,// "Motherboard"
413
+ * PartCategory.PSU, // "PSU"
414
+ * PartCategory.Storage, // "Storage"
415
+ * PartCategory.PCCase, // "PCCase"
416
+ * PartCategory.CPUCooler, // "CPUCooler"
417
+ * ];
418
+ * ```
419
+ */
420
+ var PartCategory;
421
+ (function (PartCategory) {
422
+ /** Central Processing Unit - The main processor */
423
+ PartCategory["CPU"] = "CPU";
424
+ /** Graphics Processing Unit - Video card for rendering */
425
+ PartCategory["GPU"] = "GPU";
426
+ /** Random Access Memory - System memory modules */
427
+ PartCategory["RAM"] = "RAM";
428
+ /** Main circuit board that connects all components */
429
+ PartCategory["Motherboard"] = "Motherboard";
430
+ /** Power Supply Unit - Provides power to all components */
431
+ PartCategory["PSU"] = "PSU";
432
+ /** Storage devices like SSDs, HDDs, NVMe drives */
433
+ PartCategory["Storage"] = "Storage";
434
+ /** PC Case - The enclosure that houses all components */
435
+ PartCategory["PCCase"] = "PCCase";
436
+ /** CPU Cooler - Air or liquid cooling for the processor */
437
+ PartCategory["CPUCooler"] = "CPUCooler";
438
+ })(PartCategory || (PartCategory = {}));
439
+
401
440
  /**
402
441
  * Compares two RenderBuildRequest objects for equality by checking if the same IDs
403
442
  * are present in each category array, regardless of order.
@@ -476,6 +515,65 @@ const useBuildRender = (parts, onLoadStart) => {
476
515
  };
477
516
  };
478
517
 
518
+ const useSpriteRender = (parts, onLoadStart) => {
519
+ const [spriteSrc, setSpriteSrc] = useState(null);
520
+ const [isRenderingSprite, setIsRenderingSprite] = useState(false);
521
+ const [renderError, setRenderError] = useState(null);
522
+ const [spriteMetadata, setSpriteMetadata] = useState(null);
523
+ const previousPartsRef = useRef(null);
524
+ const fetchRenderSprite = useCallback(async (currentParts) => {
525
+ try {
526
+ setIsRenderingSprite(true);
527
+ setRenderError(null);
528
+ onLoadStart?.();
529
+ const response = await renderSpriteExperimental(currentParts);
530
+ const objectUrl = URL.createObjectURL(response.sprite);
531
+ // Clean up previous sprite URL before setting new one
532
+ setSpriteSrc((prevSrc) => {
533
+ if (prevSrc) {
534
+ URL.revokeObjectURL(prevSrc);
535
+ }
536
+ return objectUrl;
537
+ });
538
+ // Set sprite metadata
539
+ setSpriteMetadata({
540
+ cols: response.metadata?.cols || 12,
541
+ rows: response.metadata?.rows || 6,
542
+ totalFrames: response.metadata?.totalFrames || 72,
543
+ });
544
+ }
545
+ catch (error) {
546
+ setRenderError(error instanceof Error ? error.message : "Failed to render sprite");
547
+ }
548
+ finally {
549
+ setIsRenderingSprite(false);
550
+ }
551
+ }, [onLoadStart]);
552
+ // Effect to call API when parts content changes (using custom equality check)
553
+ useEffect(() => {
554
+ const shouldFetch = previousPartsRef.current === null ||
555
+ !arePartsEqual(previousPartsRef.current, parts);
556
+ if (shouldFetch) {
557
+ previousPartsRef.current = parts;
558
+ fetchRenderSprite(parts);
559
+ }
560
+ }, [parts, fetchRenderSprite]);
561
+ // Cleanup effect for component unmount
562
+ useEffect(() => {
563
+ return () => {
564
+ if (spriteSrc) {
565
+ URL.revokeObjectURL(spriteSrc);
566
+ }
567
+ };
568
+ }, [spriteSrc]);
569
+ return {
570
+ spriteSrc,
571
+ isRenderingSprite,
572
+ renderError,
573
+ spriteMetadata,
574
+ };
575
+ };
576
+
479
577
  const LoadingErrorOverlay = ({ isVisible, renderError, size, }) => {
480
578
  if (!isVisible)
481
579
  return null;
@@ -511,7 +609,7 @@ const InstructionTooltip = ({ isVisible, progressValue, instructionIcon, }) => {
511
609
  position: "absolute",
512
610
  top: "50%",
513
611
  left: "50%",
514
- transform: `translate(-50%, -50%) translateX(${progressValue * 100}px)`,
612
+ transform: `translate(-50%, -50%) translateX(${progressValue * 50}px)`,
515
613
  backgroundColor: "rgba(0, 0, 0, 0.8)",
516
614
  color: "white",
517
615
  padding: "12px",
@@ -530,63 +628,213 @@ const InstructionTooltip = ({ isVisible, progressValue, instructionIcon, }) => {
530
628
  } })) }));
531
629
  };
532
630
 
533
- const BuildRender = ({ parts, size, mouseSensitivity = 0.01, touchSensitivity = 0.01, }) => {
534
- const videoRef = useRef(null);
631
+ const BuildRender = ({ parts, size, mouseSensitivity = 0.2, touchSensitivity = 0.2, }) => {
632
+ const canvasRef = useRef(null);
633
+ const [img, setImg] = useState(null);
535
634
  const [isLoading, setIsLoading] = useState(true);
536
635
  const [bouncingAllowed, setBouncingAllowed] = useState(false);
537
- // Use custom hook for build rendering
538
- const { videoSrc, isRenderingBuild, renderError } = useBuildRender(parts);
636
+ // Use custom hook for sprite rendering
637
+ const { spriteSrc, isRenderingSprite, renderError, spriteMetadata } = useSpriteRender(parts);
539
638
  const { value: progressValue, isBouncing } = useBouncePatternProgress(bouncingAllowed);
540
- const { isDragging, handleMouseDown, handleTouchStart, hasDragged } = useVideoScrubbing(videoRef, {
639
+ const total = spriteMetadata ? spriteMetadata.totalFrames : 72;
640
+ const cols = spriteMetadata ? spriteMetadata.cols : 12;
641
+ const rows = spriteMetadata ? spriteMetadata.rows : 6;
642
+ const frameRef = useRef(0);
643
+ // Image/frame sizes
644
+ const frameW = img ? img.width / cols : 0;
645
+ const frameH = img ? img.height / rows : 0;
646
+ // ---- Load sprite image ----
647
+ useEffect(() => {
648
+ if (!spriteSrc) {
649
+ setImg(null);
650
+ setIsLoading(true);
651
+ return;
652
+ }
653
+ setIsLoading(true);
654
+ const i = new Image();
655
+ i.decoding = "async";
656
+ i.loading = "eager";
657
+ i.src = spriteSrc;
658
+ i.onload = () => {
659
+ setImg(i);
660
+ setIsLoading(false);
661
+ // Start bouncing animation after delay
662
+ setTimeout(() => {
663
+ setBouncingAllowed(true);
664
+ }, 2000);
665
+ };
666
+ i.onerror = () => {
667
+ setImg(null);
668
+ setIsLoading(false);
669
+ };
670
+ }, [spriteSrc]);
671
+ // ---- Drawing function ----
672
+ const draw = useCallback((frameIndex) => {
673
+ const cnv = canvasRef.current;
674
+ if (!cnv || !img || !frameW || !frameH)
675
+ return;
676
+ const ctx = cnv.getContext("2d");
677
+ if (!ctx)
678
+ return;
679
+ // Backing store sized for HiDPI; CSS size stays `size`
680
+ const dpr = Math.max(1, window.devicePixelRatio || 1);
681
+ const targetW = Math.round(size * dpr);
682
+ const targetH = Math.round(size * dpr);
683
+ if (cnv.width !== targetW || cnv.height !== targetH) {
684
+ cnv.width = targetW;
685
+ cnv.height = targetH;
686
+ }
687
+ // Snap to integer frame (never between tiles)
688
+ let n = Math.round(frameIndex) % total;
689
+ if (n < 0)
690
+ n += total;
691
+ const r = Math.floor(n / cols);
692
+ const c = n % cols;
693
+ // Use integer source rects to avoid sampling bleed across tiles
694
+ const sx = Math.round(c * frameW);
695
+ const sy = Math.round(r * frameH);
696
+ const sw = Math.round(frameW);
697
+ const sh = Math.round(frameH);
698
+ ctx.clearRect(0, 0, targetW, targetH);
699
+ ctx.imageSmoothingEnabled = true;
700
+ ctx.imageSmoothingQuality = "high";
701
+ ctx.drawImage(img, sx, sy, sw, sh, 0, 0, targetW, targetH);
702
+ }, [img, frameW, frameH, size, cols, total]);
703
+ const { isDragging, handleMouseDown, handleTouchStart, hasDragged } = useSpriteScrubbing(canvasRef, total, {
541
704
  mouseSensitivity,
542
705
  touchSensitivity,
706
+ onFrameChange: (newFrame) => {
707
+ frameRef.current = newFrame;
708
+ draw(newFrame);
709
+ },
543
710
  });
544
- const handleLoadStartInternal = useCallback(() => {
711
+ useCallback(() => {
545
712
  setIsLoading(true);
546
713
  setBouncingAllowed(false);
547
714
  }, []);
548
- const handleCanPlayInternal = useCallback(() => {
549
- setIsLoading(false);
550
- // Start bouncing animation after delay
551
- setTimeout(() => {
552
- setBouncingAllowed(true);
553
- }, 2000);
554
- }, []);
715
+ // Auto-rotate when bouncing is allowed and not dragged
555
716
  useEffect(() => {
556
- if (hasDragged.current || !videoRef.current)
717
+ if (hasDragged.current || !img)
557
718
  return;
558
- const duration = videoRef.current.duration;
559
- if (!isFinite(duration))
560
- return;
561
- const time = calculateCircularTime(0, progressValue, 0.5, duration);
562
- if (isFinite(time)) {
563
- videoRef.current.currentTime = time;
719
+ // Calculate frame based on progress value (similar to video time calculation)
720
+ const frame = ((progressValue / 5) * total) % total;
721
+ frameRef.current = frame;
722
+ draw(frame);
723
+ }, [progressValue, hasDragged, img, total, draw]);
724
+ // Initial draw once image is ready
725
+ useEffect(() => {
726
+ if (img && !isLoading) {
727
+ draw(frameRef.current);
564
728
  }
565
- }, [progressValue, hasDragged]);
566
- return (jsxs("div", { style: { position: "relative", width: size, height: size }, children: [videoSrc && (jsx("video", { ref: videoRef, src: videoSrc, width: size, height: size, autoPlay: true, preload: "metadata", muted: true, playsInline: true, controls: false, disablePictureInPicture: true, controlsList: "nodownload nofullscreen noremoteplayback", ...{ "x-webkit-airplay": "deny" }, onMouseDown: handleMouseDown, onTouchStart: handleTouchStart, onLoadStart: handleLoadStartInternal, onCanPlay: handleCanPlayInternal, onLoadedData: () => {
567
- if (videoRef.current) {
568
- videoRef.current.pause();
569
- }
570
- }, style: {
729
+ }, [img, isLoading, draw]);
730
+ return (jsxs("div", { style: { position: "relative", width: size, height: size }, children: [img && (jsx("canvas", { ref: canvasRef, onMouseDown: handleMouseDown, onTouchStart: handleTouchStart, style: {
731
+ width: size,
732
+ height: size,
571
733
  cursor: isDragging ? "grabbing" : "grab",
572
734
  touchAction: "none", // Prevents default touch behaviors like scrolling
573
735
  display: "block",
574
- // Completely hide video controls on all browsers including mobile
575
- WebkitMediaControls: "none",
576
- MozMediaControls: "none",
577
- OMediaControls: "none",
578
- msMediaControls: "none",
579
- mediaControls: "none",
580
- // Additional iOS-specific properties
581
- WebkitTouchCallout: "none",
582
- WebkitUserSelect: "none",
583
736
  userSelect: "none",
584
- }, children: "Your browser does not support the video tag." }, videoSrc)), jsx(LoadingErrorOverlay, { isVisible: isLoading || isRenderingBuild || !!renderError, renderError: renderError || undefined, size: size }), jsx(InstructionTooltip, { isVisible: !isLoading &&
585
- !isRenderingBuild &&
737
+ WebkitUserSelect: "none",
738
+ WebkitTouchCallout: "none",
739
+ }, role: "img", "aria-label": "360\u00B0 viewer", onContextMenu: (e) => e.preventDefault() })), jsx(LoadingErrorOverlay, { isVisible: isLoading || isRenderingSprite || !!renderError, renderError: renderError || undefined, size: size }), jsx(InstructionTooltip, { isVisible: !isLoading &&
740
+ !isRenderingSprite &&
586
741
  !renderError &&
587
742
  isBouncing &&
588
743
  !hasDragged.current, progressValue: progressValue })] }));
589
744
  };
590
745
 
591
- export { API_BASE_URL, API_ENDPOINTS, BuildRender, DragIcon, InstructionTooltip, LoadingErrorOverlay, PartCategory, arePartsEqual, buildApiUrl, calculateCircularTime, getAvailableParts, renderBuildExperimental, useBouncePatternProgress, useBuildRender, useVideoScrubbing };
746
+ // Helper to extract clientX from mouse or touch events
747
+ const getClientX = (e) => {
748
+ return "touches" in e ? e.touches[0].clientX : e.clientX;
749
+ };
750
+ // Helper to calculate new video time with circular wrapping
751
+ const calculateCircularTime = (startTime, deltaX, sensitivity, duration) => {
752
+ const timeDelta = deltaX * sensitivity;
753
+ let newTime = startTime + timeDelta;
754
+ // Make it circular - wrap around when going past boundaries
755
+ newTime = newTime % duration;
756
+ if (newTime < 0) {
757
+ newTime += duration;
758
+ }
759
+ return newTime;
760
+ };
761
+ const useVideoScrubbing = (videoRef, options = {}) => {
762
+ const { mouseSensitivity = 0.01, touchSensitivity = 0.01 } = options;
763
+ const [isDragging, setIsDragging] = useState(false);
764
+ const [dragStartX, setDragStartX] = useState(0);
765
+ const [dragStartTime, setDragStartTime] = useState(0);
766
+ const hasDragged = useRef(false);
767
+ // Helper to start dragging (common logic for mouse and touch)
768
+ const startDrag = useCallback((clientX, event) => {
769
+ if (!videoRef.current)
770
+ return;
771
+ setIsDragging(true);
772
+ setDragStartX(clientX);
773
+ setDragStartTime(videoRef.current.currentTime);
774
+ hasDragged.current = true;
775
+ event.preventDefault();
776
+ }, [videoRef]);
777
+ // Helper to handle drag movement (common logic for mouse and touch)
778
+ const handleDragMove = useCallback((clientX, sensitivity) => {
779
+ if (!isDragging || !videoRef.current)
780
+ return;
781
+ const deltaX = clientX - dragStartX;
782
+ const duration = videoRef.current.duration || 0;
783
+ if (duration > 0) {
784
+ const newTime = calculateCircularTime(dragStartTime, deltaX, sensitivity, duration);
785
+ videoRef.current.currentTime = newTime;
786
+ }
787
+ }, [isDragging, dragStartX, dragStartTime, videoRef]);
788
+ // Helper to end dragging (common logic for mouse and touch)
789
+ const endDrag = useCallback(() => {
790
+ setIsDragging(false);
791
+ }, []);
792
+ const handleMouseDown = useCallback((e) => {
793
+ startDrag(e.clientX, e.nativeEvent);
794
+ }, [startDrag]);
795
+ const handleTouchStart = useCallback((e) => {
796
+ startDrag(e.touches[0].clientX, e.nativeEvent);
797
+ }, [startDrag]);
798
+ const handleDocumentMouseMove = useCallback((e) => {
799
+ handleDragMove(getClientX(e), mouseSensitivity);
800
+ }, [handleDragMove, mouseSensitivity]);
801
+ const handleDocumentTouchMove = useCallback((e) => {
802
+ handleDragMove(getClientX(e), touchSensitivity);
803
+ }, [handleDragMove, touchSensitivity]);
804
+ const handleDocumentMouseUp = useCallback(() => {
805
+ endDrag();
806
+ }, [endDrag]);
807
+ const handleDocumentTouchEnd = useCallback(() => {
808
+ endDrag();
809
+ }, [endDrag]);
810
+ // Add document-level event listeners when dragging starts
811
+ useEffect(() => {
812
+ if (isDragging) {
813
+ document.addEventListener("mousemove", handleDocumentMouseMove);
814
+ document.addEventListener("mouseup", handleDocumentMouseUp);
815
+ document.addEventListener("touchmove", handleDocumentTouchMove);
816
+ document.addEventListener("touchend", handleDocumentTouchEnd);
817
+ return () => {
818
+ document.removeEventListener("mousemove", handleDocumentMouseMove);
819
+ document.removeEventListener("mouseup", handleDocumentMouseUp);
820
+ document.removeEventListener("touchmove", handleDocumentTouchMove);
821
+ document.removeEventListener("touchend", handleDocumentTouchEnd);
822
+ };
823
+ }
824
+ }, [
825
+ isDragging,
826
+ handleDocumentMouseMove,
827
+ handleDocumentMouseUp,
828
+ handleDocumentTouchMove,
829
+ handleDocumentTouchEnd,
830
+ ]);
831
+ return {
832
+ isDragging,
833
+ handleMouseDown,
834
+ handleTouchStart,
835
+ hasDragged,
836
+ };
837
+ };
838
+
839
+ export { API_BASE_URL, API_ENDPOINTS, BuildRender, DragIcon, InstructionTooltip, LoadingErrorOverlay, PartCategory, arePartsEqual, buildApiUrl, calculateCircularFrame, calculateCircularTime, getAvailableParts, renderBuildExperimental, renderSpriteExperimental, useBouncePatternProgress, useBuildRender, useSpriteRender, useSpriteScrubbing, useVideoScrubbing };
592
840
  //# sourceMappingURL=index.esm.js.map