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