Skip to content

Commit 716cc09

Browse files
committed
feat: RAF split engine with V8/browser optimisations
1 parent b11f64f commit 716cc09

4 files changed

Lines changed: 240 additions & 93 deletions

File tree

src/components/Layout.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ function Layout(props) {
277277
display={display}
278278
compactMode={compactMode}
279279
containerWidth={ganttWidth}
280+
containerRef={layoutRef}
280281
onMove={(value) => setGridWidth(value)}
281282
onDisplayChange={(display) => setDisplay(display)}
282283
/>

src/components/Resizer.jsx

Lines changed: 70 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { useState, useMemo, useRef, useCallback, useEffect } from 'react';
1+
import { useState, useMemo, useEffect } from 'react';
22
import { useWritableProp } from '@svar-ui/lib-react';
3+
import { useDragEngine } from './splitEngine/useDragEngine';
34
import './Resizer.css';
45

56
function Resizer(props) {
@@ -13,114 +14,48 @@ function Resizer(props) {
1314
containerWidth = 0,
1415
leftThreshold = 50,
1516
rightThreshold = 50,
17+
containerRef,
1618
} = props;
1719

18-
const [value, setValue] = useWritableProp(props.value ?? 0);
20+
const [value] = useWritableProp(props.value ?? 0);
1921
const [display, setDisplay] = useWritableProp(props.display ?? 'all');
2022

21-
function getBox(val) {
22-
let offset = 0;
23-
if (position == 'center') offset = size / 2;
24-
else if (position == 'before') offset = size;
25-
26-
const box = {
27-
size: [size + 'px', 'auto'],
28-
p: [val - offset + 'px', '0px'],
29-
p2: ['auto', '0px'],
30-
};
31-
32-
if (dir != 'x') {
33-
for (let name in box) box[name] = box[name].reverse();
34-
}
35-
return box;
36-
}
37-
38-
const [active, setActive] = useState(false);
3923
const [initialPosition, setInitialPosition] = useState(null);
40-
41-
const startRef = useRef(0);
42-
const posRef = useRef();
43-
const timeoutRef = useRef();
44-
const displayRef = useRef(display);
45-
46-
useEffect(() => {
47-
displayRef.current = display;
48-
}, [display]);
24+
const [fps, setFps] = useState(null);
4925

5026
useEffect(() => {
5127
if (initialPosition === null && value > 0) {
5228
setInitialPosition(value);
5329
}
5430
}, [initialPosition, value]);
5531

56-
function getEventPos(ev) {
57-
return dir == 'x' ? ev.clientX : ev.clientY;
58-
}
59-
60-
const move = useCallback(
61-
(ev) => {
62-
const newPos = posRef.current + getEventPos(ev) - startRef.current;
63-
64-
setValue(newPos);
65-
let nextDisplay;
66-
67-
if (newPos <= leftThreshold) {
68-
nextDisplay = 'chart';
69-
} else if (containerWidth - newPos <= rightThreshold) {
70-
nextDisplay = 'grid';
71-
} else {
72-
nextDisplay = 'all';
73-
}
74-
75-
if (displayRef.current !== nextDisplay) {
76-
setDisplay(nextDisplay);
77-
displayRef.current = nextDisplay;
78-
}
79-
80-
if (timeoutRef.current) clearTimeout(timeoutRef.current);
81-
timeoutRef.current = setTimeout(() => onMove && onMove(newPos), 100);
82-
},
83-
[containerWidth, leftThreshold, rightThreshold, onMove],
84-
);
85-
86-
const up = useCallback(() => {
87-
document.body.style.cursor = '';
88-
document.body.style.userSelect = '';
89-
setActive(false);
90-
window.removeEventListener('mousemove', move);
91-
window.removeEventListener('mouseup', up);
92-
}, [move]);
93-
94-
const cursor = useMemo(
95-
() => (display !== 'all' ? 'auto' : dir == 'x' ? 'ew-resize' : 'ns-resize'),
96-
[display, dir],
97-
);
98-
99-
const down = useCallback(
100-
(ev) => {
101-
// Prevent dragging when in normal mode and only one view is visible
102-
if (!compactMode && (display === 'grid' || display === 'chart')) {
103-
return;
104-
}
105-
106-
startRef.current = getEventPos(ev);
107-
108-
posRef.current = value;
109-
setActive(true);
110-
111-
document.body.style.cursor = cursor;
112-
document.body.style.userSelect = 'none';
113-
114-
window.addEventListener('mousemove', move);
115-
window.addEventListener('mouseup', up);
32+
// ── New RAF-based drag engine ────────────────────────────────────────
33+
const { active, onPointerDown } = useDragEngine({
34+
value,
35+
containerWidth,
36+
leftThreshold,
37+
rightThreshold,
38+
onMove,
39+
onDisplayChange: (nextDisplay) => {
40+
setDisplay(nextDisplay);
41+
onDisplayChange && onDisplayChange(nextDisplay);
11642
},
117-
[cursor, move, up, value, compactMode, display],
118-
);
43+
onFps: import.meta.env.DEV ? setFps : undefined,
44+
containerRef,
45+
});
46+
47+
// Guard: prevent drag when collapsed in non-compact mode
48+
const handlePointerDown = (e) => {
49+
if (!compactMode && (display === 'grid' || display === 'chart')) {
50+
return;
51+
}
52+
onPointerDown(e);
53+
};
11954

55+
// ── Expand / Collapse button handlers (UNCHANGED) ────────────────────
12056
function resetToInitial() {
12157
setDisplay('all');
12258
if (initialPosition !== null) {
123-
setValue(initialPosition);
12459
if (onMove) onMove(initialPosition);
12560
}
12661
}
@@ -150,8 +85,32 @@ function Resizer(props) {
15085
handleExpand('right');
15186
}
15287

88+
// ── Sizing / cursor (UNCHANGED) ──────────────────────────────────────
89+
function getBox(val) {
90+
let offset = 0;
91+
if (position == 'center') offset = size / 2;
92+
else if (position == 'before') offset = size;
93+
94+
const box = {
95+
size: [size + 'px', 'auto'],
96+
p: [val - offset + 'px', '0px'],
97+
p2: ['auto', '0px'],
98+
};
99+
100+
if (dir != 'x') {
101+
for (let name in box) box[name] = box[name].reverse();
102+
}
103+
return box;
104+
}
105+
106+
const cursor = useMemo(
107+
() => (display !== 'all' ? 'auto' : dir == 'x' ? 'ew-resize' : 'ns-resize'),
108+
[display, dir],
109+
);
110+
153111
const b = useMemo(() => getBox(value), [value, position, size, dir]);
154112

113+
// ── Render (UNCHANGED JSX structure) ────────────────────────────────
155114
const rootClassName = [
156115
'wx-resizer',
157116
`wx-resizer-${dir}`,
@@ -164,7 +123,7 @@ function Resizer(props) {
164123
return (
165124
<div
166125
className={'wx-pFykzMlT ' + rootClassName}
167-
onMouseDown={down}
126+
onPointerDown={handlePointerDown}
168127
style={{ width: b.size[0], height: b.size[1], cursor }}
169128
>
170129
<div className="wx-pFykzMlT wx-button-expand-box">
@@ -182,6 +141,24 @@ function Resizer(props) {
182141
</div>
183142
</div>
184143
<div className="wx-pFykzMlT wx-resizer-line"></div>
144+
{import.meta.env.DEV && active && fps !== null && (
145+
<div style={{
146+
position: 'fixed',
147+
bottom: 8,
148+
right: 8,
149+
background: 'rgba(0,0,0,0.75)',
150+
color: '#0f0',
151+
fontSize: 11,
152+
fontFamily: 'monospace',
153+
padding: '2px 6px',
154+
borderRadius: 4,
155+
pointerEvents: 'none',
156+
whiteSpace: 'nowrap',
157+
zIndex: 9999,
158+
}}>
159+
{fps} fps
160+
</div>
161+
)}
185162
</div>
186163
);
187164
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const clamp = (v, min, max) => (v < min ? min : v > max ? max : v);
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react';
2+
import { clamp } from './clamp';
3+
4+
/**
5+
* RAF-based drag engine — V8/browser optimised.
6+
*
7+
* Optimisations applied:
8+
* 1. CSS variable bypass — writes flex directly to the Grid DOM node during
9+
* drag; React only reconciles once on pointerup.
10+
* 2. Stable hidden class — `drag` object is never null; V8 keeps one IC forever.
11+
* 3. setPointerCapture — browser skips hit-testing on every pointermove.
12+
* 4. getCoalescedEvents() — uses the last coalesced position so no sub-frame
13+
* input is silently discarded.
14+
* 5. Closure-free hot path — all values live in engineRef; handleMove has zero
15+
* deps and is never re-JIT'd by V8.
16+
*/
17+
export function useDragEngine({
18+
value,
19+
containerWidth,
20+
leftThreshold = 50,
21+
rightThreshold = 50,
22+
onMove,
23+
onDisplayChange,
24+
onFps,
25+
containerRef, // ref to .wx-layout DOM node for direct CSS writes
26+
}) {
27+
// Optimization 2: always-allocated drag shape — V8 holds one hidden class
28+
const engineRef = useRef({
29+
pos: value,
30+
drag: { active: false, startPos: 0, startValue: 0 },
31+
raf: 0,
32+
lastFrameTime: 0,
33+
display: 'all',
34+
// Optimization 5: hot-path values stored here, not in closure deps
35+
containerWidth,
36+
leftThreshold,
37+
rightThreshold,
38+
onMove,
39+
onDisplayChange,
40+
onFps,
41+
containerRef,
42+
});
43+
44+
// Sync hot-path values on every render without recreating closures
45+
const eng = engineRef.current;
46+
eng.containerWidth = containerWidth;
47+
eng.leftThreshold = leftThreshold;
48+
eng.rightThreshold = rightThreshold;
49+
eng.onMove = onMove;
50+
eng.onDisplayChange = onDisplayChange;
51+
eng.onFps = onFps;
52+
eng.containerRef = containerRef;
53+
54+
const [active, setActive] = useState(false);
55+
56+
// Keep engine pos in sync with external value when not dragging
57+
useEffect(() => {
58+
if (!engineRef.current.drag.active) {
59+
engineRef.current.pos = value;
60+
}
61+
}, [value]);
62+
63+
// ── Move handler — stable closure, zero deps (optimization 5) ────────
64+
const handleMove = useCallback((e) => {
65+
const eng = engineRef.current;
66+
if (!eng.drag.active) return;
67+
68+
if (eng.raf) cancelAnimationFrame(eng.raf);
69+
70+
// Optimization 4: getCoalescedEvents — use latest sub-frame position
71+
const events = e.getCoalescedEvents?.() ?? [e];
72+
const clientX = events[events.length - 1].clientX;
73+
74+
eng.raf = requestAnimationFrame((timestamp) => {
75+
const d = eng.drag;
76+
if (!d.active) return;
77+
78+
if (eng.onFps && eng.lastFrameTime) {
79+
eng.onFps(Math.round(1000 / (timestamp - eng.lastFrameTime)));
80+
}
81+
eng.lastFrameTime = timestamp;
82+
83+
const newPos = clamp(
84+
d.startValue + (clientX - d.startPos),
85+
eng.leftThreshold,
86+
eng.containerWidth - eng.rightThreshold,
87+
);
88+
89+
if (Math.abs(newPos - eng.pos) < 0.5) return;
90+
eng.pos = newPos;
91+
92+
// Optimization 1: direct DOM write — zero React involvement during drag
93+
const layoutEl = eng.containerRef?.current;
94+
if (layoutEl) {
95+
const gridEl = layoutEl.querySelector('.wx-table-container');
96+
if (gridEl) gridEl.style.flex = `0 0 ${newPos}px`;
97+
}
98+
99+
// Display threshold check
100+
let nextDisplay;
101+
if (newPos <= eng.leftThreshold) nextDisplay = 'chart';
102+
else if (eng.containerWidth - newPos <= eng.rightThreshold)
103+
nextDisplay = 'grid';
104+
else nextDisplay = 'all';
105+
106+
if (eng.display !== nextDisplay) {
107+
eng.display = nextDisplay;
108+
eng.onDisplayChange?.(nextDisplay);
109+
}
110+
});
111+
}, []); // stable for component lifetime — reads everything from engineRef
112+
113+
// ── Up handler ───────────────────────────────────────────────────────
114+
const handleUp = useCallback(() => {
115+
const eng = engineRef.current;
116+
if (eng.raf) cancelAnimationFrame(eng.raf);
117+
eng.drag.active = false;
118+
eng.lastFrameTime = 0;
119+
120+
document.body.style.cursor = '';
121+
document.body.style.userSelect = '';
122+
123+
setActive(false);
124+
125+
// Clear inline CSS override — let React state take over
126+
const layoutEl = eng.containerRef?.current;
127+
if (layoutEl) {
128+
const gridEl = layoutEl.querySelector('.wx-table-container');
129+
if (gridEl) gridEl.style.flex = '';
130+
}
131+
132+
// Optimization 1: one React commit on drop, not 60 per second
133+
eng.onMove?.(eng.pos);
134+
}, []);
135+
136+
// ── Event wiring ──────────────────────────────────────────────────────
137+
useEffect(() => {
138+
if (!active) return;
139+
140+
document.addEventListener('pointermove', handleMove, { passive: true });
141+
document.addEventListener('pointerup', handleUp);
142+
document.addEventListener('pointercancel', handleUp);
143+
144+
return () => {
145+
document.removeEventListener('pointermove', handleMove);
146+
document.removeEventListener('pointerup', handleUp);
147+
document.removeEventListener('pointercancel', handleUp);
148+
};
149+
}, [active, handleMove, handleUp]);
150+
151+
// ── Start handler ────────────────────────────────────────────────────
152+
const onPointerDown = useCallback((e) => {
153+
const eng = engineRef.current;
154+
eng.drag.active = true;
155+
eng.drag.startPos = e.clientX;
156+
eng.drag.startValue = eng.pos;
157+
eng.lastFrameTime = 0;
158+
159+
// Optimization 3: setPointerCapture — browser skips hit-testing on every move
160+
e.currentTarget.setPointerCapture(e.pointerId);
161+
162+
setActive(true);
163+
document.body.style.cursor = 'ew-resize';
164+
document.body.style.userSelect = 'none';
165+
}, []);
166+
167+
return { active, onPointerDown };
168+
}

0 commit comments

Comments
 (0)