trilium/apps/client/src/widgets/note_icon.tsx
2025-12-27 20:37:59 +02:00

225 lines
7.9 KiB
TypeScript

import "./note_icon.css";
import { IconRegistry } from "@triliumnext/commons";
import { Dropdown as BootstrapDropdown } from "bootstrap";
import { t } from "i18next";
import { RefObject } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
import FNote from "../entities/fnote";
import attributes from "../services/attributes";
import server from "../services/server";
import ActionButton from "./react/ActionButton";
import Dropdown from "./react/Dropdown";
import { FormDropdownDivider, FormListItem } from "./react/FormList";
import FormTextBox from "./react/FormTextBox";
import { useNoteContext, useNoteLabel } from "./react/hooks";
interface IconToCountCache {
iconClassToCountMap: Record<string, number>;
}
interface IconData {
iconToCount: Record<string, number>;
icons: (IconRegistry["sources"][number]["icons"][number] & { iconPack: string })[];
}
let iconToCountCache!: Promise<IconToCountCache> | null;
export default function NoteIcon() {
const { note, viewScope } = useNoteContext();
const [ icon, setIcon ] = useState<string | null | undefined>();
const [ iconClass ] = useNoteLabel(note, "iconClass");
const [ workspaceIconClass ] = useNoteLabel(note, "workspaceIconClass");
const dropdownRef = useRef<BootstrapDropdown>(null);
useEffect(() => {
setIcon(note?.getIcon());
}, [ note, iconClass, workspaceIconClass ]);
return (
<Dropdown
className="note-icon-widget"
title={t("note_icon.change_note_icon")}
dropdownRef={dropdownRef}
dropdownContainerStyle={{ width: "620px" }}
dropdownOptions={{ autoClose: "outside" }}
buttonClassName={`note-icon tn-focusable-button ${icon ?? "bx bx-empty"}`}
hideToggleArrow
disabled={viewScope?.viewMode !== "default"}
>
{ note && <NoteIconList note={note} dropdownRef={dropdownRef} /> }
</Dropdown>
);
}
function NoteIconList({ note, dropdownRef }: {
note: FNote,
dropdownRef: RefObject<BootstrapDropdown>;
}) {
const searchBoxRef = useRef<HTMLInputElement>(null);
const [ search, setSearch ] = useState<string>();
const [ iconData, setIconData ] = useState<IconData>();
const [ filterByPrefix, setFilterByPrefix ] = useState<string | null>(null);
useEffect(() => {
async function loadIcons() {
// Filter by text and/or category.
let icons: IconData["icons"] = [
...glob.iconRegistry.sources.map(s => s.icons.map((i) => ({
...i,
iconPack: s.name,
}))).flat()
];
const processedSearch = search?.trim()?.toLowerCase();
if (processedSearch || filterByPrefix !== null) {
icons = icons.filter((icon) => {
if (filterByPrefix) {
if (!icon.id?.startsWith(`${filterByPrefix} `)) {
return false;
}
}
if (processedSearch) {
if (!icon.terms?.some((t) => t.includes(processedSearch))) {
return false;
}
}
return true;
});
}
// Sort by count.
const iconToCount = await getIconToCountMap();
if (iconToCount) {
icons.sort((a, b) => {
const countA = iconToCount[a.id ?? ""] || 0;
const countB = iconToCount[b.id ?? ""] || 0;
return countB - countA;
});
}
setIconData({
iconToCount,
icons
});
}
loadIcons();
}, [ search, filterByPrefix ]);
return (
<>
<div class="filter-row">
<span>{t("note_icon.search")}</span>
<FormTextBox
inputRef={searchBoxRef}
type="text"
name="icon-search"
currentValue={search} onChange={setSearch}
autoFocus
/>
{getIconLabels(note).length > 0 && (
<div style={{ textAlign: "center" }}>
<ActionButton
icon="bx bx-reset"
text={t("note_icon.reset-default")}
onClick={() => {
if (!note) return;
for (const label of getIconLabels(note)) {
attributes.removeAttributeById(note.noteId, label.attributeId);
}
dropdownRef?.current?.hide();
}}
/>
</div>
)}
{glob.iconRegistry.sources.length > 0 && <Dropdown
buttonClassName="bx bx-filter-alt"
hideToggleArrow
noSelectButtonStyle
noDropdownListStyle
iconAction
title={t("note_icon.filter")}
>
<IconFilterContent filterByPrefix={filterByPrefix} setFilterByPrefix={setFilterByPrefix} />
</Dropdown>}
</div>
<div
class="icon-list"
onClick={(e) => {
// Make sure we are not clicking on something else than a button.
const clickedTarget = e.target as HTMLElement;
if (clickedTarget.tagName !== "SPAN" || clickedTarget.classList.length !== 2) return;
const iconClass = Array.from(clickedTarget.classList.values()).join(" ");
if (note) {
const attributeToSet = note.hasOwnedLabel("workspace") ? "workspaceIconClass" : "iconClass";
attributes.setLabel(note.noteId, attributeToSet, iconClass);
}
dropdownRef?.current?.hide();
}}
>
{(iconData?.icons ?? []).map(({ id, terms, iconPack }) => (
<span
key={id}
class={id}
title={t("note_icon.icon_tooltip", { name: terms?.[0] ?? id, iconPack })}
/>
))}
</div>
</>
);
}
function IconFilterContent({ filterByPrefix, setFilterByPrefix }: {
filterByPrefix: string | null;
setFilterByPrefix: (value: string | null) => void;
}) {
return (
<>
<FormListItem
checked={filterByPrefix === null}
onClick={() => setFilterByPrefix(null)}
>{t("note_icon.filter-none")}</FormListItem>
<FormListItem
checked={filterByPrefix === "bx"}
onClick={() => setFilterByPrefix("bx")}
>{t("note_icon.filter-default")}</FormListItem>
<FormDropdownDivider />
{glob.iconRegistry.sources.map(({ prefix, name, icon }) => (
prefix !== "bx" && <FormListItem
key={prefix}
onClick={() => setFilterByPrefix(prefix)}
icon={icon}
checked={filterByPrefix === prefix}
>{name}</FormListItem>
))}
</>
);
}
async function getIconToCountMap() {
if (!iconToCountCache) {
iconToCountCache = server.get<IconToCountCache>("other/icon-usage");
setTimeout(() => (iconToCountCache = null), 20000); // invalidate cache after 20 seconds
}
return (await iconToCountCache).iconClassToCountMap;
}
function getIconLabels(note: FNote) {
if (!note) {
return [];
}
return note.getOwnedLabels()
.filter((label) => ["workspaceIconClass", "iconClass"]
.includes(label.name));
}