@nafr/echo-ui
Version:
A UI library born for WAA
215 lines (214 loc) • 9.03 kB
JavaScript
import { jsx as _jsx } from "react/jsx-runtime";
import * as d3 from 'd3';
import { forwardRef, useEffect, useRef, useState, useMemo, useImperativeHandle } from 'react';
import { useResizeObserver } from '../../../lib/hooks';
import { cn, fixTo } from '../../../lib/utils';
import { useStyle } from './styles';
import { WIDTH, HEIGHT, LINE_COLOR, LINE_WIDTH, NODE_SIZE, NODE_COLOR, LIMITS } from './constants';
export const Envelope = forwardRef((props, ref) => {
const { data: _data, limits: _limits = LIMITS, lineColor = LINE_COLOR, lineWidth = LINE_WIDTH, nodeColor = NODE_COLOR, nodeSize = NODE_SIZE, onDataChange, ...restProps } = props;
useImperativeHandle(ref, () => envelopeRef.current);
const envelopeRef = useRef(null);
const svgRef = useRef(null);
const xScale = useRef(null);
const yScale = useRef(null);
const [isDragging, setIsDragging] = useState(false);
const [data, setData] = useState(_data);
const delayData = useMemo(() => fixTo(data.delay || 0), [data]);
const attackData = useMemo(() => fixTo(data.attack), [data]);
const holdData = useMemo(() => fixTo(data.hold || 0), [data]);
const decayData = useMemo(() => fixTo(data.decay), [data]);
const sustainData = useMemo(() => fixTo(data.sustain), [data]);
const releaseData = useMemo(() => fixTo(data.release), [data]);
const [points, setPoints] = useState([
{ type: 'delay', x: delayData, y: 0 },
{ type: 'attack', x: delayData + attackData, y: 1 },
{ type: 'hold', x: delayData + attackData + holdData, y: 1 },
{ type: 'sustain', x: delayData + attackData + holdData + decayData, y: sustainData },
{ type: 'release', x: delayData + attackData + holdData + decayData + releaseData, y: 0 },
]);
const delayPoint = useMemo(() => points[0], [points]);
const attackPoint = useMemo(() => points[1], [points]);
const holdPoint = useMemo(() => points[2], [points]);
const sustainPoint = useMemo(() => points[3], [points]);
const releasePoint = useMemo(() => points[4], [points]);
const limits = useMemo(() => {
const res = { ...LIMITS, ..._limits };
if (_data.delay === undefined)
res.delay = 0;
if (_data.hold === undefined)
res.hold = 0;
return res;
}, [_limits]);
const dimensions = useResizeObserver(envelopeRef, WIDTH, HEIGHT, () => {
generateScales();
generateLine();
generateNodes();
}, true, data);
useEffect(() => {
updateData();
}, [points]);
useEffect(() => {
setData(_data);
updatePointsByPropsData();
generateScales();
generateLine();
generateNodes();
}, [_data, _limits]);
const generateScales = () => {
const { width, height } = dimensions.current;
xScale.current = d3
.scaleLinear()
.domain([0, limits.delay + limits.attack + limits.hold + limits.decay + limits.release])
.range([0 + 2, width - 2]);
yScale.current = d3
.scaleLinear()
.domain([0, 1])
.range([height - 2, 2]);
};
const generateLine = () => {
const svg = d3.select(svgRef.current);
svg.selectAll('path.echo-path-line').remove();
const line = d3
.line()
.x((d) => xScale.current(d.x))
.y((d) => yScale.current(d.y));
for (let i = 0; i < points.length - 1; i++) {
const lineData = [points[i], points[i + 1]];
svg
.append('path')
.data([lineData])
.attr('class', 'echo-path-line')
.attr('d', line)
.attr('fill', 'none')
.attr('stroke', lineColor)
.attr('stroke-width', lineWidth);
}
};
const generateNodes = () => {
const svg = d3.select(svgRef.current);
svg.selectAll('circle.echo-circle-node').remove();
svg
.selectAll('circle')
.data(points.filter((p) => Object.keys(data).includes(p.type)))
.enter()
.append('circle')
.attr('class', `echo-circle-node ${isDragging ? 'cursor-grabbing' : 'cursor-pointer'}`)
.attr('type', (d) => d.type)
.attr('cx', (d) => xScale.current(d.x))
.attr('cy', (d) => yScale.current(d.y))
.attr('fill', 'var(--echo-card)')
.attr('r', nodeSize)
.attr('stroke', nodeColor)
.attr('stroke-width', 3);
svg.selectAll('circle.echo-circle-node').call(
// @ts-ignore
d3
.drag()
.on('start', onStartDragging)
.on('drag', onDragging)
.on('end', onEndDragging));
};
const onStartDragging = (_, d) => {
d.initialX = d.x;
d.initialY = d.y;
setIsDragging(true);
};
const onEndDragging = () => {
setIsDragging(false);
};
const onDragging = (e, d) => {
let newX = fixTo(d.initialX + xScale.current.invert(e.x));
let newY = fixTo(yScale.current.invert(yScale.current(d.initialY) + e.y));
switch (d.type) {
case 'delay':
newY = 0;
newX = Math.min(newX, limits.delay);
newX = Math.max(newX, 0);
delayPoint.x = newX;
attackPoint.x = newX + attackData;
holdPoint.x = newX + attackData + holdData;
sustainPoint.x = newX + attackData + holdData + decayData;
releasePoint.x = newX + attackData + holdData + decayData + releaseData;
break;
case 'attack':
newY = 1;
newX = Math.min(newX, delayPoint.x + limits.attack);
newX = Math.max(newX, delayPoint.x);
attackPoint.x = newX;
holdPoint.x = newX + holdData;
sustainPoint.x = newX + holdData + decayData;
releasePoint.x = newX + holdData + decayData + releaseData;
break;
case 'hold':
newY = 1;
newX = Math.min(newX, attackPoint.x + limits.hold);
newX = Math.max(newX, attackPoint.x);
holdPoint.x = newX;
sustainPoint.x = newX + decayData;
releasePoint.x = newX + decayData + releaseData;
break;
case 'sustain':
newY = Math.max(Math.min(newY, 1), 0);
newX = Math.min(newX, holdPoint.x + limits.decay);
newX = Math.max(newX, holdPoint.x);
sustainPoint.x = newX;
releasePoint.x = newX + releaseData;
break;
case 'release':
newY = 0;
newX = Math.min(newX, sustainPoint.x + limits.release);
newX = Math.max(newX, sustainPoint.x);
releasePoint.x = newX;
break;
}
d.x = newX;
d.y = newY;
setPoints((newPoints) => {
const updatedPoints = newPoints.map((p) => (p.type === d.type ? d : p));
return updatedPoints;
});
};
const updateData = () => {
const newData = {
delay: fixTo(delayPoint.x),
attack: fixTo(attackPoint.x - delayPoint.x),
hold: fixTo(holdPoint.x - attackPoint.x),
decay: fixTo(sustainPoint.x - holdPoint.x),
sustain: fixTo(sustainPoint.y),
release: fixTo(releasePoint.x - sustainPoint.x),
};
if (_data.delay === undefined)
delete newData?.delay;
if (_data.hold === undefined)
delete newData?.hold;
setData(newData);
onDataChange?.(newData);
};
const updatePointsByPropsData = () => {
const delay = fixTo(_data.delay || 0);
const attack = fixTo(_data.attack);
const hold = fixTo(_data.hold || 0);
const decay = fixTo(_data.decay);
const sustain = fixTo(_data.sustain);
const release = fixTo(_data.release);
delayPoint.x = delay;
attackPoint.x = delay + attack;
holdPoint.x = delay + attack + hold;
sustainPoint.x = delay + attack + hold + decay;
sustainPoint.y = sustain;
releasePoint.x = delay + attack + hold + decay + release;
};
useEffect(() => {
if (isDragging) {
document.getElementsByTagName('body')[0].style.cursor = 'grabbing';
}
else {
const svg = d3.select(svgRef.current);
svg.selectAll('circle.echo-circle-node').attr('class', 'echo-circle-node cursor-pointer');
document.getElementsByTagName('body')[0].style.cursor = '';
}
}, [isDragging]);
const { base, svg } = useStyle();
return (_jsx("div", { ref: envelopeRef, ...restProps, className: cn(base(), restProps.className), children: _jsx("svg", { ref: svgRef, className: cn(svg()) }) }));
});