feat: feature/rainbow-component Added Gradient Path locally and started work on rainbow effect wrapper component
This commit is contained in:
parent
49e6152c28
commit
302302fa8f
@ -1,6 +1,7 @@
|
||||
import React from "react"
|
||||
import { Box, Center } from "@chakra-ui/react"
|
||||
import { GradientPath } from "gradient-path"
|
||||
|
||||
import { GradientPath } from "~/gp/GradientPath"
|
||||
|
||||
export const Rainbow = (props: any): JSX.Element => {
|
||||
const rainbow = ["#f34a4a", "#ffbc48", "#58ca70", "#47b5e6", "#a555e8"]
|
||||
@ -34,13 +35,49 @@ export const Rainbow = (props: any): JSX.Element => {
|
||||
|
||||
const svgRef = React.useRef(null)
|
||||
|
||||
const colourKeyframes = React.useMemo(() => {
|
||||
const ret = {}
|
||||
|
||||
repeatedColours.map((colour, i) => {
|
||||
const keyframe = `${(i / (repeatedColours.length - 1)) * 100}%`
|
||||
ret[keyframe] = {
|
||||
fill: colour,
|
||||
stroke: colour,
|
||||
}
|
||||
})
|
||||
|
||||
return ret
|
||||
}, [repeatedColours])
|
||||
|
||||
const colourClasses = React.useMemo(() => {
|
||||
const ret = {}
|
||||
|
||||
const steps = 500
|
||||
|
||||
repeatedColours.forEach((color, i) => {
|
||||
for (let j = 0; j < steps; j++) {
|
||||
const nth = `:nth-child(${repeatedColours?.length * j}n+${i + j})`
|
||||
ret[".path-segment" + nth] = {
|
||||
// animation: `rainbow-psych 2s linear infinite`,
|
||||
// "animation-delay": `-${(i + j) * 0.05}s`,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return ret
|
||||
}, [repeatedColours])
|
||||
|
||||
const svg = React.useMemo(() => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
"svg g:nth-child(2)": {
|
||||
display: "none",
|
||||
rainbowPsych: {
|
||||
rainbowPsych: "rainbowPsych",
|
||||
},
|
||||
"@keyframes rainbow-psych": {
|
||||
...colourKeyframes,
|
||||
},
|
||||
...colourClasses,
|
||||
}}
|
||||
width="100%"
|
||||
height="100%"
|
||||
@ -51,9 +88,17 @@ export const Rainbow = (props: any): JSX.Element => {
|
||||
viewBox={viewBox}
|
||||
width="100%"
|
||||
height="100%"
|
||||
preserveAspectRatio="none"
|
||||
// preserveAspectRatio="none"
|
||||
>
|
||||
<rect x={0} y={0} width={100} height={100} rx={10} ry={10}></rect>
|
||||
<rect
|
||||
// stroke="url(#linear-gradient)"
|
||||
x={0}
|
||||
y={0}
|
||||
width={100}
|
||||
height={100}
|
||||
rx={10}
|
||||
ry={10}
|
||||
></rect>
|
||||
{/* <path
|
||||
fill="none"
|
||||
stroke="blue"
|
||||
@ -64,7 +109,16 @@ export const Rainbow = (props: any): JSX.Element => {
|
||||
</svg>
|
||||
</Box>
|
||||
)
|
||||
}, [pathString, strokeWidth, extraStroke, viewBox])
|
||||
}, [
|
||||
pathString,
|
||||
strokeWidth,
|
||||
extraStroke,
|
||||
viewBox,
|
||||
repeatedColours,
|
||||
colors,
|
||||
colourKeyframes,
|
||||
colourClasses,
|
||||
])
|
||||
|
||||
React.useEffect(() => {
|
||||
const path = svgRef.current.querySelector("rect")
|
||||
@ -72,9 +126,9 @@ export const Rainbow = (props: any): JSX.Element => {
|
||||
if (path) {
|
||||
const gp = new GradientPath({
|
||||
path,
|
||||
segments: props?.segments || 2000,
|
||||
samples: props?.samples || 10,
|
||||
precision: props?.precision || 10,
|
||||
segments: props?.segments || 250,
|
||||
samples: props?.samples || 5,
|
||||
precision: props?.precision || 5,
|
||||
})
|
||||
|
||||
const colors = repeatedColours?.map((color, idx) => {
|
||||
@ -87,46 +141,18 @@ export const Rainbow = (props: any): JSX.Element => {
|
||||
gp.render({
|
||||
type: "path",
|
||||
width: 10,
|
||||
fill: colors,
|
||||
strokeWidth: 1,
|
||||
stroke: colors,
|
||||
fill: ["orange", "blue", "orange"],
|
||||
// fill: colors,
|
||||
strokeWidth: 0.5,
|
||||
stroke: ["orange", "blue", "orange"],
|
||||
// stroke: colors,
|
||||
})
|
||||
|
||||
let prevColours = colors
|
||||
|
||||
const interval = setInterval(() => {
|
||||
// pop first colour and append new first colour to end
|
||||
const prevColoursClone = prevColours.map((colour) => {
|
||||
return {
|
||||
...colour,
|
||||
}
|
||||
})
|
||||
const newColoursStart = prevColoursClone.slice(1)
|
||||
const newColours = [...newColoursStart, { ...newColoursStart[0] }]
|
||||
|
||||
const adjustedPosition = newColours.map((colour, idx) => {
|
||||
return {
|
||||
...colour,
|
||||
pos: idx / (newColours.length - 1),
|
||||
}
|
||||
})
|
||||
|
||||
prevColours = adjustedPosition
|
||||
|
||||
gp.render({
|
||||
type: "path",
|
||||
width: 10,
|
||||
fill: adjustedPosition,
|
||||
strokeWidth: 1,
|
||||
stroke: adjustedPosition,
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
// clearInterval(interval)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, [props, repeatedColours])
|
||||
|
||||
return (
|
||||
<Center
|
||||
|
94
remix/app/gp/GradientPath.js
Normal file
94
remix/app/gp/GradientPath.js
Normal file
@ -0,0 +1,94 @@
|
||||
import { DEFAULT_PRECISION } from "./_constants"
|
||||
import { getData, strokeToFill } from "./_data"
|
||||
import { convertPathToNode, segmentToD, styleAttrs, svgElem } from "./_utils"
|
||||
|
||||
export const GradientPath = class {
|
||||
constructor({ path, segments, samples, precision = DEFAULT_PRECISION }) {
|
||||
// If the path being passed isn't a DOM node already, make it one
|
||||
this.path = convertPathToNode(path)
|
||||
|
||||
this.segments = segments
|
||||
this.samples = samples
|
||||
this.precision = precision
|
||||
|
||||
// Check if nodeName is path and that the path is closed, otherwise it's closed by default
|
||||
this.pathClosed =
|
||||
this.path.nodeName == "path"
|
||||
? this.path.getAttribute("d").match(/z/gi)
|
||||
: true
|
||||
|
||||
// Store the render cycles that the user creates
|
||||
this.renders = []
|
||||
|
||||
// Append a group to the SVG to capture everything we render and ensure our paths and circles are properly encapsulated
|
||||
this.svg = path.closest("svg")
|
||||
this.group = svgElem("g", {
|
||||
class: "gradient-path",
|
||||
})
|
||||
|
||||
// Get the data
|
||||
this.data = getData({ path, segments, samples, precision })
|
||||
|
||||
// Append the main group to the SVG
|
||||
this.svg.appendChild(this.group)
|
||||
|
||||
// Remove the main path once we have the data values
|
||||
this.path.parentNode.removeChild(this.path)
|
||||
}
|
||||
|
||||
render({ type, stroke, strokeWidth, fill, width }) {
|
||||
// Store information from this render cycle
|
||||
const renderCycle = {}
|
||||
|
||||
// Create a group for each element
|
||||
const elemGroup = svgElem("g", { class: `element-${type}` })
|
||||
|
||||
this.group.appendChild(elemGroup)
|
||||
renderCycle.group = elemGroup
|
||||
|
||||
if (type === "path") {
|
||||
// If we specify a width and fill, then we need to outline the path and then average the join points of the segments
|
||||
// If we do not specify a width and fill, then we will be stroking and can leave the data "as is"
|
||||
renderCycle.data =
|
||||
width && fill
|
||||
? strokeToFill(this.data, width, this.precision, this.pathClosed)
|
||||
: this.data
|
||||
|
||||
for (let j = 0; j < renderCycle.data.length; j++) {
|
||||
const { samples, progress } = renderCycle.data[j]
|
||||
|
||||
// Create a path for each segment and append it to its elemGroup
|
||||
elemGroup.appendChild(
|
||||
svgElem("path", {
|
||||
class: "path-segment",
|
||||
d: segmentToD(samples),
|
||||
...styleAttrs(fill, stroke, strokeWidth, progress),
|
||||
})
|
||||
)
|
||||
}
|
||||
} else if (type === "circle") {
|
||||
renderCycle.data = this.data.flatMap(({ samples }) => samples)
|
||||
|
||||
for (let j = 0; j < renderCycle.data.length; j++) {
|
||||
const { x, y, progress } = renderCycle.data[j]
|
||||
|
||||
// Create a circle for each sample and append it to its elemGroup
|
||||
elemGroup.appendChild(
|
||||
svgElem("circle", {
|
||||
class: "circle-sample",
|
||||
cx: x,
|
||||
cy: y,
|
||||
r: width / 2,
|
||||
...styleAttrs(fill, stroke, strokeWidth, progress),
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Save the information in the current renderCycle and pop it onto the renders array
|
||||
this.renders.push(renderCycle)
|
||||
|
||||
// Return this for method chaining
|
||||
return this
|
||||
}
|
||||
}
|
8
remix/app/gp/Sample.js
Normal file
8
remix/app/gp/Sample.js
Normal file
@ -0,0 +1,8 @@
|
||||
export default class Sample {
|
||||
constructor({ x, y, progress, segment }) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.progress = progress;
|
||||
this.segment = segment;
|
||||
}
|
||||
}
|
8
remix/app/gp/Segment.js
Normal file
8
remix/app/gp/Segment.js
Normal file
@ -0,0 +1,8 @@
|
||||
import { getMiddleSample } from './_utils';
|
||||
|
||||
export default class Segment {
|
||||
constructor({ samples }) {
|
||||
this.samples = samples;
|
||||
this.progress = getMiddleSample(samples).progress;
|
||||
}
|
||||
}
|
1
remix/app/gp/_constants.js
Normal file
1
remix/app/gp/_constants.js
Normal file
@ -0,0 +1 @@
|
||||
export const DEFAULT_PRECISION = 2;
|
219
remix/app/gp/_data.js
Normal file
219
remix/app/gp/_data.js
Normal file
@ -0,0 +1,219 @@
|
||||
import Sample from './Sample';
|
||||
import Segment from './Segment';
|
||||
import { convertPathToNode } from './_utils';
|
||||
import { DEFAULT_PRECISION } from './_constants';
|
||||
|
||||
// The main function responsible for getting data
|
||||
// This will take a path, number of samples, number of samples, and a precision value
|
||||
// It will return an array of Segments, which in turn contains an array of Samples
|
||||
// This can later be used to generate a stroked path, converted to outlines for a filled path, or flattened for plotting SVG circles
|
||||
export const getData = ({
|
||||
path,
|
||||
segments,
|
||||
samples,
|
||||
precision = DEFAULT_PRECISION
|
||||
}) => {
|
||||
// Convert the given path to a DOM node if it isn't already one
|
||||
path = convertPathToNode(path);
|
||||
|
||||
// We decrement the number of samples per segment because when we group them later we will add on the first sample of the following segment
|
||||
if (samples > 1) samples--;
|
||||
|
||||
// Get total length of path, total number of samples we will be generating, and two blank arrays to hold samples and segments
|
||||
const pathLength = path.getTotalLength(),
|
||||
totalSamples = segments * samples,
|
||||
allSamples = [],
|
||||
allSegments = [];
|
||||
|
||||
// For the number of total samples, get the x, y, and progress values for each sample along the path
|
||||
for (let sample = 0; sample <= totalSamples; sample++) {
|
||||
const progress = sample / totalSamples;
|
||||
|
||||
let { x, y } = path.getPointAtLength(progress * pathLength);
|
||||
|
||||
// If the user asks to round our x and y values, do so
|
||||
if (precision) {
|
||||
x = +x.toFixed(precision);
|
||||
y = +y.toFixed(precision);
|
||||
}
|
||||
|
||||
// Create a new Sample and push it onto the allSamples array
|
||||
allSamples.push(new Sample({ x, y, progress }));
|
||||
}
|
||||
|
||||
// Out of all the samples gathered previously, sort them into groups of segments
|
||||
// Each group includes the samples of the current segment, with the last sample being first sample from the next segment
|
||||
for (let segment = 0; segment < segments; segment++) {
|
||||
const currentStart = segment * samples,
|
||||
nextStart = currentStart + samples,
|
||||
segmentSamples = [];
|
||||
|
||||
// Push all current samples onto segmentSamples
|
||||
for (let samInSeg = 0; samInSeg < samples; samInSeg++) {
|
||||
segmentSamples.push(allSamples[currentStart + samInSeg]);
|
||||
}
|
||||
|
||||
// Push the first sample from the next segment onto segmentSamples
|
||||
segmentSamples.push(allSamples[nextStart]);
|
||||
|
||||
// Create a new Segment with the samples from segmentSamples
|
||||
allSegments.push(new Segment({ samples: segmentSamples }));
|
||||
}
|
||||
|
||||
// Return our group of segments
|
||||
return allSegments;
|
||||
};
|
||||
|
||||
// The function responsible for converting strokable data (from getData()) into fillable data
|
||||
// This allows any SVG path to be filled instead of just stroked, allowing for the user to fill and stroke paths simultaneously
|
||||
// We start by outlining the stroked data given a specified width and the we average together the edges where adjacent segments touch
|
||||
export const strokeToFill = (data, width, precision, pathClosed) => {
|
||||
const outlinedStrokes = outlineStrokes(data, width, precision),
|
||||
averagedSegmentJoins = averageSegmentJoins(
|
||||
outlinedStrokes,
|
||||
precision,
|
||||
pathClosed
|
||||
);
|
||||
|
||||
return averagedSegmentJoins;
|
||||
};
|
||||
|
||||
// An internal function for outlining stroked data
|
||||
const outlineStrokes = (data, width, precision) => {
|
||||
// We need to get the points perpendicular to a startPoint, given an angle, radius, and precision
|
||||
const getPerpSamples = (angle, radius, precision, startPoint) => {
|
||||
const p0 = new Sample({
|
||||
...startPoint,
|
||||
x: Math.sin(angle) * radius + startPoint.x,
|
||||
y: -Math.cos(angle) * radius + startPoint.y
|
||||
}),
|
||||
p1 = new Sample({
|
||||
...startPoint,
|
||||
x: -Math.sin(angle) * radius + startPoint.x,
|
||||
y: Math.cos(angle) * radius + startPoint.y
|
||||
});
|
||||
|
||||
// If the user asks to round our x and y values, do so
|
||||
if (precision) {
|
||||
p0.x = +p0.x.toFixed(precision);
|
||||
p0.y = +p0.y.toFixed(precision);
|
||||
p1.x = +p1.x.toFixed(precision);
|
||||
p1.y = +p1.y.toFixed(precision);
|
||||
}
|
||||
|
||||
return [p0, p1];
|
||||
};
|
||||
|
||||
// We need to set the radius (half of the width) and have a holding array for outlined Segments
|
||||
const radius = width / 2,
|
||||
outlinedData = [];
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const samples = data[i].samples,
|
||||
segmentSamples = [];
|
||||
|
||||
// For each sample point and the following sample point (if there is one) compute the angle
|
||||
// Also compute the sample's various perpendicular points (with a distance of radius away from the sample point)
|
||||
for (let j = 0; j < samples.length; j++) {
|
||||
// If we're at the end of the segment and there are no further points, get outta here!
|
||||
if (samples[j + 1] === undefined) break;
|
||||
|
||||
const p0 = samples[j], // First point
|
||||
p1 = samples[j + 1], // Second point
|
||||
angle = Math.atan2(p1.y - p0.y, p1.x - p0.x), // Perpendicular angle to p0 and p1
|
||||
p0Perps = getPerpSamples(angle, radius, precision, p0), // Get perpedicular points with a distance of radius away from p0
|
||||
p1Perps = getPerpSamples(angle, radius, precision, p1); // Get perpedicular points with a distance of radius away from p1
|
||||
|
||||
// We only need the p0 perpendenciular points for the first sample
|
||||
// The p0 for j > 0 will always be the same as p1 anyhow, so let's not add redundant points
|
||||
if (j === 0) {
|
||||
segmentSamples.push(...p0Perps);
|
||||
}
|
||||
|
||||
// Always push the second sample point's perpendicular points
|
||||
segmentSamples.push(...p1Perps);
|
||||
}
|
||||
|
||||
// segmentSamples is out of order...
|
||||
// Given a segmentSamples length of 8, the points need to be rearranged from: 0, 2, 4, 6, 7, 5, 3, 1
|
||||
outlinedData.push(
|
||||
new Segment({
|
||||
samples: [
|
||||
...segmentSamples.filter((s, i) => i % 2 === 0),
|
||||
...segmentSamples.filter((s, i) => i % 2 === 1).reverse()
|
||||
]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return outlinedData;
|
||||
};
|
||||
|
||||
// An internal function taking outlinedData (from outlineStrokes()) and averaging adjacent edges
|
||||
// If we didn't do this, our data would be fillable, but it would look stroked
|
||||
// This function fixes where segments overlap and underlap each other
|
||||
const averageSegmentJoins = (outlinedData, precision, pathClosed) => {
|
||||
// Find the average x and y between two points (p0 and p1)
|
||||
const avg = (p0, p1) => ({
|
||||
x: (p0.x + p1.x) / 2,
|
||||
y: (p0.y + p1.y) / 2
|
||||
});
|
||||
|
||||
// Recombine the new x and y positions with all the other keys in the object
|
||||
const combine = (segment, pos, avg) => ({
|
||||
...segment[pos],
|
||||
x: avg.x,
|
||||
y: avg.y
|
||||
});
|
||||
|
||||
const init_outlinedData = JSON.parse(JSON.stringify(outlinedData)); //clone initial outlinedData Object
|
||||
|
||||
for (let i = 0; i < outlinedData.length; i++) {
|
||||
// If path is closed: the current segment's samples;
|
||||
// If path is open: the current segments' samples, as long as it's not the last segment; Otherwise, the current segments' sample of the initial outlinedData object
|
||||
const currentSamples = pathClosed
|
||||
? outlinedData[i].samples
|
||||
: outlinedData[i + 1]
|
||||
? outlinedData[i].samples
|
||||
: init_outlinedData[i].samples,
|
||||
// If path is closed: the next segment's samples, otherwise, the first segment's samples
|
||||
// If path is open: the next segment's samples, otherwise, the first segment's samples of the initial outlinedData object
|
||||
nextSamples = pathClosed
|
||||
? outlinedData[i + 1]
|
||||
? outlinedData[i + 1].samples
|
||||
: outlinedData[0].samples
|
||||
: outlinedData[i + 1]
|
||||
? outlinedData[i + 1].samples
|
||||
: init_outlinedData[0].samples,
|
||||
currentMiddle = currentSamples.length / 2, // The "middle" sample in the current segment's samples
|
||||
nextEnd = nextSamples.length - 1; // The last sample in the next segment's samples
|
||||
|
||||
// Average two sets of outlined samples to create p0Average and p1Average
|
||||
const p0Average = avg(currentSamples[currentMiddle - 1], nextSamples[0]),
|
||||
p1Average = avg(currentSamples[currentMiddle], nextSamples[nextEnd]);
|
||||
|
||||
// If the user asks to round our x and y values, do so
|
||||
if (precision) {
|
||||
p0Average.x = +p0Average.x.toFixed(precision);
|
||||
p0Average.y = +p0Average.y.toFixed(precision);
|
||||
p1Average.x = +p1Average.x.toFixed(precision);
|
||||
p1Average.y = +p1Average.y.toFixed(precision);
|
||||
}
|
||||
|
||||
// Replace the previous values with new Samples
|
||||
currentSamples[currentMiddle - 1] = new Sample({
|
||||
...combine(currentSamples, currentMiddle - 1, p0Average)
|
||||
});
|
||||
currentSamples[currentMiddle] = new Sample({
|
||||
...combine(currentSamples, currentMiddle, p1Average)
|
||||
});
|
||||
nextSamples[0] = new Sample({
|
||||
...combine(nextSamples, 0, p0Average)
|
||||
});
|
||||
nextSamples[nextEnd] = new Sample({
|
||||
...combine(nextSamples, nextEnd, p1Average)
|
||||
});
|
||||
}
|
||||
|
||||
return outlinedData;
|
||||
};
|
71
remix/app/gp/_utils.js
Normal file
71
remix/app/gp/_utils.js
Normal file
@ -0,0 +1,71 @@
|
||||
import tinygradient from 'tinygradient';
|
||||
|
||||
// An internal function to help with easily creating SVG elements with an object of attributes
|
||||
export const svgElem = (type, attrs) => {
|
||||
const elem = document.createElementNS('http://www.w3.org/2000/svg', type),
|
||||
attributes = Object.keys(attrs);
|
||||
|
||||
for (let i = 0; i < attributes.length; i++) {
|
||||
const attr = attributes[i];
|
||||
|
||||
elem.setAttribute(attr, attrs[attr]);
|
||||
}
|
||||
|
||||
return elem;
|
||||
};
|
||||
|
||||
// An internal function to help with the repetition of adding fill, stroke, and stroke-width attributes
|
||||
export const styleAttrs = (fill, stroke, strokeWidth, progress) => {
|
||||
const determineColor = (type, progress) =>
|
||||
typeof type === 'string' ? type : tinygradient(type).rgbAt(progress);
|
||||
|
||||
const attrs = {};
|
||||
|
||||
if (stroke) {
|
||||
attrs['stroke'] = determineColor(stroke, progress);
|
||||
attrs['stroke-width'] = strokeWidth;
|
||||
}
|
||||
|
||||
if (fill) {
|
||||
attrs['fill'] = determineColor(fill, progress);
|
||||
}
|
||||
|
||||
return attrs;
|
||||
};
|
||||
|
||||
// An internal function to convert any array of samples into a "d" attribute to be passed to an SVG path
|
||||
export const segmentToD = samples => {
|
||||
let d = '';
|
||||
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const { x, y } = samples[i],
|
||||
prevSample = i === 0 ? null : samples[i - 1];
|
||||
|
||||
if (i === 0 && i !== samples.length - 1) {
|
||||
d += `M${x},${y}`;
|
||||
} else if (x !== prevSample.x && y !== prevSample.y) {
|
||||
d += `L${x},${y}`;
|
||||
} else if (x !== prevSample.x) {
|
||||
d += `H${x}`;
|
||||
} else if (y !== prevSample.y) {
|
||||
d += `V${y}`;
|
||||
}
|
||||
|
||||
if (i === samples.length - 1) {
|
||||
d += 'Z';
|
||||
}
|
||||
}
|
||||
|
||||
return d;
|
||||
};
|
||||
|
||||
// An internal function for getting the colors of a segment, we need to get middle most sample (sorted by progress along the path)
|
||||
export const getMiddleSample = samples => {
|
||||
const sortedSamples = [...samples].sort((a, b) => a.progress - b.progress);
|
||||
|
||||
return sortedSamples[(sortedSamples.length / 2) | 0];
|
||||
};
|
||||
|
||||
// An internal function for converting any D3 selection or DOM-like element into a DOM node
|
||||
export const convertPathToNode = path =>
|
||||
path instanceof Element || path instanceof HTMLDocument ? path : path.node();
|
2
remix/app/gp/index.js
Normal file
2
remix/app/gp/index.js
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as GradientPath } from './GradientPath';
|
||||
export { getData, strokeToFill } from './_data';
|
@ -20,6 +20,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-click-away-listener": "^2.2.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"tinygradient": "^1.1.5",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -41,6 +41,9 @@ dependencies:
|
||||
react-dom:
|
||||
specifier: ^18.2.0
|
||||
version: 18.2.0(react@18.2.0)
|
||||
tinygradient:
|
||||
specifier: ^1.1.5
|
||||
version: 1.1.5
|
||||
uuid:
|
||||
specifier: ^9.0.0
|
||||
version: 9.0.0
|
||||
|
Loading…
Reference in New Issue
Block a user