@buildcores/render-client 1.2.0 → 1.4.0

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
@@ -1,5 +1,5 @@
1
1
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
2
- import { useState, useRef, useCallback, useEffect } from 'react';
2
+ import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
3
3
  import { useAnimationFrame } from 'framer-motion';
4
4
 
5
5
  // Helper to extract clientX from mouse or touch events
@@ -147,6 +147,9 @@ const API_ENDPOINTS = {
147
147
  RENDER_BUILD_EXPERIMENTAL: "/render-build-experimental",
148
148
  RENDER_BUILD: "/render-build",
149
149
  AVAILABLE_PARTS: "/available-parts",
150
+ BUILD: "/build",
151
+ PARTS: "/parts",
152
+ RENDER_BY_SHARE_CODE: "/render-by-share-code",
150
153
  };
151
154
  // API URL helpers
152
155
  const buildApiUrl = (endpoint, config) => {
@@ -177,6 +180,8 @@ const renderBuildExperimental = async (request, config) => {
177
180
  // Include width and height if provided
178
181
  ...(request.width !== undefined ? { width: request.width } : {}),
179
182
  ...(request.height !== undefined ? { height: request.height } : {}),
183
+ // Include profile if provided
184
+ ...(request.profile ? { profile: request.profile } : {}),
180
185
  };
181
186
  const response = await fetch(buildApiUrl(API_ENDPOINTS.RENDER_BUILD_EXPERIMENTAL, config), {
182
187
  method: "POST",
@@ -204,6 +209,12 @@ const createRenderBuildJob = async (request, config) => {
204
209
  // Include width and height if provided
205
210
  ...(request.width !== undefined ? { width: request.width } : {}),
206
211
  ...(request.height !== undefined ? { height: request.height } : {}),
212
+ // Include profile if provided
213
+ ...(request.profile ? { profile: request.profile } : {}),
214
+ // Include composition settings
215
+ ...(request.showGrid !== undefined ? { showGrid: request.showGrid } : {}),
216
+ ...(request.cameraOffsetX !== undefined ? { cameraOffsetX: request.cameraOffsetX } : {}),
217
+ ...(request.gridSettings ? { gridSettings: request.gridSettings } : {}),
207
218
  };
208
219
  const response = await fetch(buildApiUrl(API_ENDPOINTS.RENDER_BUILD, config), {
209
220
  method: "POST",
@@ -267,6 +278,8 @@ const renderSpriteExperimental = async (request, config) => {
267
278
  // Include width and height if provided
268
279
  ...(request.width !== undefined ? { width: request.width } : {}),
269
280
  ...(request.height !== undefined ? { height: request.height } : {}),
281
+ // Include profile if provided
282
+ ...(request.profile ? { profile: request.profile } : {}),
270
283
  };
271
284
  const response = await fetch(buildApiUrl(API_ENDPOINTS.RENDER_BUILD_EXPERIMENTAL, config), {
272
285
  method: "POST",
@@ -307,6 +320,169 @@ const getAvailableParts = async (category, config, options) => {
307
320
  }
308
321
  return (await response.json());
309
322
  };
323
+ // ============================================
324
+ // Build and Parts API Functions
325
+ // ============================================
326
+ /**
327
+ * Fetch a build by its share code.
328
+ * Returns build metadata and parts organized by category.
329
+ *
330
+ * @param shareCode - The share code of the build to fetch
331
+ * @param config - API configuration (environment, auth token)
332
+ * @returns Promise with build details including parts
333
+ *
334
+ * @example
335
+ * ```tsx
336
+ * const build = await getBuildByShareCode('abc123xyz', {
337
+ * environment: 'prod',
338
+ * authToken: 'your-api-key'
339
+ * });
340
+ *
341
+ * console.log(build.name); // "My Gaming PC"
342
+ * console.log(build.parts.CPU); // ["7xjqsomhr"]
343
+ *
344
+ * // Use the parts directly with BuildRender
345
+ * <BuildRender parts={{ parts: build.parts }} size={500} apiConfig={config} />
346
+ * ```
347
+ */
348
+ const getBuildByShareCode = async (shareCode, config) => {
349
+ const url = buildApiUrl(`${API_ENDPOINTS.BUILD}/${encodeURIComponent(shareCode)}`, config);
350
+ const response = await fetch(url, {
351
+ method: "GET",
352
+ headers: buildHeaders(config),
353
+ });
354
+ if (response.status === 404) {
355
+ throw new Error("Build not found");
356
+ }
357
+ if (!response.ok) {
358
+ throw new Error(`Get build by share code failed: ${response.status} ${response.statusText}`);
359
+ }
360
+ return (await response.json());
361
+ };
362
+ /**
363
+ * Fetch part details by their BuildCores IDs.
364
+ *
365
+ * @param partIds - Array of BuildCores part IDs to fetch
366
+ * @param config - API configuration (environment, auth token)
367
+ * @returns Promise with part details
368
+ *
369
+ * @example
370
+ * ```tsx
371
+ * const response = await getPartsByIds(['7xjqsomhr', 'z7pyphm9k'], {
372
+ * environment: 'prod',
373
+ * authToken: 'your-api-key'
374
+ * });
375
+ *
376
+ * response.parts.forEach(part => {
377
+ * console.log(`${part.name} (${part.category})`);
378
+ * });
379
+ * ```
380
+ */
381
+ const getPartsByIds = async (partIds, config) => {
382
+ const url = buildApiUrl(API_ENDPOINTS.PARTS, config);
383
+ const response = await fetch(url, {
384
+ method: "POST",
385
+ headers: buildHeaders(config),
386
+ body: JSON.stringify({ ids: partIds }),
387
+ });
388
+ if (!response.ok) {
389
+ throw new Error(`Get parts by IDs failed: ${response.status} ${response.statusText}`);
390
+ }
391
+ return (await response.json());
392
+ };
393
+ /**
394
+ * Create a render job for a build by its share code.
395
+ * Returns the job ID for polling status.
396
+ *
397
+ * @param shareCode - The share code of the build to render
398
+ * @param config - API configuration (environment, auth token)
399
+ * @param options - Render options (format, dimensions, profile)
400
+ * @returns Promise with job creation response
401
+ */
402
+ const createRenderByShareCodeJob = async (shareCode, config, options) => {
403
+ const url = buildApiUrl(API_ENDPOINTS.RENDER_BY_SHARE_CODE, config);
404
+ const body = {
405
+ shareCode,
406
+ ...(options?.format ? { format: options.format } : {}),
407
+ ...(options?.width !== undefined ? { width: options.width } : {}),
408
+ ...(options?.height !== undefined ? { height: options.height } : {}),
409
+ ...(options?.profile ? { profile: options.profile } : {}),
410
+ ...(options?.showGrid !== undefined ? { showGrid: options.showGrid } : {}),
411
+ ...(options?.cameraOffsetX !== undefined ? { cameraOffsetX: options.cameraOffsetX } : {}),
412
+ ...(options?.gridSettings ? { gridSettings: options.gridSettings } : {}),
413
+ };
414
+ const response = await fetch(url, {
415
+ method: "POST",
416
+ headers: buildHeaders(config),
417
+ body: JSON.stringify(body),
418
+ });
419
+ if (response.status === 404) {
420
+ throw new Error("Build not found");
421
+ }
422
+ if (!response.ok) {
423
+ throw new Error(`Create render job failed: ${response.status} ${response.statusText}`);
424
+ }
425
+ const data = (await response.json());
426
+ if (!data?.job_id) {
427
+ throw new Error("Create render job failed: missing job_id in response");
428
+ }
429
+ return data;
430
+ };
431
+ /**
432
+ * Render a build by its share code.
433
+ * This is a convenience function that creates a render job and polls until completion.
434
+ *
435
+ * @param shareCode - The share code of the build to render
436
+ * @param config - API configuration (environment, auth token)
437
+ * @param options - Render options including polling configuration
438
+ * @returns Promise with the final render URL
439
+ *
440
+ * @example
441
+ * ```tsx
442
+ * // Simple usage - just pass share code
443
+ * const result = await renderByShareCode('abc123xyz', {
444
+ * environment: 'prod',
445
+ * authToken: 'your-api-key'
446
+ * });
447
+ * console.log(result.videoUrl); // URL to rendered video
448
+ *
449
+ * // With custom options
450
+ * const result = await renderByShareCode('abc123xyz', config, {
451
+ * format: 'sprite',
452
+ * width: 1920,
453
+ * height: 1080,
454
+ * profile: 'cinematic',
455
+ * timeoutMs: 180000 // 3 minute timeout
456
+ * });
457
+ * ```
458
+ */
459
+ const renderByShareCode = async (shareCode, config, options) => {
460
+ const pollIntervalMs = options?.pollIntervalMs ?? 1500;
461
+ const timeoutMs = options?.timeoutMs ?? 120000; // 2 minutes default
462
+ const { job_id } = await createRenderByShareCodeJob(shareCode, config, options);
463
+ const start = Date.now();
464
+ // Poll until completed or error or timeout
465
+ for (;;) {
466
+ const status = await getRenderBuildStatus(job_id, config);
467
+ if (status.status === "completed") {
468
+ const requestedFormat = options?.format ?? "video";
469
+ const finalUrl = requestedFormat === "sprite"
470
+ ? status.sprite_url || status.url || undefined
471
+ : status.video_url || status.url || undefined;
472
+ if (!finalUrl) {
473
+ throw new Error("Render job completed but no URL returned");
474
+ }
475
+ return { videoUrl: finalUrl };
476
+ }
477
+ if (status.status === "error") {
478
+ throw new Error(status.error || "Render job failed");
479
+ }
480
+ if (Date.now() - start > timeoutMs) {
481
+ throw new Error("Timed out waiting for render job to complete");
482
+ }
483
+ await sleep(pollIntervalMs);
484
+ }
485
+ };
310
486
 
311
487
  /**
312
488
  * Enum defining all available PC part categories that can be rendered.
@@ -347,6 +523,8 @@ var PartCategory;
347
523
  PartCategory["PCCase"] = "PCCase";
348
524
  /** CPU Cooler - Air or liquid cooling for the processor */
349
525
  PartCategory["CPUCooler"] = "CPUCooler";
526
+ /** Case Fans - Additional cooling fans for the case */
527
+ PartCategory["CaseFan"] = "CaseFan";
350
528
  })(PartCategory || (PartCategory = {}));
351
529
 
352
530
  /**
@@ -439,20 +617,47 @@ const useBuildRender = (parts, apiConfig, onLoadStart, options) => {
439
617
  };
440
618
  };
441
619
 
442
- const useSpriteRender = (parts, apiConfig, onLoadStart, options) => {
620
+ const useSpriteRender = (input, apiConfig, onLoadStart, options) => {
443
621
  const [spriteSrc, setSpriteSrc] = useState(null);
444
622
  const [isRenderingSprite, setIsRenderingSprite] = useState(false);
445
623
  const [renderError, setRenderError] = useState(null);
446
624
  const [spriteMetadata, setSpriteMetadata] = useState(null);
447
- const previousPartsRef = useRef(null);
448
- const fetchRenderSprite = useCallback(async (currentParts) => {
625
+ const previousInputRef = useRef(null);
626
+ // Normalize input to SpriteRenderInput format
627
+ const normalizedInput = 'type' in input ? input : { type: 'parts', parts: input };
628
+ const fetchRenderSprite = useCallback(async (currentInput) => {
449
629
  try {
450
630
  setIsRenderingSprite(true);
451
631
  setRenderError(null);
452
632
  onLoadStart?.();
633
+ // Handle share code rendering - uses existing build with proper interactive state
634
+ if (currentInput.type === 'shareCode') {
635
+ const { videoUrl: spriteUrl } = await renderByShareCode(currentInput.shareCode, apiConfig, {
636
+ format: 'sprite',
637
+ profile: currentInput.profile,
638
+ showGrid: currentInput.showGrid,
639
+ cameraOffsetX: currentInput.cameraOffsetX,
640
+ gridSettings: currentInput.gridSettings
641
+ });
642
+ setSpriteSrc((prevSrc) => {
643
+ if (prevSrc && prevSrc.startsWith("blob:")) {
644
+ URL.revokeObjectURL(prevSrc);
645
+ }
646
+ return spriteUrl;
647
+ });
648
+ setSpriteMetadata({ cols: 12, rows: 6, totalFrames: 72 });
649
+ return;
650
+ }
651
+ // Handle parts-based rendering (creates new build)
652
+ const currentParts = currentInput.parts;
453
653
  const mode = options?.mode ?? "async";
454
654
  if (mode === "experimental") {
455
- const response = await renderSpriteExperimental(currentParts, apiConfig);
655
+ const response = await renderSpriteExperimental({
656
+ ...currentParts,
657
+ showGrid: currentInput.showGrid,
658
+ cameraOffsetX: currentInput.cameraOffsetX,
659
+ gridSettings: currentInput.gridSettings,
660
+ }, apiConfig);
456
661
  const objectUrl = URL.createObjectURL(response.sprite);
457
662
  // Clean up previous sprite URL before setting new one
458
663
  setSpriteSrc((prevSrc) => {
@@ -470,7 +675,13 @@ const useSpriteRender = (parts, apiConfig, onLoadStart, options) => {
470
675
  }
471
676
  else {
472
677
  // Async job-based flow: request sprite format and use returned URL
473
- const { videoUrl: spriteUrl } = await renderBuild({ ...currentParts, format: "sprite" }, apiConfig);
678
+ const { videoUrl: spriteUrl } = await renderBuild({
679
+ ...currentParts,
680
+ format: "sprite",
681
+ showGrid: currentInput.showGrid,
682
+ cameraOffsetX: currentInput.cameraOffsetX,
683
+ gridSettings: currentInput.gridSettings,
684
+ }, apiConfig);
474
685
  setSpriteSrc((prevSrc) => {
475
686
  if (prevSrc && prevSrc.startsWith("blob:")) {
476
687
  URL.revokeObjectURL(prevSrc);
@@ -488,15 +699,39 @@ const useSpriteRender = (parts, apiConfig, onLoadStart, options) => {
488
699
  setIsRenderingSprite(false);
489
700
  }
490
701
  }, [apiConfig, onLoadStart, options?.mode]);
491
- // Effect to call API when parts content changes (using custom equality check)
702
+ // Check if inputs are equal
703
+ const areInputsEqual = (a, b) => {
704
+ if (!a)
705
+ return false;
706
+ if (a.type !== b.type)
707
+ return false;
708
+ if (a.type === 'shareCode' && b.type === 'shareCode') {
709
+ // Compare grid settings (shallow comparison of properties)
710
+ const gridSettingsEqual = JSON.stringify(a.gridSettings ?? {}) === JSON.stringify(b.gridSettings ?? {});
711
+ return a.shareCode === b.shareCode &&
712
+ a.profile === b.profile &&
713
+ a.showGrid === b.showGrid &&
714
+ a.cameraOffsetX === b.cameraOffsetX &&
715
+ gridSettingsEqual;
716
+ }
717
+ if (a.type === 'parts' && b.type === 'parts') {
718
+ // Compare grid settings (shallow comparison of properties)
719
+ const gridSettingsEqual = JSON.stringify(a.gridSettings ?? {}) === JSON.stringify(b.gridSettings ?? {});
720
+ return arePartsEqual(a.parts, b.parts) &&
721
+ a.showGrid === b.showGrid &&
722
+ a.cameraOffsetX === b.cameraOffsetX &&
723
+ gridSettingsEqual;
724
+ }
725
+ return false;
726
+ };
727
+ // Effect to call API when input changes
492
728
  useEffect(() => {
493
- const shouldFetch = previousPartsRef.current === null ||
494
- !arePartsEqual(previousPartsRef.current, parts);
729
+ const shouldFetch = !areInputsEqual(previousInputRef.current, normalizedInput);
495
730
  if (shouldFetch) {
496
- previousPartsRef.current = parts;
497
- fetchRenderSprite(parts);
731
+ previousInputRef.current = normalizedInput;
732
+ fetchRenderSprite(normalizedInput);
498
733
  }
499
- }, [parts, fetchRenderSprite]);
734
+ }, [normalizedInput, fetchRenderSprite]);
500
735
  // Cleanup effect for component unmount
501
736
  useEffect(() => {
502
737
  return () => {
@@ -568,20 +803,137 @@ const InstructionTooltip = ({ isVisible, progressValue, instructionIcon, }) => {
568
803
  } })) }));
569
804
  };
570
805
 
571
- const BuildRender = ({ parts, width, height, size, apiConfig, useSpriteRenderOptions, mouseSensitivity = 0.2, touchSensitivity = 0.2, }) => {
806
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
807
+ const getTouchDistance = (touches) => {
808
+ const first = touches[0];
809
+ const second = touches[1];
810
+ if (!first || !second)
811
+ return 0;
812
+ return Math.hypot(second.clientX - first.clientX, second.clientY - first.clientY);
813
+ };
814
+ const useZoomPan = ({ displayWidth, displayHeight, minScale = 1, maxScale = 4, } = {}) => {
815
+ const [scale, setScale] = useState(1);
816
+ const [isPinching, setIsPinching] = useState(false);
817
+ const scaleRef = useRef(1);
818
+ const pinchDataRef = useRef({
819
+ initialDistance: 0,
820
+ initialScale: 1,
821
+ });
822
+ const setScaleSafe = useCallback((next) => {
823
+ const clamped = clamp(next, minScale, maxScale);
824
+ if (clamped === scaleRef.current)
825
+ return;
826
+ scaleRef.current = clamped;
827
+ setScale(clamped);
828
+ }, [minScale, maxScale]);
829
+ const handleWheel = useCallback((event) => {
830
+ event.preventDefault();
831
+ event.stopPropagation();
832
+ const deltaY = event.deltaMode === 1
833
+ ? event.deltaY * 16
834
+ : event.deltaMode === 2
835
+ ? event.deltaY * (displayHeight ?? 300)
836
+ : event.deltaY;
837
+ const zoomFactor = Math.exp(-deltaY * 0.0015);
838
+ const nextScale = scaleRef.current * zoomFactor;
839
+ setScaleSafe(nextScale);
840
+ }, [setScaleSafe, displayHeight]);
841
+ const handleTouchStart = useCallback((event) => {
842
+ if (event.touches.length < 2) {
843
+ return false;
844
+ }
845
+ const distance = getTouchDistance(event.touches);
846
+ if (!distance) {
847
+ return false;
848
+ }
849
+ pinchDataRef.current = {
850
+ initialDistance: distance,
851
+ initialScale: scaleRef.current,
852
+ };
853
+ setIsPinching(true);
854
+ event.preventDefault();
855
+ return true;
856
+ }, []);
857
+ useEffect(() => {
858
+ if (!isPinching)
859
+ return;
860
+ const handleMove = (event) => {
861
+ if (event.touches.length < 2)
862
+ return;
863
+ const distance = getTouchDistance(event.touches);
864
+ if (!distance || pinchDataRef.current.initialDistance === 0)
865
+ return;
866
+ const scaleFactor = distance / pinchDataRef.current.initialDistance;
867
+ const nextScale = pinchDataRef.current.initialScale * scaleFactor;
868
+ setScaleSafe(nextScale);
869
+ event.preventDefault();
870
+ };
871
+ const handleEnd = (event) => {
872
+ if (event.touches.length < 2) {
873
+ setIsPinching(false);
874
+ }
875
+ };
876
+ window.addEventListener("touchmove", handleMove, { passive: false });
877
+ window.addEventListener("touchend", handleEnd);
878
+ window.addEventListener("touchcancel", handleEnd);
879
+ return () => {
880
+ window.removeEventListener("touchmove", handleMove);
881
+ window.removeEventListener("touchend", handleEnd);
882
+ window.removeEventListener("touchcancel", handleEnd);
883
+ };
884
+ }, [isPinching, setScaleSafe]);
885
+ const reset = useCallback(() => {
886
+ scaleRef.current = 1;
887
+ setScale(1);
888
+ }, []);
889
+ return {
890
+ scale,
891
+ isPinching,
892
+ handleWheel,
893
+ handleTouchStart,
894
+ reset,
895
+ };
896
+ };
897
+
898
+ const BuildRender = ({ parts, shareCode, width, height, size, apiConfig, useSpriteRenderOptions, mouseSensitivity = 0.2, touchSensitivity = 0.2, showGrid, cameraOffsetX, gridSettings, }) => {
572
899
  const canvasRef = useRef(null);
900
+ const containerRef = useRef(null);
573
901
  const [img, setImg] = useState(null);
574
902
  const [isLoading, setIsLoading] = useState(true);
575
903
  const [bouncingAllowed, setBouncingAllowed] = useState(false);
576
904
  const displayW = width ?? size ?? 300;
577
905
  const displayH = height ?? size ?? 300;
906
+ // Build the render input - prefer shareCode if provided (preserves interactive state like case fan slots)
907
+ const renderInput = useMemo(() => {
908
+ if (shareCode) {
909
+ return {
910
+ type: 'shareCode',
911
+ shareCode,
912
+ profile: parts?.profile,
913
+ showGrid,
914
+ cameraOffsetX,
915
+ gridSettings,
916
+ };
917
+ }
918
+ return {
919
+ type: 'parts',
920
+ parts: parts,
921
+ showGrid,
922
+ cameraOffsetX,
923
+ gridSettings,
924
+ };
925
+ }, [shareCode, parts, showGrid, cameraOffsetX, gridSettings]);
578
926
  // Use custom hook for sprite rendering
579
- const { spriteSrc, isRenderingSprite, renderError, spriteMetadata } = useSpriteRender(parts, apiConfig, undefined, useSpriteRenderOptions);
927
+ const { spriteSrc, isRenderingSprite, renderError, spriteMetadata } = useSpriteRender(renderInput, apiConfig, undefined, useSpriteRenderOptions);
580
928
  const { value: progressValue, isBouncing } = useBouncePatternProgress(bouncingAllowed);
581
929
  const total = spriteMetadata ? spriteMetadata.totalFrames : 72;
582
930
  const cols = spriteMetadata ? spriteMetadata.cols : 12;
583
931
  const rows = spriteMetadata ? spriteMetadata.rows : 6;
584
932
  const frameRef = useRef(0);
933
+ const { scale, handleWheel: handleZoomWheel, handleTouchStart: handleZoomTouchStart, reset: resetZoom, } = useZoomPan({
934
+ displayWidth: displayW,
935
+ displayHeight: displayH,
936
+ });
585
937
  // Image/frame sizes
586
938
  const frameW = img ? img.width / cols : 0;
587
939
  const frameH = img ? img.height / rows : 0;
@@ -640,8 +992,12 @@ const BuildRender = ({ parts, width, height, size, apiConfig, useSpriteRenderOpt
640
992
  ctx.clearRect(0, 0, targetW, targetH);
641
993
  ctx.imageSmoothingEnabled = true;
642
994
  ctx.imageSmoothingQuality = "high";
643
- ctx.drawImage(img, sx, sy, sw, sh, 0, 0, targetW, targetH);
644
- }, [img, frameW, frameH, displayW, displayH, cols, total]);
995
+ const scaledW = targetW * scale;
996
+ const scaledH = targetH * scale;
997
+ const offsetX = -((scaledW - targetW) / 2);
998
+ const offsetY = -((scaledH - targetH) / 2);
999
+ ctx.drawImage(img, sx, sy, sw, sh, offsetX, offsetY, scaledW, scaledH);
1000
+ }, [img, frameW, frameH, displayW, displayH, cols, total, scale]);
645
1001
  const { isDragging, handleMouseDown, handleTouchStart, hasDragged } = useSpriteScrubbing(canvasRef, total, {
646
1002
  mouseSensitivity,
647
1003
  touchSensitivity,
@@ -663,18 +1019,66 @@ const BuildRender = ({ parts, width, height, size, apiConfig, useSpriteRenderOpt
663
1019
  frameRef.current = frame;
664
1020
  draw(frame);
665
1021
  }, [progressValue, hasDragged, img, total, draw]);
666
- // Initial draw once image is ready
1022
+ // Reset zoom when sprite changes or container size updates
1023
+ useEffect(() => {
1024
+ resetZoom();
1025
+ }, [spriteSrc, displayW, displayH, resetZoom]);
1026
+ // Add native wheel event listener to prevent scrolling AND handle zoom
1027
+ useEffect(() => {
1028
+ const container = containerRef.current;
1029
+ if (!container)
1030
+ return;
1031
+ const handleNativeWheel = (event) => {
1032
+ event.preventDefault();
1033
+ event.stopPropagation();
1034
+ // Manually trigger zoom since we're preventing the React event
1035
+ event.deltaMode === 1
1036
+ ? event.deltaY * 16
1037
+ : event.deltaMode === 2
1038
+ ? event.deltaY * (displayH ?? 300)
1039
+ : event.deltaY;
1040
+ // We need to call the zoom handler directly
1041
+ // Create a synthetic React event-like object
1042
+ const syntheticEvent = {
1043
+ preventDefault: () => { },
1044
+ stopPropagation: () => { },
1045
+ deltaY: event.deltaY,
1046
+ deltaMode: event.deltaMode,
1047
+ currentTarget: container,
1048
+ };
1049
+ handleZoomWheel(syntheticEvent);
1050
+ hasDragged.current = true;
1051
+ };
1052
+ // Add listener to container to catch all wheel events
1053
+ container.addEventListener('wheel', handleNativeWheel, { passive: false });
1054
+ return () => {
1055
+ container.removeEventListener('wheel', handleNativeWheel);
1056
+ };
1057
+ }, [handleZoomWheel, scale, displayH]);
1058
+ // Initial draw once image is ready or zoom changes
667
1059
  useEffect(() => {
668
1060
  if (img && !isLoading) {
669
1061
  draw(frameRef.current);
670
1062
  }
671
1063
  }, [img, isLoading, draw]);
672
- return (jsxs("div", { style: {
1064
+ const handleCanvasTouchStart = useCallback((event) => {
1065
+ if (handleZoomTouchStart(event)) {
1066
+ hasDragged.current = true;
1067
+ return;
1068
+ }
1069
+ handleTouchStart(event);
1070
+ }, [handleZoomTouchStart, handleTouchStart, hasDragged]);
1071
+ useCallback((event) => {
1072
+ hasDragged.current = true;
1073
+ handleZoomWheel(event);
1074
+ }, [handleZoomWheel, hasDragged]);
1075
+ return (jsxs("div", { ref: containerRef, style: {
673
1076
  position: "relative",
674
1077
  width: displayW,
675
1078
  height: displayH,
676
1079
  backgroundColor: "black",
677
- }, children: [img && (jsx("canvas", { ref: canvasRef, onMouseDown: handleMouseDown, onTouchStart: handleTouchStart, style: {
1080
+ overflow: "hidden",
1081
+ }, children: [img && (jsx("canvas", { ref: canvasRef, onMouseDown: handleMouseDown, onTouchStart: handleCanvasTouchStart, style: {
678
1082
  width: displayW,
679
1083
  height: displayH,
680
1084
  cursor: isDragging ? "grabbing" : "grab",
@@ -843,5 +1247,5 @@ const BuildRenderVideo = ({ parts, width, height, size, apiConfig, useBuildRende
843
1247
  !hasDragged.current, progressValue: progressValue })] }));
844
1248
  };
845
1249
 
846
- export { API_BASE_URL, API_ENDPOINTS, BuildRender, BuildRenderVideo, DragIcon, InstructionTooltip, LoadingErrorOverlay, PartCategory, arePartsEqual, buildApiUrl, buildHeaders, calculateCircularFrame, calculateCircularTime, getAvailableParts, renderBuildExperimental, renderSpriteExperimental, useBouncePatternProgress, useBuildRender, useSpriteRender, useSpriteScrubbing, useVideoScrubbing };
1250
+ export { API_BASE_URL, API_ENDPOINTS, BuildRender, BuildRenderVideo, DragIcon, InstructionTooltip, LoadingErrorOverlay, PartCategory, arePartsEqual, buildApiUrl, buildHeaders, calculateCircularFrame, calculateCircularTime, createRenderByShareCodeJob, getAvailableParts, getBuildByShareCode, getPartsByIds, renderBuildExperimental, renderByShareCode, renderSpriteExperimental, useBouncePatternProgress, useBuildRender, useSpriteRender, useSpriteScrubbing, useVideoScrubbing };
847
1251
  //# sourceMappingURL=index.esm.js.map