IconViewer: refactor (#582)

* IconViewer: refactor

* t
This commit is contained in:
prism 2025-12-19 23:01:17 +01:00 • committed by GitHub
parent bc769692dc
commit 6ad57d76c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 682 additions and 787 deletions

View file

@ -1,112 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { CodeBlock } from "@components/CodeBlock";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import {
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalProps,
ModalRoot,
ModalSize,
openModal
} from "@utils/modal";
import * as t from "@vencord/discord-types";
import { Button, FluxDispatcher, TooltipContainer, useCallback, useEffect, useState } from "@webpack/common";
import { IconsFinds } from "./names";
import { openRawModal } from "./rawModal";
import { openSaveModal } from "./saveModal";
import { ModalHeaderTitle } from "./subComponents";
import { _cssColors, cssColors, iconSizes } from "./utils";
const defaultColor = 209;
function ModalComponent(props: { iconName: string; Icon: t.Icon; } & ModalProps) {
const [color, SetColor] = useState(defaultColor);
const onKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
if (e.key === "ArrowLeft") {
SetColor(color + -1);
} else if (e.key === "ArrowRight") {
SetColor(color + 1);
}
}
}, [color]);
const onColorChange = useCallback((e: { type: string; color: string; }) => {
SetColor(_cssColors.indexOf(e.color));
}, [color]);
useEffect(() => {
document.addEventListener("keydown", onKeyDown);
// @ts-ignore
FluxDispatcher.subscribe("ICONVIEWER_COLOR_CHANGE", onColorChange);
return () => {
document.removeEventListener("keydown", onKeyDown);
// @ts-ignore
FluxDispatcher.unsubscribe("ICONVIEWER_COLOR_CHANGE", onColorChange);
};
}, [onKeyDown]);
if (color < 0 || color >= cssColors.length) {
SetColor(0);
}
const { iconName, Icon } = props;
return (<ModalRoot {...props} size={ModalSize.DYNAMIC} className="vc-ic-modals-root vc-ic-icon-modal-root">
<ModalHeader>
<ModalHeaderTitle iconName={iconName} color={color} name="icon" />
<ModalCloseButton onClick={props.onClose} />
</ModalHeader>
<ModalContent>
{IconsFinds[iconName] ?
<div className="vc-icon-modal-codeblock">
<CodeBlock lang="ts" content={`const ${iconName + "Icon"} = findComponentByCode(${JSON.stringify(IconsFinds[iconName])})`} />
</div>
: null
}
<div className="vc-icon-modal-main-container">
<div className="vc-icon-display-box" aria-label={cssColors[color]?.name}>
<Icon className="vc-icon-modal-icon" color={cssColors[color]?.css} />
</div>
<div className="vc-icon-other-icon-sizes">
{iconSizes.map((size, idx) =>
<TooltipContainer text={`${size} size`} key={`vc-iv-size-${size}-${idx}`}>
<Icon className="vc-icon-modal-size-ex-icon" size={size} color={cssColors[color]?.css} style={{
marginLeft: "25px"
}} />
</TooltipContainer>
)}
</div>
</div>
</ModalContent>
<ModalFooter className="vc-ic-modals-footer">
<Button
color={Button.Colors.BRAND}
onClick={() => openSaveModal(iconName, Icon, color)}
>
Save as
</Button>
<Button
color={Button.Colors.LINK}
className={classes(Margins.right8, "vc-iv-raw-modal-button")}
onClick={() => openRawModal(iconName, Icon, color)}
>
Raw
</Button>
</ModalFooter>
</ModalRoot>);
}
export function openIconModal(iconName: string, Icon: t.Icon) {
openModal(props => <ModalComponent iconName={iconName} Icon={Icon} {...props} />);
}

View file

@ -1,122 +0,0 @@
.vc-icon-modal-codeblock {
margin-left: 10%;
margin-top: 30px;
}
.vc-icon-icon {
margin-left: 5%;
color: var(--interactive-icon-default);
transition: transform 0.15s ease, color 0.15s ease;
}
.vc-icon-container:hover .vc-icon-icon {
transform: scale(1.020);
filter: brightness(1.5);
}
.vc-icon-modal-main-container {
display: flex;
}
.vc-ic-unordered-list li {
margin-left: 5%;
list-style: disc;
}
.vc-icon-other-icon-sizes {
height: 32px;
display: flex;
margin-top: 15%;
margin-left: 5%;
}
.vc-ic-icon-modal-root {
height: 450px;
width: 700px;
}
.vc-icons-tab-grid-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(64px, 1fr));
gap: 8px;
}
.vc-icon-modal-size-ex-icon {
margin-right: 5%;
}
.vc-icon-modal-icon {
height: 164px;
width: 164px;
}
.vc-icon-tab-search-bar-grid {
display: grid;
height: 50px;
gap: 10px;
grid-template-columns: 1fr 10px;
}
.vc-icon-display-box {
height: 164px;
width: 164px;
margin-top: 5%;
margin-left: 15%;
background-image: repeating-linear-gradient(45deg,
#ffffff1a 0,
#ffffff1a 10px,
#0000001a 10px,
#0000001a 20px);
border-radius: 10px;
}
.vc-icon-container {
margin-top: 5px;
padding: 15px;
border-radius: 5px;
border: 3px solid transparent;
box-sizing: border-box;
}
.vc-icon-container:hover {
border-radius: 5px;
border: 3px solid var(--background-base-low);
box-sizing: border-box;
}
.vc-icon-title {
font-size: 0.8em;
margin-top: 0;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.vc-icon-modal-color-tooltip:hover {
background-color: var(--background-feedback-info);
}
.vc-save-modal {
margin-top: 10%;
display: grid;
grid-template-columns: auto 1fr;
gap: 10px;
}
.vc-save-select-option-1 {
margin-bottom: 5%;
}
.vc-save-select-option-2 {
margin-top: 5%;
}
.vc-ic-modals-root {
border-radius: 25px;
}
.vc-ic-modals-footer {
border-bottom-left-radius: 25px;
border-bottom-right-radius: 25px;
}

View file

@ -1,89 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import "./IconsTab.css";
import { Heading } from "@components/Heading";
import { SettingsTab, wrapTab } from "@components/settings";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import * as t from "@vencord/discord-types";
import { Button, Clickable, React, TextInput, TooltipContainer } from "@webpack/common";
import { openIconModal } from "./IconModal";
import { getNameByIcon } from "./names";
import { findAllIcons, IconsDef } from "./utils";
export let Icons: IconsDef | null = null;
function searchMatch(search: string, name: string, Icon: t.Icon, searchbyFunction: boolean): boolean {
if (search === "") return true;
if (searchbyFunction) {
return String(Icon).includes(search);
}
const words = name.replace(/([A-Z]([a-z]+)?)/g, " $1").toLowerCase().split(" ");
const searchKeywords = search.toLowerCase().split(" ");
return searchKeywords.every(keyword => words.includes(keyword)) || words.every(keyword => searchKeywords.includes(keyword)) || name.toLowerCase().includes(search.toLowerCase());
}
function RenderIcons({ search, searchbyFunction }: { search: string; searchbyFunction: boolean; }) {
if (Icons === null) {
const OrgIcons = Array.from(new Set(findAllIcons()));
Icons = Object.fromEntries(Object.keys(OrgIcons).map(k => [String(getNameByIcon(OrgIcons[k], k)), OrgIcons[k]])) as IconsDef;
}
return <div className="vc-icons-tab-grid-container">
{Object.entries(Icons).map(([iconName, Icon], index) =>
searchMatch(search, iconName, Icon, searchbyFunction) && <React.Fragment key={`iv-${iconName}`}>
<div className="vc-icon-box">
<Clickable onClick={() => openIconModal(iconName, Icon)}>
<div className="vc-icon-container">
<Icon className="vc-icon-icon" size="lg" width={32} height={32} color="var(--interactive-icon-default)" />
</div>
</Clickable>
<Heading className="vc-icon-title" tag="h3">{iconName}</Heading>
</div>
</React.Fragment>
)}</div>;
}
function IconsTab() {
const [search, setSearch] = React.useState<string>("");
const [searchByFunction, setSearchByFunction] = React.useState<boolean>(false);
const MemoRenderIcons = React.memo(RenderIcons);
return (
<SettingsTab>
<div className={classes(Margins.top16, "vc-icon-tab-search-bar-grid")}>
<TextInput autoFocus value={search} placeholder="Search for an icon..." onChange={setSearch} />
<TooltipContainer text="Search by function context">
<Button
size={Button.Sizes.SMALL}
aria-label="Search by function context"
style={{ marginTop: "50%" }}
color={searchByFunction ? Button.Colors.GREEN : Button.Colors.PRIMARY}
onClick={() => setSearchByFunction(!searchByFunction)}
>Func</Button>
</TooltipContainer>
</div>
<MemoRenderIcons search={search} searchbyFunction={searchByFunction} />
</SettingsTab>
);
}
export default wrapTab(IconsTab, "IconsTab");

View file

@ -0,0 +1,132 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "../styles.css";
import { Button } from "@components/Button";
import { Heading } from "@components/Heading";
import { SettingsTab, wrapTab } from "@components/settings";
import { debounce } from "@shared/debounce";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { useIntersection } from "@utils/react";
import { Icon } from "@vencord/discord-types";
import { Clickable, TextInput, TooltipContainer, useCallback, useEffect, useMemo, useState } from "@webpack/common";
import { getNameByIcon, IconsFinds } from "../names";
import { IconsDef } from "../types";
import { findAllIcons } from "../utils";
import { openIconModal } from "./Modals";
let cachedIcons: IconsDef | null = null;
function getIcons(): IconsDef {
if (cachedIcons) return cachedIcons;
const allIcons = Array.from(new Set(findAllIcons()));
cachedIcons = Object.fromEntries(
allIcons.map((icon, idx) => [getNameByIcon(icon, String(idx)), icon])
);
return cachedIcons;
}
function searchMatch(search: string, name: string, Icon: Icon, searchByFunction: boolean): boolean {
if (!search) return true;
if (searchByFunction) return String(Icon).includes(search);
const words = name.replace(/([A-Z]([a-z]+)?)/g, " $1").toLowerCase().split(" ");
const keywords = search.toLowerCase().split(" ");
return keywords.every(k => words.includes(k)) ||
words.every(w => keywords.includes(w)) ||
name.toLowerCase().includes(search.toLowerCase());
}
function IconItem({ iconName, Icon }: { iconName: string; Icon: Icon; }) {
return (
<div className="vc-icon-box">
<Clickable onClick={() => openIconModal(iconName, Icon, IconsFinds[iconName])}>
<div className="vc-icon-container">
<Icon className="vc-icon-icon" size="lg" width={32} height={32} color="var(--interactive-icon-default)" />
</div>
</Clickable>
<Heading className="vc-icon-title" tag="h3">{iconName}</Heading>
</div>
);
}
function IconsTab() {
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const [searchByFunction, setSearchByFunction] = useState(false);
const icons = useMemo(() => getIcons(), []);
const debouncedSetSearch = useMemo(
() => debounce((query: string) => setSearch(query), 150),
[]
);
const onSearch = useCallback((query: string) => {
setSearchInput(query);
debouncedSetSearch(query);
}, [debouncedSetSearch]);
const filteredIcons = useMemo(() =>
Object.entries(icons).filter(([name, Icon]) => searchMatch(search, name, Icon, searchByFunction)),
[icons, search, searchByFunction]
);
const iconsToLoad = 120;
const [visibleCount, setVisibleCount] = useState(iconsToLoad);
useEffect(() => {
setVisibleCount(iconsToLoad);
}, [search, searchByFunction]);
const loadMore = useCallback(() => {
setVisibleCount(v => Math.min(v + iconsToLoad, filteredIcons.length));
}, [filteredIcons.length]);
const [sentinelRef, isSentinelVisible] = useIntersection();
useEffect(() => {
if (isSentinelVisible && visibleCount < filteredIcons.length) {
loadMore();
}
}, [isSentinelVisible, visibleCount, filteredIcons.length, loadMore]);
const visibleIcons = filteredIcons.slice(0, visibleCount);
return (
<SettingsTab>
<div className={classes(Margins.top16, "vc-icon-tab-search-bar-grid")}>
<TextInput autoFocus value={searchInput} placeholder="Search for an icon..." onChange={onSearch} />
<TooltipContainer text="Search by function context">
<Button
size="small"
aria-label="Search by function context"
className="vc-icon-search-func-btn"
variant={searchByFunction ? "positive" : "primary"}
onClick={() => setSearchByFunction(!searchByFunction)}
>
Func
</Button>
</TooltipContainer>
</div>
<div className="vc-icons-tab-grid-container">
{visibleIcons.map(([iconName, Icon]) => (
<IconItem key={iconName} iconName={iconName} Icon={Icon} />
))}
</div>
{visibleCount < filteredIcons.length && (
<div ref={sentinelRef} className="vc-icon-sentinel" />
)}
</SettingsTab>
);
}
export default wrapTab(IconsTab, "IconsTab");

View file

@ -0,0 +1,331 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { BaseText } from "@components/BaseText";
import { Button } from "@components/Button";
import { Flex } from "@components/Flex";
import { HeadingSecondary } from "@components/Heading";
import { Paragraph } from "@components/Paragraph";
import { copyWithToast, getIntlMessage } from "@utils/discord";
import { Logger } from "@utils/Logger";
import {
ModalContent,
ModalFooter,
ModalHeader,
ModalProps,
ModalRoot,
ModalSize,
openModal
} from "@utils/modal";
import { saveFile } from "@utils/web";
import { Icon } from "@vencord/discord-types";
import { findComponentByCodeLazy } from "@webpack";
import {
ContextMenuApi,
createRoot,
FluxDispatcher,
Menu,
ReactDOM,
TooltipContainer,
useCallback,
useEffect,
useMemo,
useState
} from "@webpack/common";
import { cssColors, getCssColorKeys, iconSizes, iconSizesInPx } from "../utils";
const logger = new Logger("IconViewer");
const CloseButton = findComponentByCodeLazy("CLOSE_BUTTON_LABEL");
const BugIcon = findComponentByCodeLazy("1.1.27.1.37 0a6.66 6.6");
const FORMAT_EXTENSIONS: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpeg",
"image/gif": "gif",
"image/webp": "webp",
"image/svg+xml": "svg",
"image/avif": "avif"
};
function useColorNavigation(initialColor: number) {
const [color, setColor] = useState(initialColor);
const onKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
setColor(c => {
const next = c + (e.key === "ArrowLeft" ? -1 : 1);
const max = getCssColorKeys().length;
return next < 0 ? max - 1 : next >= max ? 0 : next;
});
}
}, []);
const onColorChange = useCallback((e: { color: string; }) => {
const keys = getCssColorKeys();
const idx = keys.indexOf(e.color);
if (idx !== -1) setColor(idx);
}, []);
useEffect(() => {
document.addEventListener("keydown", onKeyDown);
FluxDispatcher.subscribe("ICONVIEWER_COLOR_CHANGE", onColorChange);
return () => {
document.removeEventListener("keydown", onKeyDown);
FluxDispatcher.unsubscribe("ICONVIEWER_COLOR_CHANGE", onColorChange);
};
}, [onKeyDown, onColorChange]);
return [color, setColor] as const;
}
function ColorContextMenu({ colorKeys }: { colorKeys: string[]; }) {
const [query, setQuery] = useState("");
const filtered = colorKeys.filter(k =>
!query || k.toLowerCase().includes(query.toLowerCase())
);
return (
<Menu.Menu
navId="vc-ic-colors-menu"
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
aria-label="Icon Viewer Colors"
>
<Menu.MenuControlItem
id="vc-ic-colors-search"
control={(props, ref) => (
<Menu.MenuSearchControl
{...props}
query={query}
onChange={setQuery}
ref={ref}
placeholder={getIntlMessage("SEARCH")}
autoFocus
/>
)}
/>
<Menu.MenuSeparator />
{filtered.map(colorKey => (
<Menu.MenuItem
key={colorKey}
id={colorKey}
label={colorKey}
action={() => FluxDispatcher.dispatch({ type: "ICONVIEWER_COLOR_CHANGE", color: colorKey })}
/>
))}
</Menu.Menu>
);
}
function convertToHtml(component: React.ReactElement): string {
const container = document.createElement("div");
const root = createRoot(container);
ReactDOM.flushSync(() => root.render(component));
const content = container.innerHTML;
root.unmount();
return content;
}
function saveIcon(iconName: string, icon: Element | string, color: number, size: number, type: string) {
const colorName = cssColors[color]?.name ?? "unknown";
const ext = FORMAT_EXTENSIONS[type] ?? "png";
const filename = `${iconName}-${colorName}-${size}px.${ext}`;
if (typeof icon === "string") {
saveFile(new File([icon], filename, { type: "text/plain" }));
return;
}
for (const el of icon.children) {
const fill = el.getAttribute("fill");
if (fill?.startsWith("var(")) {
el.setAttribute("fill", getComputedStyle(icon).getPropertyValue(fill.slice(4, -1)));
}
}
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const img = new Image();
img.onload = () => {
ctx.drawImage(img, 0, 0, size, size);
const link = document.createElement("a");
link.download = filename;
link.href = canvas.toDataURL(type);
link.click();
};
img.src = `data:image/svg+xml;base64,${btoa(icon.outerHTML)}`;
}
function OtherContextMenu({ iconName, Icon, color, findCode }: { iconName: string; Icon: Icon; color: number; findCode: string | null; }) {
const colorData = cssColors[color];
const handleSave = (type: string) => {
const size = iconSizesInPx.lg;
const iconEl = type === "image/svg+xml"
? convertToHtml(<Icon className="vc-ic-save-icon" color={colorData?.css} />)
: document.querySelector(".vc-ic-icon-preview .vc-ic-icon-large") as Element | null;
if (iconEl) saveIcon(iconName, iconEl, color, size, type);
};
return (
<Menu.Menu
navId="vc-ic-other-menu"
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
aria-label="Icon Options"
>
<Menu.MenuItem
id="log-console"
label="Log to Console"
icon={BugIcon}
action={() => logger.info(Icon)}
/>
<Menu.MenuItem id="save" label="Save As...">
<Menu.MenuItem
id="save-png"
label="PNG"
action={() => handleSave("image/png")}
/>
<Menu.MenuItem
id="save-svg"
label="SVG"
action={() => handleSave("image/svg+xml")}
/>
<Menu.MenuItem
id="save-jpeg"
label="JPEG"
action={() => handleSave("image/jpeg")}
/>
<Menu.MenuItem
id="save-webp"
label="WEBP"
action={() => handleSave("image/webp")}
/>
<Menu.MenuItem
id="save-gif"
label="GIF"
action={() => handleSave("image/gif")}
/>
<Menu.MenuItem
id="save-avif"
label="AVIF"
action={() => handleSave("image/avif")}
/>
</Menu.MenuItem>
</Menu.Menu>
);
}
function IconModal({ iconName, Icon, findPattern, onClose, transitionState }: { iconName: string; Icon: Icon; findPattern?: string; } & ModalProps) {
const [color, setColor] = useColorNavigation(209);
const colorData = cssColors[color];
const colorKeys = useMemo(() => getCssColorKeys(), []);
const findCode = findPattern
? `const ${iconName}Icon = findComponentByCode(${JSON.stringify(findPattern)})`
: null;
const openColorMenu = (e: React.MouseEvent) => {
ContextMenuApi.openContextMenu(e, () => <ColorContextMenu colorKeys={colorKeys} />);
};
const onWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const max = colorKeys.length;
setColor(c => {
const next = c + (e.deltaY > 0 ? 1 : -1);
return next < 0 ? max - 1 : next >= max ? 0 : next;
});
}, [colorKeys.length, setColor]);
const openOtherMenu = (e: React.MouseEvent) => {
ContextMenuApi.openContextMenu(e, () => (
<OtherContextMenu iconName={iconName} Icon={Icon} color={color} findCode={findCode} />
));
};
return (
<ModalRoot transitionState={transitionState} size={ModalSize.MEDIUM}>
<ModalHeader separator={false} className="vc-ic-modal-header">
<div className="vc-ic-modal-header-content">
<BaseText size="lg" weight="semibold" className="vc-ic-modal-title">{iconName}</BaseText>
</div>
<div className="vc-ic-modal-header-trailing">
<CloseButton onClick={onClose} />
</div>
</ModalHeader>
<ModalContent className="vc-ic-modal-content">
<Flex className="vc-ic-modal-main">
<div
className="vc-ic-icon-preview"
aria-label={colorData?.name}
onContextMenu={openColorMenu}
onWheel={onWheel}
>
<Icon className="vc-ic-icon-large" color={colorData?.css} />
</div>
<Flex flexDirection="column" className="vc-ic-icon-info">
<Flex className="vc-ic-icon-sizes">
{iconSizes.map(size => (
<TooltipContainer text={size} key={size}>
<Icon size={size} color={colorData?.css} />
</TooltipContainer>
))}
</Flex>
<TooltipContainer text="Right-click icon to change">
<BaseText size="sm" color="text-muted" className="vc-ic-color-label">
{colorData?.name}
</BaseText>
</TooltipContainer>
</Flex>
</Flex>
</ModalContent>
<ModalFooter className="vc-ic-modal-footer">
<Button variant="primary" onClick={() => copyWithToast(findCode ?? String(Icon), findCode ? "Find code copied!" : "Raw function copied!")}>
{findCode ? "Copy" : "Copy Raw"}
</Button>
<Button variant="secondary" onClick={openOtherMenu}>
Actions
</Button>
</ModalFooter>
</ModalRoot>
);
}
export function openIconModal(iconName: string, Icon: Icon, findPattern?: string) {
openModal(props => <IconModal iconName={iconName} Icon={Icon} findPattern={findPattern} {...props} />);
}
export function SettingsAbout() {
return (
<>
<HeadingSecondary>Features</HeadingSecondary>
<Paragraph>
<ul className="vc-ic-unordered-list">
<li>Preview icons</li>
<li>Copy icon names and CSS variables</li>
<li>Download icons in different formats (SVG, PNG, GIF, etc.)</li>
<li>Copy pre-made icon finds for your plugins</li>
<li>Find icons by function context</li>
<li>Search for colors by right-clicking the color name</li>
</ul>
</Paragraph>
<HeadingSecondary>Special thanks</HeadingSecondary>
<Paragraph>
<ul className="vc-ic-unordered-list">
<li>krystalskullofficial._.</li>
<li>davr1</li>
<li>suffocate</li>
</ul>
</Paragraph>
</>
);
}

View file

@ -1,6 +1,6 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
@ -10,13 +10,12 @@ import { EquicordDevs } from "@utils/constants";
import definePlugin, { StartAt } from "@utils/types";
import { openUserSettingsPanel } from "@webpack/common";
import IconsTab from "./IconsTab";
import { SettingsAbout } from "./subComponents";
import IconsTab from "./components/IconsTab";
import { SettingsAbout } from "./components/Modals";
export default definePlugin({
name: "IconViewer",
description: "Adds a new tab to settings, to preview all icons",
description: "Adds a new tab to settings to preview all icons.",
authors: [EquicordDevs.iamme],
dependencies: ["Settings"],
startAt: StartAt.WebpackReady,
@ -46,9 +45,9 @@ export default definePlugin({
},
stop() {
const { customEntries, customSections } = SettingsPlugin;
const entry = customEntries.findIndex(entry => entry.key === "equicord_icon_viewer");
const section = customSections.findIndex(section => section({} as any).id === "IconViewer");
if (entry !== -1) customEntries.splice(entry, 1);
if (section !== -1) customSections.splice(section, 1);
const entryIdx = customEntries.findIndex(e => e.key === "equicord_icon_viewer");
const sectionIdx = customSections.findIndex(s => s({} as any).id === "IconViewer");
if (entryIdx !== -1) customEntries.splice(entryIdx, 1);
if (sectionIdx !== -1) customSections.splice(sectionIdx, 1);
},
});

View file

@ -4,10 +4,10 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import * as t from "@vencord/discord-types";
import { Icon } from "@vencord/discord-types";
// name: pattern
export const IconsFinds = {
export const IconsFinds: Record<string, string> = {
Discord: "1.6 5.64-2.87",
XboxNeutral: "8.68-.62c.89-.81 1.5",
PlaystationNeutral: "2.04Zm-9.35",
@ -137,12 +137,12 @@ export const IconsFinds = {
// 13l4.91-8.05a1.8
export const namePatterns = new Map(Object.entries(IconsFinds).map(([name, pattern]) => [name, pattern]));
const namePatterns = new Map(Object.entries(IconsFinds));
export function getNameByIcon(Icon: t.Icon, defaultName: any) {
export function getNameByIcon(Icon: Icon, defaultName: string) {
for (const [name, pattern] of namePatterns) {
if (String(Icon).includes(pattern)) {
namePatterns.delete(name); // remove pattern from map after being found prevent overshadowing
namePatterns.delete(name);
return name;
}
}

View file

@ -1,69 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { CodeBlock } from "@components/CodeBlock";
import { copyToClipboard } from "@utils/clipboard";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import {
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalProps,
ModalRoot,
ModalSize,
openModal
} from "@utils/modal";
import * as t from "@vencord/discord-types";
import { Button, Toasts } from "@webpack/common";
import { ModalHeaderTitle } from "./subComponents";
function ModalComponent(props: { func: Function; iconName: string; color: number; } & ModalProps) {
const { func, iconName, color } = props;
return (<ModalRoot {...props} size={ModalSize.LARGE} className="vc-ic-modals-root vc-ic-raw-modal-root">
<ModalHeader>
<ModalHeaderTitle iconName={iconName} color={color} name="raw" />
<ModalCloseButton onClick={props.onClose} />
</ModalHeader>
<ModalContent>
<div className="vc-iv-raw-modal">
<CodeBlock content={String(func)} lang="js" />
</div>
</ModalContent>
<ModalFooter className="vc-ic-modals-footer">
<Button
color={Button.Colors.PRIMARY}
className={"vc-iv-raw-modal-copy-button"}
onClick={() => {
copyToClipboard(String(func));
Toasts.show({
id: Toasts.genId(),
message: `Copied raw \`${iconName}\` to clipboard`,
type: Toasts.Type.SUCCESS
});
}}
>
Copy
</Button>
<Button
color={Button.Colors.LINK}
className={classes(Margins.right8, "vc-iv-log-to-console-button")}
onClick={() => { console.log(func); }}
>
log to console
</Button>
</ModalFooter>
</ModalRoot>);
}
export function openRawModal(iconName: string, Icon: t.Icon, colorIndex: number) {
openModal(props => <ModalComponent iconName={iconName} func={Icon} color={colorIndex} {...props} />);
}

View file

@ -1,154 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Paragraph } from "@components/Paragraph";
import {
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalProps,
ModalRoot,
ModalSize,
openModal
} from "@utils/modal";
import * as t from "@vencord/discord-types";
import { Button, Select, TextInput, useCallback, useEffect, useState } from "@webpack/common";
import { ModalHeaderTitle } from "./subComponents";
import { convertComponentToHtml, cssColors, iconSizesInPx, saveIcon } from "./utils";
type IDivElement = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
export function NumericComponent({ onChange, value, className, style }: { onChange: (value: number) => void, value: number; className?: string; style?: React.CSSProperties; }) {
const handleChange = (value: string) => {
const newValue = Number(value);
if (isNaN(newValue)) return;
onChange(newValue);
};
return (
<div className={className} style={style}>
<TextInput
type="number"
pattern="-?[0-9]+"
value={value}
placeholder="Enter a number"
onChange={handleChange}
/>
</div>
);
}
export function SelectComponent({ option, onChange, onError, className }: IDivElement & { option: any, onChange: (value: any) => void, onError: (msg: string | null) => void; className?: string; }) {
const [state, setState] = useState(option.options?.find(o => o.default)?.value ?? null);
const [error, setError] = useState<string | null>(null);
useEffect(() => onError(error), [error]);
const handleChange = (newValue: any) => {
const isValid = option.isValid?.call({}, newValue) ?? true;
if (!isValid) setError("Invalid input provided.");
else {
setError(null);
setState(newValue);
onChange(newValue);
}
};
return (<div className={className}>
<Select
options={option.options}
placeholder={"Select an option"}
maxVisibleItems={5}
closeOnSelect={true}
select={handleChange}
isSelected={v => v === state}
serialize={v => String(v)}
/>
{error && <Paragraph style={{ color: "var(--text-feedback-critical)" }}>{error}</Paragraph>}
</div>);
}
function ModalComponent(props: { iconName: string, Icon: t.Icon; color: number; } & ModalProps) {
const [color, SetColor] = useState((props.color ?? 187));
const [iconSize, SetIconSize] = useState("lg");
const [saveType, SetSaveType] = useState("png");
const [customSize, SetCustomSize] = useState(32);
const onKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
if (e.key === "ArrowLeft") {
SetColor(color + -1);
} else if (e.key === "ArrowRight") {
SetColor(color + 1);
}
}
}, [color]);
useEffect(() => {
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [onKeyDown]);
const { iconName, Icon } = props;
return (<ModalRoot {...props} size={ModalSize.MEDIUM} className="vc-ic-modals-root vc-ic-save-modal-root">
<ModalHeader>
<ModalHeaderTitle iconName={iconName} color={color} name="save" />
<ModalCloseButton onClick={props.onClose} />
</ModalHeader>
<ModalContent>
<div className="vc-save-modal">
<div className="vc-icon-display-box vc-save-modal-icon-display-box" aria-label={cssColors[color]?.name} style={{ marginLeft: "0", marginTop: "0" }}>
<Icon className="vc-icon-modal-icon" color={cssColors[color].css} />
</div>
<div className="vc-save-options" style={{ marginTop: "0", marginLeft: "0" }}>
<SelectComponent className="vc-save-select-option-1"
option={{
options: [
{ "label": "large", "value": "lg", "default": true },
{ "label": "medium", "value": "md" },
{ "label": "small", "value": "sm" },
{ "label": "extra small", "value": "xs" },
{ "label": "extra extra small", "value": "xxs" },
{ "label": "custom", "value": "custom" }
]
}} onChange={newValue => SetIconSize(newValue)} onError={() => { }} />
<NumericComponent style={{ visibility: iconSize === "custom" ? "visible" : "hidden" }} value={customSize} onChange={(value: number) => SetCustomSize(value)} />
<SelectComponent className="vc-save-select-option-2"
option={{
options: [
{ "label": "png", "value": "image/png", "default": true },
{ "label": "jpeg", "value": "image/jpeg" },
{ "label": "gif", "value": "image/gif" },
{ "label": "avif", "value": "image/avif" },
{ "label": "webp", "value": "image/webp" },
{ "label": "svg", "value": "image/svg+xml" },
]
}} onChange={newValue => SetSaveType(newValue)} onError={() => { }} />
</div>
</div>
</ModalContent>
<ModalFooter className="vc-ic-modals-footer">
<Button
color={Button.Colors.BRAND}
onClick={() => saveIcon(iconName,
saveType === "image/svg+xml" || document.querySelector(".vc-icon-modal-icon") == null ?
convertComponentToHtml(<Icon className="vc-icon-modal-icon" color={cssColors[color].css} />) :
document.querySelector(".vc-icon-modal-icon") as Element,
color, iconSizesInPx[iconSize] ?? customSize, saveType)}
>
Save
</Button>
</ModalFooter>
</ModalRoot>);
}
export function openSaveModal(iconName: string, Icon: t.Icon, colorIndex: number) {
openModal(props => <ModalComponent iconName={iconName} Icon={Icon} color={colorIndex} {...props} />);
}

View file

@ -0,0 +1,140 @@
.vc-icons-tab-grid-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(64px, 1fr));
gap: 8px;
}
.vc-icon-box {
display: flex;
flex-direction: column;
align-items: center;
}
.vc-icon-container {
padding: 12px;
border-radius: 8px;
border: 3px solid transparent;
transition: background-color 0.15s ease;
}
.vc-icon-container:hover {
background-color: var(--background-modifier-hover);
}
.vc-icon-icon {
color: var(--interactive-icon-default);
transition: transform 0.15s ease, filter 0.15s ease;
}
.vc-icon-container:hover .vc-icon-icon {
transform: scale(1.1);
filter: brightness(1.5);
}
.vc-icon-title {
font-size: 0.75rem;
margin-top: 4px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 64px;
}
.vc-icon-tab-search-bar-grid {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 16px;
}
.vc-icon-tab-search-bar-grid>div:first-child {
flex: 1;
}
.vc-icon-sentinel {
height: 32px;
}
.vc-ic-modal-header {
align-items: flex-start;
position: relative;
padding: 16px 16px 16px 24px;
z-index: 1;
}
.vc-ic-modal-header-content {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.vc-ic-modal-header-trailing {
position: absolute;
top: 8px;
right: 8px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.vc-ic-modal-content {
padding: 16px;
}
.vc-ic-modal-main {
gap: 24px;
align-items: center;
}
.vc-ic-icon-preview {
width: 128px;
height: 128px;
display: flex;
align-items: center;
justify-content: center;
background-image: repeating-linear-gradient(45deg,
#ffffff1a 0,
#ffffff1a 10px,
#0000001a 10px,
#0000001a 20px);
border-radius: 8px;
cursor: context-menu;
}
.vc-ic-icon-large {
width: 96px;
height: 96px;
}
.vc-ic-icon-info {
gap: 12px;
}
.vc-ic-icon-sizes {
gap: 16px;
align-items: flex-end;
}
.vc-ic-color-label {
cursor: help;
}
.vc-ic-modal-footer {
display: flex;
flex-direction: row-reverse;
justify-content: flex-start;
gap: 8px;
}
.vc-ic-unordered-list li {
margin-left: 16px;
list-style: disc;
}
.vc-ic-modal-title {
user-select: none;
padding-right: 32px;
}

View file

@ -1,121 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { BaseText } from "@components/BaseText";
import { HeadingSecondary } from "@components/Heading";
import { Paragraph } from "@components/Paragraph";
import { copyToClipboard } from "@utils/clipboard";
import { getIntlMessage } from "@utils/discord";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { Clickable, ContextMenuApi, FluxDispatcher, Menu, TooltipContainer, useState } from "@webpack/common";
import type { ComponentPropsWithRef, PropsWithChildren } from "react";
import { _cssColors, cssColors } from "./utils";
function searchMatch(search: string, name: string): boolean {
if (search === "") return true;
const words = name.toLowerCase().split("_");
const searchKeywords = search.toLowerCase().split(" ").filter(keyword => keyword !== "");
return searchKeywords.every(keyword => words.includes(keyword)) || words.every(keyword => searchKeywords.includes(keyword)) || name.toLowerCase().includes(search.toLowerCase());
}
export type ClickableProps<T extends "a" | "div" | "span" | "li" = "div"> = PropsWithChildren<ComponentPropsWithRef<T>> & {
tag?: T;
};
export function IconTooltip({ children, copy, className, ...props }: ClickableProps & { children: string; copy: string; }) {
return <TooltipContainer text={"Click to copy"} className={className}>
<Clickable onClick={() => {
copyToClipboard(copy);
}} {...props}>{children}</Clickable>
</TooltipContainer>;
}
export const ModalHeaderTitle = ({ iconName, color, name }: { iconName: string; color: number; name: string; }) => {
return <BaseText size="lg" weight="semibold"
style={{ flexGrow: 1, display: "flex" }}
className={classes("vc-ic-modal-header-title", `vc-ic-${name}-modal-header-title`)}>
<IconTooltip copy={iconName} className={classes(Margins.right8, "vc-icon-modal-color-tooltip")}>
{iconName}
</IconTooltip>
{" - "}
<IconTooltip copy={cssColors[color]?.css} className={classes(Margins.left8, "vc-icon-modal-color-tooltip")}
onContextMenu={e => {
ContextMenuApi.openContextMenu(e, () => {
const [query, setQuery] = useState("");
return (<Menu.Menu
navId="vc-ic-colors-header-menu"
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
color="danger"
aria-label="Icon Viewer Colors"
>
<Menu.MenuControlItem
id="vc-ic-colors-search"
control={(props, ref) => (
<Menu.MenuSearchControl
{...props}
query={query}
onChange={setQuery}
ref={ref}
placeholder={getIntlMessage("SEARCH")}
autoFocus
/>
)}
/>
{!!_cssColors.length && <Menu.MenuSeparator />}
{_cssColors.map(p => (
searchMatch(query, p) && <Menu.MenuItem
key={p}
id={p}
label={p}
action={() => {
// @ts-ignore
FluxDispatcher.dispatch({ type: "ICONVIEWER_COLOR_CHANGE", color: p });
}}
/>
))}
</Menu.Menu>);
});
}}>
{cssColors[color]?.name}
</IconTooltip>
</BaseText >;
};
export function SettingsAbout() {
return <>
<HeadingSecondary>Features</HeadingSecondary>
<Paragraph>
<BaseText size="sm">
<ul className="vc-ic-unordered-list">
<li>Preview icons</li>
<li>Copy icon names and CSS variables</li>
<li>Ability to download icons in different formats (SVG, PNG, GIF, etc.)</li>
<li>Copy pre-made icon finds for your plugins (Only some icons have this, submit finds either in a server or DMs)</li>
<li>Find icons by function context (helpful when creating finds)</li>
<li>Search for colors by right-clicking the color name in the modal title</li>
</ul>
</BaseText>
</Paragraph>
<HeadingSecondary>Special thanks</HeadingSecondary>
<Paragraph>
<BaseText size="sm" className="vc-ic-unordered-list">
<ul>
<li>krystalskullofficial._.</li>
<li>davr1</li>
<li>suffocate</li>
</ul>
</BaseText>
</Paragraph>
</>;
}

View file

@ -0,0 +1,17 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Icon } from "@vencord/discord-types";
export type IconsDef = Record<string, Icon>;
export interface CssColorData {
name: string;
css: string;
key: string;
}
export type IconSize = "xxs" | "xs" | "sm" | "md" | "lg";

View file

@ -0,0 +1,49 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { findAll, findByPropsLazy, waitFor } from "@webpack";
import { CssColorData, IconSize } from "./types";
let colorKeys: string[] = [];
const Colors = findByPropsLazy("colors", "layout");
export const iconSizesInPx: Record<string, number> = findByPropsLazy("md", "lg", "xxs");
export const iconSizes: IconSize[] = ["xxs", "xs", "sm", "md", "lg"];
export function getCssColorKeys(): string[] {
return colorKeys;
}
export const cssColors = new Proxy({} as Record<number, CssColorData>, {
get: (target, key) => {
const idx = Number(key);
if (isNaN(idx)) return undefined;
if (target[idx]) return target[idx];
const colorKey = colorKeys[idx];
if (!colorKey || !Colors.colors[colorKey]?.css) return undefined;
const name = colorKey.split("_").map(x => x[0].toUpperCase() + x.toLowerCase().slice(1)).join(" ");
target[idx] = { name, css: Colors.colors[colorKey].css, key: colorKey };
return target[idx];
}
});
export function findAllIcons() {
return findAll(m => {
if (typeof m !== "function") return false;
const str = m.toString?.() ?? "";
if (str.includes("direction:")) return false;
return str.includes("viewBox:") && str.includes("color:") && (str.includes("foreground:") || str.includes("colorClass:"));
});
}
waitFor(["colors", "layout"], m => {
colorKeys = Object.keys(m.colors);
});

View file

@ -1,106 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { saveFile } from "@utils/web";
import * as t from "@vencord/discord-types";
import { findAll, findByPropsLazy, waitFor } from "@webpack";
import { createRoot, ReactDOM } from "@webpack/common";
export let _cssColors: string[] = [];
export type IconsDef = { [k: string]: t.Icon; };
export const iconSizesInPx = findByPropsLazy("md", "lg", "xxs");
export const Colors = findByPropsLazy("colors", "layout");
export const cssColors = new Proxy(
{
},
{
get: (target, key) => {
const colorKey = _cssColors[key];
return key in target
? target[key]
: Colors.colors[colorKey]?.css != null ? (target[key] = { name: colorKey.split("_").map((x: string) => x[0].toUpperCase() + x.toLowerCase().slice(1)).join(" "), css: Colors.colors[colorKey].css, key: colorKey }) : undefined;
},
set: (target, key, value) => {
target[key] = value;
return true;
}
}
) as unknown as Array<{ name: string; css: string; key: string; }>;
export const iconSizes = ["xxs", "xs", "sm", "md", "lg"];
const CrosspendingTypes: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpeg",
"image/gif": "gif",
"image/bmp": "bmp",
"image/tiff": "tiff",
"image/webp": "webp",
"image/svg+xml": "svg",
"image/avif": "avif"
};
export function saveIcon(iconName: string, icon: EventTarget & SVGSVGElement | Element | string, color: number, size: number, type = "image/png") {
const filename = `${iconName}-${cssColors[color]?.name ?? "unknown"}-${size}px.${CrosspendingTypes[type] ?? "png"}`;
if (typeof icon === "string") {
const file = new File([icon], filename, { type: "text/plain" });
saveFile(file);
return;
}
const innerElements = icon.children;
for (const el of innerElements) {
const fill = el.getAttribute("fill");
if (fill && fill.startsWith("var(")) {
el.setAttribute("fill", getComputedStyle(icon).getPropertyValue(fill.replace("var(", "").replace(")", "")));
}
}
// save svg as the given type
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const img = new Image();
img.onload = () => {
ctx.drawImage(img, 0, 0, size, size);
const link = document.createElement("a");
link.download = filename;
link.href = canvas.toDataURL(type);
link.click();
};
img.src = `data:image/svg+xml;base64,${btoa(icon.outerHTML)}`;
}
export function convertComponentToHtml(component?: React.ReactElement): string {
const container = document.createElement("div");
const root = createRoot(container);
ReactDOM.flushSync(() => root.render(component));
const content = container.innerHTML;
root.unmount();
return content;
}
export function findAllIcons() {
return findAll(m => {
if (typeof m !== "function") return false;
const str = m.toString?.() || "";
if (str.includes("direction:")) return false;
return str.includes("viewBox:") && str.includes("color:") && (str.includes("foreground:") || str.includes("colorClass:"));
});
}
waitFor(["colors", "layout"], m => {
_cssColors = Object.keys(m.colors);
cssColors.length = _cssColors.length;
});