Files
phosphor-icons/src/components/IconGrid/Panel.tsx
2024-03-29 03:20:30 -06:00

471 lines
13 KiB
TypeScript

import React, {
useRef,
useState,
useEffect,
useMemo,
HTMLAttributes,
} from "react";
import { useRecoilValue, useRecoilState } from "recoil";
import { useHotkeys } from "react-hotkeys-hook";
import { motion, AnimatePresence, Variants } from "framer-motion";
import { Svg2Png } from "svg2png-converter";
import { saveAs } from "file-saver";
import {
Copy,
CheckCircle,
ArrowFatLinesDown,
XCircle,
CaretDoubleLeft,
CaretDoubleRight,
} from "@phosphor-icons/react";
import { IconStyle } from "@phosphor-icons/core";
import ReactGA from "react-ga4";
import Tabs, { Tab } from "@/components/Tabs";
import { useMediaQuery, useTransientState, useSessionStorage } from "@/hooks";
import { SnippetType } from "@/lib";
import {
iconWeightAtom,
iconSizeAtom,
iconColorAtom,
selectionEntryAtom,
isDarkThemeSelector,
} from "@/state";
import { getCodeSnippets, supportsWeight } from "@/utils";
import TagCloud from "./TagCloud";
const variants: Record<string, Variants> = {
desktop: {
initial: { y: 188 },
animate: { y: 0 },
exit: { y: 188 },
},
mobile: {
initial: { y: "60vh" },
animate: { y: 0 },
exit: { y: "60vh" },
},
};
const RENDERED_SNIPPETS = [
SnippetType.REACT,
SnippetType.HTML,
SnippetType.VUE,
SnippetType.FLUTTER,
SnippetType.ELM,
SnippetType.SWIFT,
];
enum CopyType {
SVG,
SVG_RAW,
SVG_DATA,
PNG,
PNG_DATA,
UNICODE,
}
function cloneWithSize(svg: SVGSVGElement, size: number): SVGSVGElement {
const sized = svg.cloneNode(true) as SVGSVGElement;
sized.setAttribute("width", `${size}`);
sized.setAttribute("height", `${size}`);
return sized;
}
const ActionButton = (
props: {
active?: boolean;
label: string;
download?: boolean;
disabled?: boolean;
} & HTMLAttributes<HTMLButtonElement>
) => {
const { active, download, label, ...rest } = props;
const Icon = download ? ArrowFatLinesDown : Copy;
return (
<button
{...rest}
className={`action-button text ${props.disabled ? "disabled" : ""}`}
aria-disabled={props.disabled}
tabIndex={0}
>
{active ? (
<CheckCircle size={20} color="var(--olive)" weight="fill" />
) : (
<Icon size={20} color="currentColor" weight="fill" />
)}
{label}
</button>
);
};
const Panel = () => {
const [entry, setSelectionEntry] = useRecoilState(selectionEntryAtom);
const weight = useRecoilValue(iconWeightAtom);
const size = useRecoilValue(iconSizeAtom);
const color = useRecoilValue(iconColorAtom);
const isDark = useRecoilValue(isDarkThemeSelector);
const [copied, setCopied] = useTransientState<SnippetType | CopyType | false>(
false,
2000
);
const ref = useRef<SVGSVGElement>(null);
const [showMoreActions, setShowMoreActions] = useState<boolean>(false);
const [i, setInitialTab] = useSessionStorage("tab", 0);
const isMobile = useMediaQuery("(max-width: 719px)");
const [snippets, tabs] = useMemo<
[Partial<Record<SnippetType, string>>, Tab[]]
>(() => {
if (!entry) return [{}, []];
const snippets = getCodeSnippets({
displayName: entry?.pascal_name!,
name: entry.name,
weight,
size,
color,
});
const tabs = [
{
header: "Tags",
content: (
<TagCloud
name={entry.name}
tags={Array.from(
new Set<string>([
...entry.tags,
...entry.categories,
...entry.name.split("-"),
])
)}
/>
),
},
].concat(
RENDERED_SNIPPETS.map((type) => {
const isWeightSupported = supportsWeight({ type, weight });
return {
header: type,
content: (
<div className="snippet" key={type}>
<pre className={!isWeightSupported ? "disabled" : undefined}>
<span className={!isWeightSupported ? "disabled" : undefined}>
{isWeightSupported
? snippets[type]
: "This weight is not yet supported"}
</span>
{isWeightSupported && (
<button
title="Copy snippet"
className="action-button"
onClick={(e) => handleCopySnippet(e, type)}
>
{copied === type ? (
<CheckCircle
size={20}
color="var(--olive)"
weight="fill"
/>
) : (
<Copy size={20} color="var(--foreground)" weight="fill" />
)}
</button>
)}
</pre>
</div>
),
};
})
);
return [snippets, tabs];
}, [entry, weight, size, color, copied, isDark]);
useHotkeys("esc", () => setSelectionEntry(null));
useEffect(() => {
if (!entry) return;
ReactGA.event({
category: "Grid",
action: "Details",
label: entry.name,
});
}, [entry]);
const handleCopySnippet = (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>,
type: SnippetType
) => {
event.currentTarget.blur();
if (!entry) return;
setCopied(type);
const data = snippets[type];
data && void navigator.clipboard?.writeText(data);
};
const handleCopySVG = (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
event.currentTarget.blur();
if (!entry) return;
if (!ref.current) return;
navigator.clipboard?.writeText(cloneWithSize(ref.current, size).outerHTML);
setCopied(CopyType.SVG);
};
const handleCopyDataSVG = (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
event.currentTarget.blur();
if (!entry) return;
if (!ref.current) return;
navigator.clipboard?.writeText(
"data:image/svg+xml;base64," +
btoa(
unescape(
encodeURIComponent(cloneWithSize(ref.current, size).outerHTML)
)
)
);
setCopied(CopyType.SVG_DATA);
};
const handleCopyRawSVG = async () => {
if (!entry) return;
const { name } = entry;
const data = await fetch(
`https://raw.githubusercontent.com/phosphor-icons/core/main/raw/${weight}/${name}${
weight === "regular" ? "" : `-${weight}`
}.svg`
);
const content = await data.text();
navigator.clipboard?.writeText(content);
setCopied(CopyType.SVG_RAW);
};
const handleCopyUnicode = async () => {
if (!entry) return;
const content = String.fromCharCode(entry.codepoint);
navigator.clipboard?.writeText(content);
setCopied(CopyType.UNICODE);
};
const handleDownloadSVG = (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
event.currentTarget.blur();
if (!entry) return;
if (!ref.current) return;
const blob = new Blob([cloneWithSize(ref.current, size).outerHTML]);
saveAs(
blob,
`${entry?.name}${weight === "regular" ? "" : `-${weight}`}.svg`
);
};
const handleDownloadRawSVG = async () => {
if (!entry) return;
const { name } = entry;
saveAs(
`https://raw.githubusercontent.com/phosphor-icons/core/main/raw/${weight}/${name}${
weight === "regular" ? "" : `-${weight}`
}.svg`,
`${entry?.name}${weight === "regular" ? "" : `-${weight}`}.svg`
);
};
const handleDownloadPNG = async (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
event.currentTarget.blur();
if (!entry) return;
if (!ref.current) return;
Svg2Png.save(
cloneWithSize(ref.current, size),
`${entry?.name}${weight === "regular" ? "" : `-${weight}`}.png`
);
};
const handleCopyPNG = async (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
event.currentTarget.blur();
if (!entry) return;
if (!ref.current) return;
Svg2Png.toDataURL(cloneWithSize(ref.current, size))
.then((data) => fetch(data))
.then((res) => res.blob())
.then((blob) =>
navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob,
}),
])
)
.then(() => {
setCopied(CopyType.PNG);
});
};
// const handleCopyDataPNG = async (
// event: React.MouseEvent<HTMLButtonElement, MouseEvent>
// ) => {
// event.currentTarget.blur();
// if (!entry) return;
// if (!ref.current) return;
// const data = await Svg2Png.toDataURL(cloneWithSize(ref.current, size));
// navigator.clipboard?.writeText(data);
// setCopied(CopyType.PNG_DATA);
// };
return (
<AnimatePresence initial={true}>
{!!entry && (
<motion.aside
initial="initial"
animate="animate"
exit="exit"
variants={isMobile ? variants.mobile : variants.desktop}
className="secondary detail-footer card"
transition={isMobile ? { duration: 0.25 } : { duration: 0.1 }}
>
<div className="detail-preview">
<figure>
<entry.Icon ref={ref} size={64}></entry.Icon>
<figcaption>
<p>{entry.name}</p>
<small className="versioning">
U+{entry.codepoint.toString(16).toUpperCase()}
</small>
<small className="versioning">
available in v{entry.published_in.toFixed(1)}+
</small>
</figcaption>
</figure>
<hr />
<div className="detail-meta">
<div className="detail-actions">
{!showMoreActions ? (
<>
<ActionButton
label="SVG"
title="Download SVG"
download
onClick={handleDownloadSVG}
/>
<ActionButton
label="SVG Raw"
title="Download raw SVG including original strokes"
download
onClick={handleDownloadRawSVG}
/>
<ActionButton
label="SVG"
title="Copy SVG"
active={copied === CopyType.SVG}
onClick={handleCopySVG}
/>
<ActionButton
label="SVG Raw"
title="Copy raw SVG including original strokes"
active={copied === CopyType.SVG_RAW}
onClick={handleCopyRawSVG}
/>
</>
) : (
<>
<ActionButton
label="PNG"
title="Download PNG"
download
onClick={handleDownloadPNG}
/>
<ActionButton
label="PNG"
title="Copy PNG"
active={copied === CopyType.PNG}
onClick={handleCopyPNG}
/>
<ActionButton
label="Data SVG"
title="Copy SVG as DataURL"
active={copied === CopyType.SVG_DATA}
onClick={handleCopyDataSVG}
/>
<ActionButton
label="Unicode"
title="Copy Unicode character (v2.1.0 or newer)"
active={copied === CopyType.UNICODE}
disabled={weight === IconStyle.DUOTONE}
onClick={handleCopyUnicode}
/>
</>
)}
</div>
<button
className="action-button"
title="More actions"
tabIndex={0}
onClick={() => setShowMoreActions((s) => !s)}
>
{!showMoreActions ? (
<CaretDoubleRight
size={16}
weight="bold"
color="var(--foreground)"
/>
) : (
<CaretDoubleLeft
size={16}
weight="bold"
color="var(--foreground)"
/>
)}
</button>
</div>
</div>
<Tabs tabs={tabs} initialIndex={i} onTabChange={setInitialTab} />
<button
tabIndex={0}
className="close-button"
onClick={() => setSelectionEntry(null)}
onKeyDown={(e) => {
e.key === "Enter" && setSelectionEntry(null);
}}
>
<XCircle color="currentColor" size={28} weight="fill" />
</button>
</motion.aside>
)}
</AnimatePresence>
);
};
export default Panel;