@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/api.d.ts +86 -1
- package/dist/hooks/useSpriteRender.d.ts +28 -1
- package/dist/hooks/useZoomPan.d.ts +16 -0
- package/dist/index.d.ts +321 -5
- package/dist/index.esm.js +424 -20
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +426 -18
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +206 -2
- package/package.json +1 -1
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 = (
|
|
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
|
|
450
|
-
|
|
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(
|
|
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({
|
|
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
|
-
//
|
|
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 =
|
|
496
|
-
!arePartsEqual(previousPartsRef.current, parts);
|
|
731
|
+
const shouldFetch = !areInputsEqual(previousInputRef.current, normalizedInput);
|
|
497
732
|
if (shouldFetch) {
|
|
498
|
-
|
|
499
|
-
fetchRenderSprite(
|
|
733
|
+
previousInputRef.current = normalizedInput;
|
|
734
|
+
fetchRenderSprite(normalizedInput);
|
|
500
735
|
}
|
|
501
|
-
}, [
|
|
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
|
|
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(
|
|
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
|
-
|
|
646
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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;
|