mirror of
https://github.com/lucide-icons/lucide.git
synced 2025-12-16 17:17:43 +01:00
feat(SvgPreview): add features from lucide studio (#3365)
* feat(SvgPreview): add features from lucide studio * Update docs/.vitepress/lib/SvgPreview/index.tsx --------- Co-authored-by: Eric Fennis <eric.fennis@gmail.com>
This commit is contained in:
@@ -13,10 +13,10 @@ export default eventHandler((event) => {
|
|||||||
const data = pathData.at(-1).slice(0, -4);
|
const data = pathData.at(-1).slice(0, -4);
|
||||||
const [name] = pathData;
|
const [name] = pathData;
|
||||||
|
|
||||||
const src = Buffer.from(data, 'base64')
|
const src = Buffer.from(data, 'base64').toString('utf8').replaceAll('\n', '');
|
||||||
.toString('utf8')
|
|
||||||
.replaceAll('\n', '')
|
const width = parseInt((src.includes('svg') ? src.match(/width="(\d+)"/)?.[1] : null) ?? '24');
|
||||||
.replace(/<svg[^>]*>|<\/svg>/g, '');
|
const height = parseInt((src.includes('svg') ? src.match(/height="(\d+)"/)?.[1] : null) ?? '24');
|
||||||
|
|
||||||
const children = [];
|
const children = [];
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ export default eventHandler((event) => {
|
|||||||
children.push(
|
children.push(
|
||||||
createElement(Backdrop, {
|
createElement(Backdrop, {
|
||||||
backdropString,
|
backdropString,
|
||||||
src,
|
src: src.replace(/<svg[^>]*>|<\/svg>/g, ''),
|
||||||
color: '#777',
|
color: '#777',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -46,7 +46,18 @@ export default eventHandler((event) => {
|
|||||||
|
|
||||||
const svg = Buffer.from(
|
const svg = Buffer.from(
|
||||||
// We can't use jsx here, is not supported here by nitro.
|
// We can't use jsx here, is not supported here by nitro.
|
||||||
renderToString(createElement(SvgPreview, { src, showGrid: true }, children)),
|
renderToString(
|
||||||
|
createElement(
|
||||||
|
SvgPreview,
|
||||||
|
{
|
||||||
|
src: src.replace(/<svg[^>]*>|<\/svg>/g, ''),
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
showGrid: true,
|
||||||
|
},
|
||||||
|
children,
|
||||||
|
),
|
||||||
|
),
|
||||||
).toString('utf8');
|
).toString('utf8');
|
||||||
|
|
||||||
defaultContentType(event, 'image/svg+xml');
|
defaultContentType(event, 'image/svg+xml');
|
||||||
|
|||||||
137
docs/.vitepress/lib/SvgPreview/GapViolationHighlight.tsx
Normal file
137
docs/.vitepress/lib/SvgPreview/GapViolationHighlight.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import pathToPoints from './path-to-points';
|
||||||
|
import { Path, PathProps } from './types';
|
||||||
|
|
||||||
|
export const GapViolationHighlight = ({
|
||||||
|
radius,
|
||||||
|
stroke,
|
||||||
|
strokeWidth,
|
||||||
|
strokeOpacity,
|
||||||
|
paths,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
paths: Path[];
|
||||||
|
} & PathProps<'stroke' | 'strokeOpacity' | 'strokeWidth', 'd'>) => {
|
||||||
|
const id = React.useId();
|
||||||
|
|
||||||
|
const groupedPaths = Object.entries(
|
||||||
|
paths.reduce(
|
||||||
|
(groups, val) => {
|
||||||
|
const key = val.c.id;
|
||||||
|
groups[key] = [...(groups[key] || []), val];
|
||||||
|
return groups;
|
||||||
|
},
|
||||||
|
{} as Record<number, Path[]>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups: Group[] = [];
|
||||||
|
|
||||||
|
for (const [, paths] of groupedPaths) {
|
||||||
|
const d = paths.map((path) => path.d).join(' ');
|
||||||
|
const points = paths.flatMap((path) => pathToPoints(path));
|
||||||
|
groups.push({ id: d, points });
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedGroups = mergeGroups(groups, 2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g {...props}>
|
||||||
|
<defs xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<pattern
|
||||||
|
id={`backdrop-pattern-${id}`}
|
||||||
|
width=".1"
|
||||||
|
height=".1"
|
||||||
|
patternUnits="userSpaceOnUse"
|
||||||
|
patternTransform="rotate(45 50 50)"
|
||||||
|
>
|
||||||
|
<line
|
||||||
|
stroke={stroke}
|
||||||
|
strokeWidth={0.1}
|
||||||
|
y2={1}
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
stroke={stroke}
|
||||||
|
strokeWidth={0.1}
|
||||||
|
y2={1}
|
||||||
|
/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
{mergedGroups.flatMap((ds, idx, arr) =>
|
||||||
|
arr.slice(0, idx).map((val, i) => (
|
||||||
|
<g
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
key={i}
|
||||||
|
>
|
||||||
|
<mask
|
||||||
|
id={`svg-preview-backdrop-mask-${id}-${i}`}
|
||||||
|
maskUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke="white"
|
||||||
|
d={val.join(' ')}
|
||||||
|
/>
|
||||||
|
</mask>
|
||||||
|
<path
|
||||||
|
d={ds.join(' ')}
|
||||||
|
stroke={`url(#backdrop-pattern-${id})`}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeOpacity={strokeOpacity}
|
||||||
|
mask={`url(#svg-preview-backdrop-mask-${id}-${i})`}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type Point = { x: number; y: number };
|
||||||
|
type Group = { id: string; points: Point[] };
|
||||||
|
|
||||||
|
// Euclidean distance
|
||||||
|
function distance(a: Point, b: Point): number {
|
||||||
|
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if two groups should be merged based on minimum distance
|
||||||
|
function shouldMerge(a: Group, b: Group, minDistance: number): boolean {
|
||||||
|
for (const pa of a.points) {
|
||||||
|
for (const pb of b.points) {
|
||||||
|
if (distance(pa, pb) <= minDistance) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge groups and return arrays of merged group IDs
|
||||||
|
function mergeGroups(groups: Group[], minDistance: number): string[][] {
|
||||||
|
const mergedGroups: Group[][] = groups.map((g) => [g]);
|
||||||
|
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
|
||||||
|
outer: for (let i = 0; i < mergedGroups.length; i++) {
|
||||||
|
for (let j = i + 1; j < mergedGroups.length; j++) {
|
||||||
|
// Check if any group in mergedGroups[i] should merge with any in mergedGroups[j]
|
||||||
|
if (
|
||||||
|
mergedGroups[i].some((ga) =>
|
||||||
|
mergedGroups[j].some((gb) => shouldMerge(ga, gb, minDistance)),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// Merge group j into group i
|
||||||
|
mergedGroups[i] = [...mergedGroups[i], ...mergedGroups[j]];
|
||||||
|
mergedGroups.splice(j, 1);
|
||||||
|
changed = true;
|
||||||
|
break outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return only arrays of IDs
|
||||||
|
return mergedGroups.map((groupList) => groupList.map((g) => g.id));
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { PathProps, Path } from './types';
|
import { PathProps, Path } from './types';
|
||||||
import { getPaths, assert } from './utils';
|
import getPaths, { assert } from './utils';
|
||||||
|
import { GapViolationHighlight } from './GapViolationHighlight.tsx';
|
||||||
|
|
||||||
export const darkModeCss = `
|
export const darkModeCss = `
|
||||||
@media screen and (prefers-color-scheme: light) {
|
@media screen and (prefers-color-scheme: light) {
|
||||||
@@ -20,10 +21,16 @@ export const darkModeCss = `
|
|||||||
|
|
||||||
export const Grid = ({
|
export const Grid = ({
|
||||||
radius,
|
radius,
|
||||||
fill = '#fff',
|
fill,
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
subGridSize = 0,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
|
height: number;
|
||||||
|
width: number;
|
||||||
strokeWidth: number;
|
strokeWidth: number;
|
||||||
|
subGridSize?: number;
|
||||||
radius: number;
|
radius: number;
|
||||||
} & PathProps<'stroke', 'strokeWidth'>) => (
|
} & PathProps<'stroke', 'strokeWidth'>) => (
|
||||||
<g
|
<g
|
||||||
@@ -33,43 +40,53 @@ export const Grid = ({
|
|||||||
>
|
>
|
||||||
<rect
|
<rect
|
||||||
className="svg-preview-grid-rect"
|
className="svg-preview-grid-rect"
|
||||||
width={24 - props.strokeWidth}
|
width={width - props.strokeWidth}
|
||||||
height={24 - props.strokeWidth}
|
height={height - props.strokeWidth}
|
||||||
x={props.strokeWidth / 2}
|
x={props.strokeWidth / 2}
|
||||||
y={props.strokeWidth / 2}
|
y={props.strokeWidth / 2}
|
||||||
rx={radius}
|
rx={radius}
|
||||||
fill={fill}
|
fill={fill}
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
strokeDasharray={'0 0.1 ' + '0.1 0.15 '.repeat(11) + '0 0.15'}
|
strokeDasharray={
|
||||||
|
'0 0.1 ' + '0.1 0.15 '.repeat(subGridSize ? subGridSize * 4 - 1 : 95) + '0 0.15'
|
||||||
|
}
|
||||||
strokeWidth={0.1}
|
strokeWidth={0.1}
|
||||||
d={
|
d={
|
||||||
props.d ||
|
props.d ||
|
||||||
new Array(Math.floor(24 - 1))
|
[
|
||||||
.fill(null)
|
...new Array(Math.floor(width - 1))
|
||||||
.map((_, i) => i)
|
.fill(null)
|
||||||
.filter((i) => i % 3 !== 2)
|
.map((_, i) => i)
|
||||||
.flatMap((i) => [
|
.filter((i) => !subGridSize || i % subGridSize !== subGridSize - 1)
|
||||||
`M${props.strokeWidth} ${i + 1}h${24 - props.strokeWidth * 2}`,
|
.flatMap((i) => [`M${i + 1} ${props.strokeWidth}v${height - props.strokeWidth * 2}`]),
|
||||||
`M${i + 1} ${props.strokeWidth}v${24 - props.strokeWidth * 2}`,
|
...new Array(Math.floor(height - 1))
|
||||||
])
|
.fill(null)
|
||||||
.join('')
|
.map((_, i) => i)
|
||||||
}
|
.filter((i) => !subGridSize || i % subGridSize !== subGridSize - 1)
|
||||||
/>
|
.flatMap((i) => [`M${props.strokeWidth} ${i + 1}h${width - props.strokeWidth * 2}`]),
|
||||||
<path
|
].join('')
|
||||||
d={
|
|
||||||
props.d ||
|
|
||||||
new Array(Math.floor(24 - 1))
|
|
||||||
.fill(null)
|
|
||||||
.map((_, i) => i)
|
|
||||||
.filter((i) => i % 3 === 2)
|
|
||||||
.flatMap((i) => [
|
|
||||||
`M${props.strokeWidth} ${i + 1}h${24 - props.strokeWidth * 2}`,
|
|
||||||
`M${i + 1} ${props.strokeWidth}v${24 - props.strokeWidth * 2}`,
|
|
||||||
])
|
|
||||||
.join('')
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{!!subGridSize && (
|
||||||
|
<path
|
||||||
|
d={
|
||||||
|
props.d ||
|
||||||
|
[
|
||||||
|
...new Array(Math.floor(width - 1))
|
||||||
|
.fill(null)
|
||||||
|
.map((_, i) => i)
|
||||||
|
.filter((i) => i % subGridSize === subGridSize - 1)
|
||||||
|
.flatMap((i) => [`M${i + 1} ${props.strokeWidth}v${height - props.strokeWidth * 2}`]),
|
||||||
|
...new Array(Math.floor(height - 1))
|
||||||
|
.fill(null)
|
||||||
|
.map((_, i) => i)
|
||||||
|
.filter((i) => i % subGridSize === subGridSize - 1)
|
||||||
|
.flatMap((i) => [`M${props.strokeWidth} ${i + 1}h${width - props.strokeWidth * 2}`]),
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -99,6 +116,7 @@ const Shadow = ({
|
|||||||
>
|
>
|
||||||
{groupedPaths.map(([id, paths]) => (
|
{groupedPaths.map(([id, paths]) => (
|
||||||
<mask
|
<mask
|
||||||
|
key={`svg-preview-shadow-mask-${id}`}
|
||||||
id={`svg-preview-shadow-mask-${id}`}
|
id={`svg-preview-shadow-mask-${id}`}
|
||||||
maskUnits="userSpaceOnUse"
|
maskUnits="userSpaceOnUse"
|
||||||
strokeOpacity="1"
|
strokeOpacity="1"
|
||||||
@@ -108,8 +126,8 @@ const Shadow = ({
|
|||||||
<rect
|
<rect
|
||||||
x={0}
|
x={0}
|
||||||
y={0}
|
y={0}
|
||||||
width={24}
|
width="100%"
|
||||||
height={24}
|
height="100%"
|
||||||
fill="#fff"
|
fill="#fff"
|
||||||
stroke="none"
|
stroke="none"
|
||||||
rx={radius}
|
rx={radius}
|
||||||
@@ -152,30 +170,34 @@ const ColoredPath = ({
|
|||||||
colors,
|
colors,
|
||||||
paths,
|
paths,
|
||||||
...props
|
...props
|
||||||
}: { paths: Path[]; colors: string[] } & PathProps<never, 'd' | 'stroke'>) => (
|
}: { paths: Path[]; colors: string[] } & PathProps<never, 'd' | 'stroke'>) => {
|
||||||
<g
|
let idx = 0;
|
||||||
className="svg-preview-colored-path-group"
|
return (
|
||||||
{...props}
|
<g
|
||||||
>
|
className="svg-preview-colored-path-group"
|
||||||
{paths.map(({ d, c }, i) => (
|
{...props}
|
||||||
<path
|
>
|
||||||
key={i}
|
{paths.map(({ d, c }, i) => (
|
||||||
d={d}
|
<path
|
||||||
stroke={colors[(c.name === 'path' ? i : c.id) % colors.length]}
|
key={i}
|
||||||
/>
|
d={d}
|
||||||
))}
|
stroke={colors[(c.name === 'path' ? idx++ : c.id) % colors.length]}
|
||||||
</g>
|
/>
|
||||||
);
|
))}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const ControlPath = ({
|
const ControlPath = ({
|
||||||
paths,
|
paths,
|
||||||
radius,
|
radius,
|
||||||
pointSize,
|
pointSize,
|
||||||
...props
|
...props
|
||||||
}: { pointSize: number; paths: Path[]; radius: number } & PathProps<
|
}: {
|
||||||
'stroke' | 'strokeWidth',
|
pointSize: number;
|
||||||
'd'
|
paths: Path[];
|
||||||
>) => {
|
radius: number;
|
||||||
|
} & PathProps<'stroke' | 'strokeWidth', 'd'>) => {
|
||||||
const controlPaths = paths.map((path, i) => {
|
const controlPaths = paths.map((path, i) => {
|
||||||
const element = paths.filter((p) => p.c.id === path.c.id);
|
const element = paths.filter((p) => p.c.id === path.c.id);
|
||||||
const lastElement = element.at(-1)?.next;
|
const lastElement = element.at(-1)?.next;
|
||||||
@@ -207,8 +229,8 @@ const ControlPath = ({
|
|||||||
<rect
|
<rect
|
||||||
x="0"
|
x="0"
|
||||||
y="0"
|
y="0"
|
||||||
width="24"
|
width="100%"
|
||||||
height="24"
|
height="100%"
|
||||||
fill="#fff"
|
fill="#fff"
|
||||||
stroke="none"
|
stroke="none"
|
||||||
rx={radius}
|
rx={radius}
|
||||||
@@ -243,7 +265,7 @@ const ControlPath = ({
|
|||||||
)
|
)
|
||||||
.join('')}
|
.join('')}
|
||||||
/>
|
/>
|
||||||
{controlPaths.map(({ d, prev, next, startMarker, endMarker }, i) => (
|
{controlPaths.map(({ prev, next, startMarker, endMarker }, i) => (
|
||||||
<React.Fragment key={i}>
|
<React.Fragment key={i}>
|
||||||
{startMarker && (
|
{startMarker && (
|
||||||
<circle
|
<circle
|
||||||
@@ -279,11 +301,37 @@ const Radii = ({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{paths.map(
|
{paths.map(
|
||||||
({ c, prev, next, circle }, i) =>
|
({ circle, next, prev, c }, i) =>
|
||||||
circle && (
|
circle && (
|
||||||
<React.Fragment key={i}>
|
<React.Fragment key={i}>
|
||||||
{c.name !== 'circle' && (
|
{circle.tangentIntersection && c.name === 'path' && (
|
||||||
<path d={`M${prev.x} ${prev.y} ${circle.x} ${circle.y} ${next.x} ${next.y}`} />
|
<>
|
||||||
|
<circle
|
||||||
|
cx={next.x * 2 - circle.tangentIntersection.x}
|
||||||
|
cy={next.y * 2 - circle.tangentIntersection.y}
|
||||||
|
r={0.25}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={prev.x * 2 - circle.tangentIntersection.x}
|
||||||
|
cy={prev.y * 2 - circle.tangentIntersection.y}
|
||||||
|
r={0.25}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d={`M${next.x * 2 - circle.tangentIntersection.x} ${
|
||||||
|
next.y * 2 - circle.tangentIntersection.y
|
||||||
|
}L${circle.tangentIntersection.x} ${circle.tangentIntersection.y}L${prev.x * 2 - circle.tangentIntersection.x} ${
|
||||||
|
prev.y * 2 - circle.tangentIntersection.y
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={circle.tangentIntersection.x}
|
||||||
|
cy={circle.tangentIntersection.y}
|
||||||
|
r={0.25}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{c.name === 'path' && (
|
||||||
|
<path d={`M${next.x} ${next.y}L${circle.x} ${circle.y}L${prev.x} ${prev.y}`} />
|
||||||
)}
|
)}
|
||||||
<circle
|
<circle
|
||||||
cy={circle.y}
|
cy={circle.y}
|
||||||
@@ -313,55 +361,60 @@ const Radii = ({
|
|||||||
const Handles = ({
|
const Handles = ({
|
||||||
paths,
|
paths,
|
||||||
...props
|
...props
|
||||||
}: { paths: Path[] } & PathProps<
|
}: { paths: Path[] } & PathProps<'strokeWidth' | 'stroke' | 'strokeOpacity', any>) => (
|
||||||
'strokeWidth' | 'stroke' | 'strokeDasharray' | 'strokeOpacity',
|
<g
|
||||||
any
|
className="svg-preview-handles-group"
|
||||||
>) => {
|
{...props}
|
||||||
return (
|
>
|
||||||
<g
|
{paths.map(({ c, prev, next, cp1, cp2 }, i) => (
|
||||||
className="svg-preview-handles-group"
|
<React.Fragment key={i}>
|
||||||
{...props}
|
{cp1 && <path d={`M${prev.x} ${prev.y} ${cp1.x} ${cp1.y}`} />}
|
||||||
>
|
{cp1 && (
|
||||||
{paths.map(({ c, prev, next, cp1, cp2 }) => (
|
<circle
|
||||||
<>
|
cy={cp1.y}
|
||||||
{cp1 && <path d={`M${prev.x} ${prev.y} ${cp1.x} ${cp1.y}`} />}
|
cx={cp1.x}
|
||||||
{cp1 && (
|
r={0.25}
|
||||||
<circle
|
/>
|
||||||
cy={cp1.y}
|
)}
|
||||||
cx={cp1.x}
|
{cp2 && <path d={`M${next.x} ${next.y} ${cp2.x} ${cp2.y}`} />}
|
||||||
r={0.25}
|
{cp2 && (
|
||||||
/>
|
<circle
|
||||||
)}
|
cy={cp2.y}
|
||||||
{cp2 && <path d={`M${next.x} ${next.y} ${cp2.x} ${cp2.y}`} />}
|
cx={cp2.x}
|
||||||
{cp2 && (
|
r={0.25}
|
||||||
<circle
|
/>
|
||||||
cy={cp2.y}
|
)}
|
||||||
cx={cp2.x}
|
</React.Fragment>
|
||||||
r={0.25}
|
))}
|
||||||
/>
|
</g>
|
||||||
)}
|
);
|
||||||
</>
|
|
||||||
))}
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SvgPreview = React.forwardRef<
|
const SvgPreview = React.forwardRef<
|
||||||
SVGSVGElement,
|
SVGSVGElement,
|
||||||
{
|
{
|
||||||
|
height?: number;
|
||||||
|
width?: number;
|
||||||
src: string | ReturnType<typeof getPaths>;
|
src: string | ReturnType<typeof getPaths>;
|
||||||
showGrid?: boolean;
|
showGrid?: boolean;
|
||||||
} & React.SVGProps<SVGSVGElement>
|
} & React.SVGProps<SVGSVGElement>
|
||||||
>(({ src, children, showGrid = false, ...props }, ref) => {
|
>(({ src, children, height = 24, width = 24, showGrid = false, ...props }, ref) => {
|
||||||
|
const subGridSize =
|
||||||
|
Math.max(height, width) % 3 === 0
|
||||||
|
? Math.max(height, width) > 24
|
||||||
|
? 12
|
||||||
|
: 3
|
||||||
|
: Math.max(height, width) % 5 === 0
|
||||||
|
? 5
|
||||||
|
: 0;
|
||||||
const paths = typeof src === 'string' ? getPaths(src) : src;
|
const paths = typeof src === 'string' ? getPaths(src) : src;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
ref={ref}
|
ref={ref}
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width={24}
|
width={width}
|
||||||
height={24}
|
height={height}
|
||||||
viewBox="0 0 24 24"
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
@@ -372,8 +425,12 @@ const SvgPreview = React.forwardRef<
|
|||||||
<style>{darkModeCss}</style>
|
<style>{darkModeCss}</style>
|
||||||
{showGrid && (
|
{showGrid && (
|
||||||
<Grid
|
<Grid
|
||||||
|
height={height}
|
||||||
|
width={width}
|
||||||
|
subGridSize={subGridSize}
|
||||||
strokeWidth={0.1}
|
strokeWidth={0.1}
|
||||||
stroke="#777"
|
stroke="#777"
|
||||||
|
mask="url(#svg-preview-bounding-box-mask)"
|
||||||
strokeOpacity={0.3}
|
strokeOpacity={0.3}
|
||||||
radius={1}
|
radius={1}
|
||||||
/>
|
/>
|
||||||
@@ -385,6 +442,12 @@ const SvgPreview = React.forwardRef<
|
|||||||
radius={1}
|
radius={1}
|
||||||
strokeOpacity={0.15}
|
strokeOpacity={0.15}
|
||||||
/>
|
/>
|
||||||
|
<GapViolationHighlight
|
||||||
|
paths={paths}
|
||||||
|
stroke="red"
|
||||||
|
strokeOpacity={0.75}
|
||||||
|
strokeWidth={4}
|
||||||
|
/>
|
||||||
<Handles
|
<Handles
|
||||||
paths={paths}
|
paths={paths}
|
||||||
strokeWidth={0.12}
|
strokeWidth={0.12}
|
||||||
@@ -433,4 +496,6 @@ const SvgPreview = React.forwardRef<
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
SvgPreview.displayName = 'SvgPreview';
|
||||||
|
|
||||||
export default SvgPreview;
|
export default SvgPreview;
|
||||||
|
|||||||
19
docs/.vitepress/lib/SvgPreview/path-to-points.ts
Normal file
19
docs/.vitepress/lib/SvgPreview/path-to-points.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import memoize from 'lodash/memoize';
|
||||||
|
import SVGPathCommander from 'svg-path-commander';
|
||||||
|
import { Path } from './types';
|
||||||
|
|
||||||
|
function pathToPoints({ d, prev, next }: Path, interval = 1) {
|
||||||
|
const commander = new SVGPathCommander(d);
|
||||||
|
const points = [];
|
||||||
|
try {
|
||||||
|
const totalLength = commander.getTotalLength();
|
||||||
|
points.push(prev);
|
||||||
|
for (let i = interval; i < totalLength - interval; i += interval) {
|
||||||
|
points.push(commander.getPointAtLength(i));
|
||||||
|
}
|
||||||
|
points.push(next);
|
||||||
|
} catch (err) {}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memoize(pathToPoints);
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { INode, parseSync } from 'svgson';
|
import { INode, parseSync } from 'svgson';
|
||||||
|
// @ts-ignore
|
||||||
import toPath from 'element-to-path';
|
import toPath from 'element-to-path';
|
||||||
|
// @ts-ignore
|
||||||
import { SVGPathData, encodeSVGPath } from 'svg-pathdata';
|
import { SVGPathData, encodeSVGPath } from 'svg-pathdata';
|
||||||
import { Path, Point } from './types';
|
import { Path, Point } from './types';
|
||||||
|
import memoize from 'lodash/memoize';
|
||||||
|
|
||||||
function assertNever(x: never): never {
|
function assertNever(x: never): never {
|
||||||
throw new Error('Unknown type: ' + x['type']);
|
throw new Error('Unknown type: ' + x['type']);
|
||||||
@@ -44,17 +47,21 @@ const extractNodes = (node: INode): INode[] => {
|
|||||||
return [];
|
return [];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getNodes = (src: string) =>
|
export const getNodes = memoize((src: string) =>
|
||||||
extractNodes(parseSync(src.includes('<svg') ? src : `<svg>${src}</svg>`));
|
extractNodes(parseSync(src.includes('<svg') ? src : `<svg>${src}</svg>`)),
|
||||||
|
);
|
||||||
|
|
||||||
export const getCommands = (src: string) =>
|
export const getCommands = (src: string) =>
|
||||||
getNodes(src)
|
getNodes(src)
|
||||||
.map(convertToPathNode)
|
.map(convertToPathNode)
|
||||||
.flatMap(({ d, name }, idx) =>
|
.flatMap(({ d, name }, idx) =>
|
||||||
new SVGPathData(d).toAbs().commands.map((c, cIdx) => ({ ...c, id: idx, idx: cIdx, name })),
|
new SVGPathData(d)
|
||||||
|
.toAbs()
|
||||||
|
// @ts-ignore
|
||||||
|
.commands.map((c, cIdx) => ({ ...c, id: idx, idx: cIdx, name })),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getPaths = (src: string) => {
|
const getPaths = (src: string) => {
|
||||||
const commands = getCommands(src.includes('<svg') ? src : `<svg>${src}</svg>`);
|
const commands = getCommands(src.includes('<svg') ? src : `<svg>${src}</svg>`);
|
||||||
const paths: Path[] = [];
|
const paths: Path[] = [];
|
||||||
let prev: Point | undefined = undefined;
|
let prev: Point | undefined = undefined;
|
||||||
@@ -237,6 +244,7 @@ export const getPaths = (src: string) => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
|
// @ts-ignore
|
||||||
assertNever(c);
|
assertNever(c);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,7 +252,7 @@ export const getPaths = (src: string) => {
|
|||||||
return paths;
|
return paths;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const arcEllipseCenter = (
|
const arcEllipseCenter = (
|
||||||
x1: number,
|
x1: number,
|
||||||
y1: number,
|
y1: number,
|
||||||
rx: number,
|
rx: number,
|
||||||
@@ -296,5 +304,52 @@ export const arcEllipseCenter = (
|
|||||||
M2[1][0] * Cp[0] + M2[1][1] * Cp[1] + V3[1],
|
M2[1][0] * Cp[0] + M2[1][1] * Cp[1] + V3[1],
|
||||||
];
|
];
|
||||||
|
|
||||||
return { x: C[0], y: C[1] };
|
return {
|
||||||
|
x: C[0],
|
||||||
|
y: C[1],
|
||||||
|
tangentIntersection: intersectTangents(
|
||||||
|
{ x: x1, y: y1 },
|
||||||
|
{ x: x2, y: y2 },
|
||||||
|
{ x: C[0], y: C[1] },
|
||||||
|
),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getTangentDirection(p: Point, center: Point): Point {
|
||||||
|
// Tangent is perpendicular to the radius vector (rotate radius 90°)
|
||||||
|
const dx = p.x - center.x;
|
||||||
|
const dy = p.y - center.y;
|
||||||
|
return { x: -dy, y: dx }; // 90° rotation
|
||||||
|
}
|
||||||
|
|
||||||
|
function intersectTangents(start: Point, end: Point, center: Point): Point | null {
|
||||||
|
const t1 = getTangentDirection(start, center);
|
||||||
|
const t2 = getTangentDirection(end, center);
|
||||||
|
|
||||||
|
// Solve: start + λ * t1 = end + μ * t2
|
||||||
|
const A = [
|
||||||
|
[t1.x, -t2.x],
|
||||||
|
[t1.y, -t2.y],
|
||||||
|
];
|
||||||
|
const b = [end.x - start.x, end.y - start.y];
|
||||||
|
|
||||||
|
// Compute determinant
|
||||||
|
const det = A[0][0] * A[1][1] - A[0][1] * A[1][0];
|
||||||
|
|
||||||
|
if (Math.abs(det) < 1e-10) {
|
||||||
|
// Lines are parallel, no intersection
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invDet = 1 / det;
|
||||||
|
|
||||||
|
const lambda = (b[0] * A[1][1] - b[1] * A[0][1]) * invDet;
|
||||||
|
|
||||||
|
// Intersection point = start + lambda * t1
|
||||||
|
return {
|
||||||
|
x: start.x + lambda * t1.x,
|
||||||
|
y: start.y + lambda * t1.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memoize(getPaths);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"h3": "^1.8.0",
|
"h3": "^1.8.0",
|
||||||
"nitropack": "2.8.1",
|
"nitropack": "2.8.1",
|
||||||
"rollup-plugin-copy": "^3.4.0",
|
"rollup-plugin-copy": "^3.4.0",
|
||||||
|
"svg-path-commander": "^2.1.11",
|
||||||
"vitepress": "^1.3.1"
|
"vitepress": "^1.3.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
@@ -200,6 +200,9 @@ importers:
|
|||||||
rollup-plugin-copy:
|
rollup-plugin-copy:
|
||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
version: 3.5.0
|
version: 3.5.0
|
||||||
|
svg-path-commander:
|
||||||
|
specifier: ^2.1.11
|
||||||
|
version: 2.1.11
|
||||||
vitepress:
|
vitepress:
|
||||||
specifier: ^1.3.1
|
specifier: ^1.3.1
|
||||||
version: 1.6.3(@algolia/client-search@5.25.0)(@types/node@22.15.30)(@types/react@18.3.21)(axios@1.7.4)(fuse.js@6.6.2)(less@4.2.0)(postcss@8.5.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8)(search-insights@2.8.2)(stylus@0.56.0)(terser@5.39.2)(typescript@5.8.3)
|
version: 1.6.3(@algolia/client-search@5.25.0)(@types/node@22.15.30)(@types/react@18.3.21)(axios@1.7.4)(fuse.js@6.6.2)(less@4.2.0)(postcss@8.5.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8)(search-insights@2.8.2)(stylus@0.56.0)(terser@5.39.2)(typescript@5.8.3)
|
||||||
@@ -5152,6 +5155,10 @@ packages:
|
|||||||
'@vue/compiler-sfc':
|
'@vue/compiler-sfc':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@thednp/dommatrix@2.0.12':
|
||||||
|
resolution: {integrity: sha512-eOshhlSShBXLfrMQqqhA450TppJXhKriaQdN43mmniOCMn9sD60QKF1Axsj7bKl339WH058LuGFS6H84njYH5w==}
|
||||||
|
engines: {node: '>=20', pnpm: '>=8.6.0'}
|
||||||
|
|
||||||
'@tokenizer/token@0.3.0':
|
'@tokenizer/token@0.3.0':
|
||||||
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
|
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
|
||||||
|
|
||||||
@@ -12213,6 +12220,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-QIYtKnJGkubWXtNkrUBKVCvyo9gjcccdbnvXfwsGNhvbeNNdQjRDTa/BiQcJ2kWXbXPQbWKyT7CUu53KIj1rfw==}
|
resolution: {integrity: sha512-QIYtKnJGkubWXtNkrUBKVCvyo9gjcccdbnvXfwsGNhvbeNNdQjRDTa/BiQcJ2kWXbXPQbWKyT7CUu53KIj1rfw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
svg-path-commander@2.1.11:
|
||||||
|
resolution: {integrity: sha512-wmQ6QA3Od+HOcpIzLjPlbv59+x3yd3V5W6xitUOvAHmqZpP7wVrRM2CHqEm5viHUbZu6PjzFsjbTEFtIeUxaNA==}
|
||||||
|
engines: {node: '>=16', pnpm: '>=8.6.0'}
|
||||||
|
|
||||||
svg-pathdata@6.0.3:
|
svg-pathdata@6.0.3:
|
||||||
resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==}
|
resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -18275,6 +18286,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@vue/compiler-sfc': 3.5.14
|
'@vue/compiler-sfc': 3.5.14
|
||||||
|
|
||||||
|
'@thednp/dommatrix@2.0.12': {}
|
||||||
|
|
||||||
'@tokenizer/token@0.3.0': {}
|
'@tokenizer/token@0.3.0': {}
|
||||||
|
|
||||||
'@tootallnate/once@1.1.2': {}
|
'@tootallnate/once@1.1.2': {}
|
||||||
@@ -27273,6 +27286,10 @@ snapshots:
|
|||||||
magic-string: 0.30.17
|
magic-string: 0.30.17
|
||||||
zimmerframe: 1.1.2
|
zimmerframe: 1.1.2
|
||||||
|
|
||||||
|
svg-path-commander@2.1.11:
|
||||||
|
dependencies:
|
||||||
|
'@thednp/dommatrix': 2.0.12
|
||||||
|
|
||||||
svg-pathdata@6.0.3: {}
|
svg-pathdata@6.0.3: {}
|
||||||
|
|
||||||
svg-pathdata@7.2.0: {}
|
svg-pathdata@7.2.0: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user