@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.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 = (
|
|
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
|
|
448
|
-
|
|
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(
|
|
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({
|
|
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
|
-
//
|
|
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 =
|
|
494
|
-
!arePartsEqual(previousPartsRef.current, parts);
|
|
729
|
+
const shouldFetch = !areInputsEqual(previousInputRef.current, normalizedInput);
|
|
495
730
|
if (shouldFetch) {
|
|
496
|
-
|
|
497
|
-
fetchRenderSprite(
|
|
731
|
+
previousInputRef.current = normalizedInput;
|
|
732
|
+
fetchRenderSprite(normalizedInput);
|
|
498
733
|
}
|
|
499
|
-
}, [
|
|
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
|
|
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(
|
|
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
|
-
|
|
644
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|