@buildcores/render-client 1.1.0 → 1.3.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/README.md +26 -0
- package/dist/hooks/useZoomPan.d.ts +16 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.esm.js +171 -186
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +235 -250
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +52 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -69,6 +69,9 @@ interface RenderBuildRequest {
|
|
|
69
69
|
parts: {
|
|
70
70
|
[K in PartCategory]?: string[];
|
|
71
71
|
};
|
|
72
|
+
format?: "video" | "sprite";
|
|
73
|
+
width?: number; // Optional: Canvas pixel width (256-2000)
|
|
74
|
+
height?: number; // Optional: Canvas pixel height (256-2000)
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
// Available part categories
|
|
@@ -86,6 +89,8 @@ enum PartCategory {
|
|
|
86
89
|
|
|
87
90
|
**Current Limitation**: Each category array must contain exactly one part ID. Multiple parts per category will be supported in future versions.
|
|
88
91
|
|
|
92
|
+
**Resolution Control**: You can specify custom `width` and `height` (both must be provided together, 256-2000 pixels) for higher or lower quality renders. If not specified, the default resolution is used.
|
|
93
|
+
|
|
89
94
|
#### Examples
|
|
90
95
|
|
|
91
96
|
**Complete Build (All Components)**
|
|
@@ -119,6 +124,27 @@ const caseOnly = {
|
|
|
119
124
|
<BuildRender parts={caseOnly} size={500} />;
|
|
120
125
|
```
|
|
121
126
|
|
|
127
|
+
**Custom Resolution (High Quality)**
|
|
128
|
+
|
|
129
|
+
```tsx
|
|
130
|
+
const highResBuild = {
|
|
131
|
+
parts: {
|
|
132
|
+
CPU: ["7xjqsomhr"],
|
|
133
|
+
GPU: ["z7pyphm9k"],
|
|
134
|
+
RAM: ["dpl1iyvb5"],
|
|
135
|
+
Motherboard: ["iwin2u9vx"],
|
|
136
|
+
PSU: ["m4kilv190"],
|
|
137
|
+
Storage: ["0bkvs17po"],
|
|
138
|
+
PCCase: ["qq9jamk7c"],
|
|
139
|
+
CPUCooler: ["62d8zelr5"],
|
|
140
|
+
},
|
|
141
|
+
width: 1920, // Custom resolution width
|
|
142
|
+
height: 1080, // Custom resolution height
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
<BuildRender parts={highResBuild} size={500} />;
|
|
146
|
+
```
|
|
147
|
+
|
|
122
148
|
### `getAvailableParts()` Function
|
|
123
149
|
|
|
124
150
|
Fetches all available PC parts from the BuildCores API.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type TouchEvent as ReactTouchEvent, type WheelEvent as ReactWheelEvent } from "react";
|
|
2
|
+
interface UseZoomOptions {
|
|
3
|
+
displayWidth?: number;
|
|
4
|
+
displayHeight?: number;
|
|
5
|
+
minScale?: number;
|
|
6
|
+
maxScale?: number;
|
|
7
|
+
}
|
|
8
|
+
interface UseZoomReturn {
|
|
9
|
+
scale: number;
|
|
10
|
+
isPinching: boolean;
|
|
11
|
+
handleWheel: (event: ReactWheelEvent<Element>) => void;
|
|
12
|
+
handleTouchStart: (event: ReactTouchEvent<HTMLCanvasElement>) => boolean;
|
|
13
|
+
reset: () => void;
|
|
14
|
+
}
|
|
15
|
+
export declare const useZoomPan: ({ displayWidth, displayHeight, minScale, maxScale, }?: UseZoomOptions) => UseZoomReturn;
|
|
16
|
+
export {};
|
package/dist/index.d.ts
CHANGED
|
@@ -340,6 +340,58 @@ interface RenderBuildRequest {
|
|
|
340
340
|
* @default "video"
|
|
341
341
|
*/
|
|
342
342
|
format?: "video" | "sprite";
|
|
343
|
+
/**
|
|
344
|
+
* Desired canvas pixel width (256-2000).
|
|
345
|
+
* Must be provided together with height.
|
|
346
|
+
*
|
|
347
|
+
* @example
|
|
348
|
+
* ```tsx
|
|
349
|
+
* const request: RenderBuildRequest = {
|
|
350
|
+
* parts: { CPU: ["7xjqsomhr"] },
|
|
351
|
+
* width: 1920,
|
|
352
|
+
* height: 1080
|
|
353
|
+
* };
|
|
354
|
+
* ```
|
|
355
|
+
*/
|
|
356
|
+
width?: number;
|
|
357
|
+
/**
|
|
358
|
+
* Desired canvas pixel height (256-2000).
|
|
359
|
+
* Must be provided together with width.
|
|
360
|
+
*
|
|
361
|
+
* @example
|
|
362
|
+
* ```tsx
|
|
363
|
+
* const request: RenderBuildRequest = {
|
|
364
|
+
* parts: { CPU: ["7xjqsomhr"] },
|
|
365
|
+
* width: 1920,
|
|
366
|
+
* height: 1080
|
|
367
|
+
* };
|
|
368
|
+
* ```
|
|
369
|
+
*/
|
|
370
|
+
height?: number;
|
|
371
|
+
/**
|
|
372
|
+
* Render quality profile that controls visual effects and rendering speed.
|
|
373
|
+
*
|
|
374
|
+
* - **cinematic**: All effects enabled (shadows, ambient occlusion, bloom) for highest quality
|
|
375
|
+
* - **flat**: No effects for clean, simple product shots
|
|
376
|
+
* - **fast**: Minimal rendering for fastest processing speed
|
|
377
|
+
*
|
|
378
|
+
* @example
|
|
379
|
+
* ```tsx
|
|
380
|
+
* const request: RenderBuildRequest = {
|
|
381
|
+
* parts: { CPU: ["7xjqsomhr"] },
|
|
382
|
+
* profile: 'cinematic' // High quality with all effects
|
|
383
|
+
* };
|
|
384
|
+
* ```
|
|
385
|
+
*
|
|
386
|
+
* @example Fast rendering
|
|
387
|
+
* ```tsx
|
|
388
|
+
* const request: RenderBuildRequest = {
|
|
389
|
+
* parts: { CPU: ["7xjqsomhr"] },
|
|
390
|
+
* profile: 'fast' // Quick render, minimal effects
|
|
391
|
+
* };
|
|
392
|
+
* ```
|
|
393
|
+
*/
|
|
394
|
+
profile?: 'cinematic' | 'flat' | 'fast';
|
|
343
395
|
}
|
|
344
396
|
/**
|
|
345
397
|
* Response structure containing all available parts for each category.
|
package/dist/index.esm.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
2
|
-
import { useState, useRef, useCallback, useEffect
|
|
2
|
+
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
3
|
+
import { useAnimationFrame } from 'framer-motion';
|
|
3
4
|
|
|
4
5
|
// Helper to extract clientX from mouse or touch events
|
|
5
6
|
const getClientX$1 = (e) => {
|
|
@@ -102,186 +103,6 @@ const useSpriteScrubbing = (canvasRef, totalFrames, options = {}) => {
|
|
|
102
103
|
};
|
|
103
104
|
};
|
|
104
105
|
|
|
105
|
-
/**
|
|
106
|
-
* @public
|
|
107
|
-
*/
|
|
108
|
-
const MotionConfigContext = createContext({
|
|
109
|
-
transformPagePoint: (p) => p,
|
|
110
|
-
isStatic: false,
|
|
111
|
-
reducedMotion: "never",
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
/*#__NO_SIDE_EFFECTS__*/
|
|
115
|
-
const noop = (any) => any;
|
|
116
|
-
|
|
117
|
-
if (process.env.NODE_ENV !== "production") ;
|
|
118
|
-
|
|
119
|
-
function createRenderStep(runNextFrame) {
|
|
120
|
-
/**
|
|
121
|
-
* We create and reuse two queues, one to queue jobs for the current frame
|
|
122
|
-
* and one for the next. We reuse to avoid triggering GC after x frames.
|
|
123
|
-
*/
|
|
124
|
-
let thisFrame = new Set();
|
|
125
|
-
let nextFrame = new Set();
|
|
126
|
-
/**
|
|
127
|
-
* Track whether we're currently processing jobs in this step. This way
|
|
128
|
-
* we can decide whether to schedule new jobs for this frame or next.
|
|
129
|
-
*/
|
|
130
|
-
let isProcessing = false;
|
|
131
|
-
let flushNextFrame = false;
|
|
132
|
-
/**
|
|
133
|
-
* A set of processes which were marked keepAlive when scheduled.
|
|
134
|
-
*/
|
|
135
|
-
const toKeepAlive = new WeakSet();
|
|
136
|
-
let latestFrameData = {
|
|
137
|
-
delta: 0.0,
|
|
138
|
-
timestamp: 0.0,
|
|
139
|
-
isProcessing: false,
|
|
140
|
-
};
|
|
141
|
-
function triggerCallback(callback) {
|
|
142
|
-
if (toKeepAlive.has(callback)) {
|
|
143
|
-
step.schedule(callback);
|
|
144
|
-
runNextFrame();
|
|
145
|
-
}
|
|
146
|
-
callback(latestFrameData);
|
|
147
|
-
}
|
|
148
|
-
const step = {
|
|
149
|
-
/**
|
|
150
|
-
* Schedule a process to run on the next frame.
|
|
151
|
-
*/
|
|
152
|
-
schedule: (callback, keepAlive = false, immediate = false) => {
|
|
153
|
-
const addToCurrentFrame = immediate && isProcessing;
|
|
154
|
-
const queue = addToCurrentFrame ? thisFrame : nextFrame;
|
|
155
|
-
if (keepAlive)
|
|
156
|
-
toKeepAlive.add(callback);
|
|
157
|
-
if (!queue.has(callback))
|
|
158
|
-
queue.add(callback);
|
|
159
|
-
return callback;
|
|
160
|
-
},
|
|
161
|
-
/**
|
|
162
|
-
* Cancel the provided callback from running on the next frame.
|
|
163
|
-
*/
|
|
164
|
-
cancel: (callback) => {
|
|
165
|
-
nextFrame.delete(callback);
|
|
166
|
-
toKeepAlive.delete(callback);
|
|
167
|
-
},
|
|
168
|
-
/**
|
|
169
|
-
* Execute all schedule callbacks.
|
|
170
|
-
*/
|
|
171
|
-
process: (frameData) => {
|
|
172
|
-
latestFrameData = frameData;
|
|
173
|
-
/**
|
|
174
|
-
* If we're already processing we've probably been triggered by a flushSync
|
|
175
|
-
* inside an existing process. Instead of executing, mark flushNextFrame
|
|
176
|
-
* as true and ensure we flush the following frame at the end of this one.
|
|
177
|
-
*/
|
|
178
|
-
if (isProcessing) {
|
|
179
|
-
flushNextFrame = true;
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
isProcessing = true;
|
|
183
|
-
[thisFrame, nextFrame] = [nextFrame, thisFrame];
|
|
184
|
-
// Execute this frame
|
|
185
|
-
thisFrame.forEach(triggerCallback);
|
|
186
|
-
// Clear the frame so no callbacks remain. This is to avoid
|
|
187
|
-
// memory leaks should this render step not run for a while.
|
|
188
|
-
thisFrame.clear();
|
|
189
|
-
isProcessing = false;
|
|
190
|
-
if (flushNextFrame) {
|
|
191
|
-
flushNextFrame = false;
|
|
192
|
-
step.process(frameData);
|
|
193
|
-
}
|
|
194
|
-
},
|
|
195
|
-
};
|
|
196
|
-
return step;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const stepsOrder = [
|
|
200
|
-
"read", // Read
|
|
201
|
-
"resolveKeyframes", // Write/Read/Write/Read
|
|
202
|
-
"update", // Compute
|
|
203
|
-
"preRender", // Compute
|
|
204
|
-
"render", // Write
|
|
205
|
-
"postRender", // Compute
|
|
206
|
-
];
|
|
207
|
-
const maxElapsed = 40;
|
|
208
|
-
function createRenderBatcher(scheduleNextBatch, allowKeepAlive) {
|
|
209
|
-
let runNextFrame = false;
|
|
210
|
-
let useDefaultElapsed = true;
|
|
211
|
-
const state = {
|
|
212
|
-
delta: 0.0,
|
|
213
|
-
timestamp: 0.0,
|
|
214
|
-
isProcessing: false,
|
|
215
|
-
};
|
|
216
|
-
const flagRunNextFrame = () => (runNextFrame = true);
|
|
217
|
-
const steps = stepsOrder.reduce((acc, key) => {
|
|
218
|
-
acc[key] = createRenderStep(flagRunNextFrame);
|
|
219
|
-
return acc;
|
|
220
|
-
}, {});
|
|
221
|
-
const { read, resolveKeyframes, update, preRender, render, postRender } = steps;
|
|
222
|
-
const processBatch = () => {
|
|
223
|
-
const timestamp = performance.now();
|
|
224
|
-
runNextFrame = false;
|
|
225
|
-
state.delta = useDefaultElapsed
|
|
226
|
-
? 1000 / 60
|
|
227
|
-
: Math.max(Math.min(timestamp - state.timestamp, maxElapsed), 1);
|
|
228
|
-
state.timestamp = timestamp;
|
|
229
|
-
state.isProcessing = true;
|
|
230
|
-
// Unrolled render loop for better per-frame performance
|
|
231
|
-
read.process(state);
|
|
232
|
-
resolveKeyframes.process(state);
|
|
233
|
-
update.process(state);
|
|
234
|
-
preRender.process(state);
|
|
235
|
-
render.process(state);
|
|
236
|
-
postRender.process(state);
|
|
237
|
-
state.isProcessing = false;
|
|
238
|
-
if (runNextFrame && allowKeepAlive) {
|
|
239
|
-
useDefaultElapsed = false;
|
|
240
|
-
scheduleNextBatch(processBatch);
|
|
241
|
-
}
|
|
242
|
-
};
|
|
243
|
-
const wake = () => {
|
|
244
|
-
runNextFrame = true;
|
|
245
|
-
useDefaultElapsed = true;
|
|
246
|
-
if (!state.isProcessing) {
|
|
247
|
-
scheduleNextBatch(processBatch);
|
|
248
|
-
}
|
|
249
|
-
};
|
|
250
|
-
const schedule = stepsOrder.reduce((acc, key) => {
|
|
251
|
-
const step = steps[key];
|
|
252
|
-
acc[key] = (process, keepAlive = false, immediate = false) => {
|
|
253
|
-
if (!runNextFrame)
|
|
254
|
-
wake();
|
|
255
|
-
return step.schedule(process, keepAlive, immediate);
|
|
256
|
-
};
|
|
257
|
-
return acc;
|
|
258
|
-
}, {});
|
|
259
|
-
const cancel = (process) => {
|
|
260
|
-
for (let i = 0; i < stepsOrder.length; i++) {
|
|
261
|
-
steps[stepsOrder[i]].cancel(process);
|
|
262
|
-
}
|
|
263
|
-
};
|
|
264
|
-
return { schedule, cancel, state, steps };
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
const { schedule: frame, cancel: cancelFrame} = createRenderBatcher(typeof requestAnimationFrame !== "undefined" ? requestAnimationFrame : noop, true);
|
|
268
|
-
|
|
269
|
-
function useAnimationFrame(callback) {
|
|
270
|
-
const initialTimestamp = useRef(0);
|
|
271
|
-
const { isStatic } = useContext(MotionConfigContext);
|
|
272
|
-
useEffect(() => {
|
|
273
|
-
if (isStatic)
|
|
274
|
-
return;
|
|
275
|
-
const provideTimeSinceStart = ({ timestamp, delta }) => {
|
|
276
|
-
if (!initialTimestamp.current)
|
|
277
|
-
initialTimestamp.current = timestamp;
|
|
278
|
-
callback(timestamp - initialTimestamp.current, delta);
|
|
279
|
-
};
|
|
280
|
-
frame.update(provideTimeSinceStart, true);
|
|
281
|
-
return () => cancelFrame(provideTimeSinceStart);
|
|
282
|
-
}, [callback]);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
106
|
function useBouncePatternProgress(enabled = true) {
|
|
286
107
|
const [value, setValue] = useState(0);
|
|
287
108
|
const [isBouncing, setIsBouncing] = useState(false);
|
|
@@ -353,6 +174,11 @@ const renderBuildExperimental = async (request, config) => {
|
|
|
353
174
|
const requestWithFormat = {
|
|
354
175
|
...request,
|
|
355
176
|
format: request.format || "video", // Default to video format
|
|
177
|
+
// Include width and height if provided
|
|
178
|
+
...(request.width !== undefined ? { width: request.width } : {}),
|
|
179
|
+
...(request.height !== undefined ? { height: request.height } : {}),
|
|
180
|
+
// Include profile if provided
|
|
181
|
+
...(request.profile ? { profile: request.profile } : {}),
|
|
356
182
|
};
|
|
357
183
|
const response = await fetch(buildApiUrl(API_ENDPOINTS.RENDER_BUILD_EXPERIMENTAL, config), {
|
|
358
184
|
method: "POST",
|
|
@@ -377,6 +203,11 @@ const createRenderBuildJob = async (request, config) => {
|
|
|
377
203
|
parts: request.parts,
|
|
378
204
|
// If provided, forward format; default handled server-side but we keep explicit default
|
|
379
205
|
...(request.format ? { format: request.format } : {}),
|
|
206
|
+
// Include width and height if provided
|
|
207
|
+
...(request.width !== undefined ? { width: request.width } : {}),
|
|
208
|
+
...(request.height !== undefined ? { height: request.height } : {}),
|
|
209
|
+
// Include profile if provided
|
|
210
|
+
...(request.profile ? { profile: request.profile } : {}),
|
|
380
211
|
};
|
|
381
212
|
const response = await fetch(buildApiUrl(API_ENDPOINTS.RENDER_BUILD, config), {
|
|
382
213
|
method: "POST",
|
|
@@ -437,6 +268,11 @@ const renderSpriteExperimental = async (request, config) => {
|
|
|
437
268
|
const requestWithFormat = {
|
|
438
269
|
...request,
|
|
439
270
|
format: "sprite",
|
|
271
|
+
// Include width and height if provided
|
|
272
|
+
...(request.width !== undefined ? { width: request.width } : {}),
|
|
273
|
+
...(request.height !== undefined ? { height: request.height } : {}),
|
|
274
|
+
// Include profile if provided
|
|
275
|
+
...(request.profile ? { profile: request.profile } : {}),
|
|
440
276
|
};
|
|
441
277
|
const response = await fetch(buildApiUrl(API_ENDPOINTS.RENDER_BUILD_EXPERIMENTAL, config), {
|
|
442
278
|
method: "POST",
|
|
@@ -738,8 +574,101 @@ const InstructionTooltip = ({ isVisible, progressValue, instructionIcon, }) => {
|
|
|
738
574
|
} })) }));
|
|
739
575
|
};
|
|
740
576
|
|
|
577
|
+
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|
|
578
|
+
const getTouchDistance = (touches) => {
|
|
579
|
+
const first = touches[0];
|
|
580
|
+
const second = touches[1];
|
|
581
|
+
if (!first || !second)
|
|
582
|
+
return 0;
|
|
583
|
+
return Math.hypot(second.clientX - first.clientX, second.clientY - first.clientY);
|
|
584
|
+
};
|
|
585
|
+
const useZoomPan = ({ displayWidth, displayHeight, minScale = 1, maxScale = 4, } = {}) => {
|
|
586
|
+
const [scale, setScale] = useState(1);
|
|
587
|
+
const [isPinching, setIsPinching] = useState(false);
|
|
588
|
+
const scaleRef = useRef(1);
|
|
589
|
+
const pinchDataRef = useRef({
|
|
590
|
+
initialDistance: 0,
|
|
591
|
+
initialScale: 1,
|
|
592
|
+
});
|
|
593
|
+
const setScaleSafe = useCallback((next) => {
|
|
594
|
+
const clamped = clamp(next, minScale, maxScale);
|
|
595
|
+
if (clamped === scaleRef.current)
|
|
596
|
+
return;
|
|
597
|
+
scaleRef.current = clamped;
|
|
598
|
+
setScale(clamped);
|
|
599
|
+
}, [minScale, maxScale]);
|
|
600
|
+
const handleWheel = useCallback((event) => {
|
|
601
|
+
event.preventDefault();
|
|
602
|
+
event.stopPropagation();
|
|
603
|
+
const deltaY = event.deltaMode === 1
|
|
604
|
+
? event.deltaY * 16
|
|
605
|
+
: event.deltaMode === 2
|
|
606
|
+
? event.deltaY * (displayHeight ?? 300)
|
|
607
|
+
: event.deltaY;
|
|
608
|
+
const zoomFactor = Math.exp(-deltaY * 0.0015);
|
|
609
|
+
const nextScale = scaleRef.current * zoomFactor;
|
|
610
|
+
setScaleSafe(nextScale);
|
|
611
|
+
}, [setScaleSafe, displayHeight]);
|
|
612
|
+
const handleTouchStart = useCallback((event) => {
|
|
613
|
+
if (event.touches.length < 2) {
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
const distance = getTouchDistance(event.touches);
|
|
617
|
+
if (!distance) {
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
pinchDataRef.current = {
|
|
621
|
+
initialDistance: distance,
|
|
622
|
+
initialScale: scaleRef.current,
|
|
623
|
+
};
|
|
624
|
+
setIsPinching(true);
|
|
625
|
+
event.preventDefault();
|
|
626
|
+
return true;
|
|
627
|
+
}, []);
|
|
628
|
+
useEffect(() => {
|
|
629
|
+
if (!isPinching)
|
|
630
|
+
return;
|
|
631
|
+
const handleMove = (event) => {
|
|
632
|
+
if (event.touches.length < 2)
|
|
633
|
+
return;
|
|
634
|
+
const distance = getTouchDistance(event.touches);
|
|
635
|
+
if (!distance || pinchDataRef.current.initialDistance === 0)
|
|
636
|
+
return;
|
|
637
|
+
const scaleFactor = distance / pinchDataRef.current.initialDistance;
|
|
638
|
+
const nextScale = pinchDataRef.current.initialScale * scaleFactor;
|
|
639
|
+
setScaleSafe(nextScale);
|
|
640
|
+
event.preventDefault();
|
|
641
|
+
};
|
|
642
|
+
const handleEnd = (event) => {
|
|
643
|
+
if (event.touches.length < 2) {
|
|
644
|
+
setIsPinching(false);
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
window.addEventListener("touchmove", handleMove, { passive: false });
|
|
648
|
+
window.addEventListener("touchend", handleEnd);
|
|
649
|
+
window.addEventListener("touchcancel", handleEnd);
|
|
650
|
+
return () => {
|
|
651
|
+
window.removeEventListener("touchmove", handleMove);
|
|
652
|
+
window.removeEventListener("touchend", handleEnd);
|
|
653
|
+
window.removeEventListener("touchcancel", handleEnd);
|
|
654
|
+
};
|
|
655
|
+
}, [isPinching, setScaleSafe]);
|
|
656
|
+
const reset = useCallback(() => {
|
|
657
|
+
scaleRef.current = 1;
|
|
658
|
+
setScale(1);
|
|
659
|
+
}, []);
|
|
660
|
+
return {
|
|
661
|
+
scale,
|
|
662
|
+
isPinching,
|
|
663
|
+
handleWheel,
|
|
664
|
+
handleTouchStart,
|
|
665
|
+
reset,
|
|
666
|
+
};
|
|
667
|
+
};
|
|
668
|
+
|
|
741
669
|
const BuildRender = ({ parts, width, height, size, apiConfig, useSpriteRenderOptions, mouseSensitivity = 0.2, touchSensitivity = 0.2, }) => {
|
|
742
670
|
const canvasRef = useRef(null);
|
|
671
|
+
const containerRef = useRef(null);
|
|
743
672
|
const [img, setImg] = useState(null);
|
|
744
673
|
const [isLoading, setIsLoading] = useState(true);
|
|
745
674
|
const [bouncingAllowed, setBouncingAllowed] = useState(false);
|
|
@@ -752,6 +681,10 @@ const BuildRender = ({ parts, width, height, size, apiConfig, useSpriteRenderOpt
|
|
|
752
681
|
const cols = spriteMetadata ? spriteMetadata.cols : 12;
|
|
753
682
|
const rows = spriteMetadata ? spriteMetadata.rows : 6;
|
|
754
683
|
const frameRef = useRef(0);
|
|
684
|
+
const { scale, handleWheel: handleZoomWheel, handleTouchStart: handleZoomTouchStart, reset: resetZoom, } = useZoomPan({
|
|
685
|
+
displayWidth: displayW,
|
|
686
|
+
displayHeight: displayH,
|
|
687
|
+
});
|
|
755
688
|
// Image/frame sizes
|
|
756
689
|
const frameW = img ? img.width / cols : 0;
|
|
757
690
|
const frameH = img ? img.height / rows : 0;
|
|
@@ -810,8 +743,12 @@ const BuildRender = ({ parts, width, height, size, apiConfig, useSpriteRenderOpt
|
|
|
810
743
|
ctx.clearRect(0, 0, targetW, targetH);
|
|
811
744
|
ctx.imageSmoothingEnabled = true;
|
|
812
745
|
ctx.imageSmoothingQuality = "high";
|
|
813
|
-
|
|
814
|
-
|
|
746
|
+
const scaledW = targetW * scale;
|
|
747
|
+
const scaledH = targetH * scale;
|
|
748
|
+
const offsetX = -((scaledW - targetW) / 2);
|
|
749
|
+
const offsetY = -((scaledH - targetH) / 2);
|
|
750
|
+
ctx.drawImage(img, sx, sy, sw, sh, offsetX, offsetY, scaledW, scaledH);
|
|
751
|
+
}, [img, frameW, frameH, displayW, displayH, cols, total, scale]);
|
|
815
752
|
const { isDragging, handleMouseDown, handleTouchStart, hasDragged } = useSpriteScrubbing(canvasRef, total, {
|
|
816
753
|
mouseSensitivity,
|
|
817
754
|
touchSensitivity,
|
|
@@ -833,18 +770,66 @@ const BuildRender = ({ parts, width, height, size, apiConfig, useSpriteRenderOpt
|
|
|
833
770
|
frameRef.current = frame;
|
|
834
771
|
draw(frame);
|
|
835
772
|
}, [progressValue, hasDragged, img, total, draw]);
|
|
836
|
-
//
|
|
773
|
+
// Reset zoom when sprite changes or container size updates
|
|
774
|
+
useEffect(() => {
|
|
775
|
+
resetZoom();
|
|
776
|
+
}, [spriteSrc, displayW, displayH, resetZoom]);
|
|
777
|
+
// Add native wheel event listener to prevent scrolling AND handle zoom
|
|
778
|
+
useEffect(() => {
|
|
779
|
+
const container = containerRef.current;
|
|
780
|
+
if (!container)
|
|
781
|
+
return;
|
|
782
|
+
const handleNativeWheel = (event) => {
|
|
783
|
+
event.preventDefault();
|
|
784
|
+
event.stopPropagation();
|
|
785
|
+
// Manually trigger zoom since we're preventing the React event
|
|
786
|
+
event.deltaMode === 1
|
|
787
|
+
? event.deltaY * 16
|
|
788
|
+
: event.deltaMode === 2
|
|
789
|
+
? event.deltaY * (displayH ?? 300)
|
|
790
|
+
: event.deltaY;
|
|
791
|
+
// We need to call the zoom handler directly
|
|
792
|
+
// Create a synthetic React event-like object
|
|
793
|
+
const syntheticEvent = {
|
|
794
|
+
preventDefault: () => { },
|
|
795
|
+
stopPropagation: () => { },
|
|
796
|
+
deltaY: event.deltaY,
|
|
797
|
+
deltaMode: event.deltaMode,
|
|
798
|
+
currentTarget: container,
|
|
799
|
+
};
|
|
800
|
+
handleZoomWheel(syntheticEvent);
|
|
801
|
+
hasDragged.current = true;
|
|
802
|
+
};
|
|
803
|
+
// Add listener to container to catch all wheel events
|
|
804
|
+
container.addEventListener('wheel', handleNativeWheel, { passive: false });
|
|
805
|
+
return () => {
|
|
806
|
+
container.removeEventListener('wheel', handleNativeWheel);
|
|
807
|
+
};
|
|
808
|
+
}, [handleZoomWheel, scale, displayH]);
|
|
809
|
+
// Initial draw once image is ready or zoom changes
|
|
837
810
|
useEffect(() => {
|
|
838
811
|
if (img && !isLoading) {
|
|
839
812
|
draw(frameRef.current);
|
|
840
813
|
}
|
|
841
814
|
}, [img, isLoading, draw]);
|
|
842
|
-
|
|
815
|
+
const handleCanvasTouchStart = useCallback((event) => {
|
|
816
|
+
if (handleZoomTouchStart(event)) {
|
|
817
|
+
hasDragged.current = true;
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
handleTouchStart(event);
|
|
821
|
+
}, [handleZoomTouchStart, handleTouchStart, hasDragged]);
|
|
822
|
+
useCallback((event) => {
|
|
823
|
+
hasDragged.current = true;
|
|
824
|
+
handleZoomWheel(event);
|
|
825
|
+
}, [handleZoomWheel, hasDragged]);
|
|
826
|
+
return (jsxs("div", { ref: containerRef, style: {
|
|
843
827
|
position: "relative",
|
|
844
828
|
width: displayW,
|
|
845
829
|
height: displayH,
|
|
846
830
|
backgroundColor: "black",
|
|
847
|
-
|
|
831
|
+
overflow: "hidden",
|
|
832
|
+
}, children: [img && (jsx("canvas", { ref: canvasRef, onMouseDown: handleMouseDown, onTouchStart: handleCanvasTouchStart, style: {
|
|
848
833
|
width: displayW,
|
|
849
834
|
height: displayH,
|
|
850
835
|
cursor: isDragging ? "grabbing" : "grab",
|