feat(react): port note icon

This commit is contained in:
Elian Doran 2025-08-21 14:41:59 +03:00
parent 009fd63ce9
commit aa608510d0
No known key found for this signature in database
7 changed files with 185 additions and 112 deletions

View File

@ -18,7 +18,7 @@ import SqlTableSchemasWidget from "../widgets/sql_table_schemas.js";
import FilePropertiesWidget from "../widgets/ribbon_widgets/file_properties.js"; import FilePropertiesWidget from "../widgets/ribbon_widgets/file_properties.js";
import ImagePropertiesWidget from "../widgets/ribbon_widgets/image_properties.js"; import ImagePropertiesWidget from "../widgets/ribbon_widgets/image_properties.js";
import NotePropertiesWidget from "../widgets/ribbon_widgets/note_properties.js"; import NotePropertiesWidget from "../widgets/ribbon_widgets/note_properties.js";
import NoteIconWidget from "../widgets/note_icon.js"; import NoteIconWidget from "../widgets/note_icon.jsx";
import SearchResultWidget from "../widgets/search_result.js"; import SearchResultWidget from "../widgets/search_result.js";
import ScrollingContainer from "../widgets/containers/scrolling_container.js"; import ScrollingContainer from "../widgets/containers/scrolling_container.js";
import RootContainer from "../widgets/containers/root_container.js"; import RootContainer from "../widgets/containers/root_container.js";
@ -151,7 +151,7 @@ export default class DesktopLayout {
.css("min-height", "50px") .css("min-height", "50px")
.css("align-items", "center") .css("align-items", "center")
.cssBlock(".title-row > * { margin: 5px; }") .cssBlock(".title-row > * { margin: 5px; }")
.child(new NoteIconWidget()) .child(<NoteIconWidget />)
.child(<NoteTitleWidget />) .child(<NoteTitleWidget />)
.child(new SpacerWidget(0, 1)) .child(new SpacerWidget(0, 1))
.child(new MovePaneButton(true)) .child(new MovePaneButton(true))

View File

@ -24,7 +24,7 @@ import InfoDialog from "../widgets/dialogs/info.js";
import IncorrectCpuArchDialog from "../widgets/dialogs/incorrect_cpu_arch.js"; import IncorrectCpuArchDialog from "../widgets/dialogs/incorrect_cpu_arch.js";
import PopupEditorDialog from "../widgets/dialogs/popup_editor.js"; import PopupEditorDialog from "../widgets/dialogs/popup_editor.js";
import FlexContainer from "../widgets/containers/flex_container.js"; import FlexContainer from "../widgets/containers/flex_container.js";
import NoteIconWidget from "../widgets/note_icon.js"; import NoteIconWidget from "../widgets/note_icon";
import ClassicEditorToolbar from "../widgets/ribbon_widgets/classic_editor_toolbar.js"; import ClassicEditorToolbar from "../widgets/ribbon_widgets/classic_editor_toolbar.js";
import PromotedAttributesWidget from "../widgets/ribbon_widgets/promoted_attributes.js"; import PromotedAttributesWidget from "../widgets/ribbon_widgets/promoted_attributes.js";
import NoteDetailWidget from "../widgets/note_detail.js"; import NoteDetailWidget from "../widgets/note_detail.js";
@ -61,7 +61,7 @@ export function applyModals(rootContainer: RootContainer) {
.class("title-row") .class("title-row")
.css("align-items", "center") .css("align-items", "center")
.cssBlock(".title-row > * { margin: 5px; }") .cssBlock(".title-row > * { margin: 5px; }")
.child(new NoteIconWidget()) .child(<NoteIconWidget />)
.child(<NoteTitleWidget />)) .child(<NoteTitleWidget />))
.child(new ClassicEditorToolbar()) .child(new ClassicEditorToolbar())
.child(new PromotedAttributesWidget()) .child(new PromotedAttributesWidget())

View File

@ -1,6 +1,6 @@
// taken from the HTML source of https://boxicons.com/ // taken from the HTML source of https://boxicons.com/
interface Category { export interface Category {
name: string; name: string;
id: number; id: number;
} }

View File

@ -0,0 +1,59 @@
.note-icon-widget {
padding-top: 3px;
padding-left: 7px;
margin-right: 0;
width: 50px;
height: 50px;
}
.note-icon-widget button.note-icon {
font-size: 180%;
background-color: transparent;
border: 1px solid transparent;
cursor: pointer;
padding: 6px;
color: var(--main-text-color);
}
.note-icon-widget button.note-icon:hover {
border: 1px solid var(--main-border-color);
}
.note-icon-widget .dropdown-menu {
border-radius: 10px;
border-width: 2px;
box-shadow: 10px 10px 93px -25px black;
padding: 10px 15px 10px 15px !important;
}
.note-icon-widget .filter-row {
padding-top: 10px;
padding-bottom: 10px;
padding-right: 20px;
display: flex;
align-items: baseline;
}
.note-icon-widget .filter-row span {
display: block;
padding-left: 15px;
padding-right: 15px;
font-weight: bold;
}
.note-icon-widget .icon-list {
height: 500px;
overflow: auto;
}
.note-icon-widget .icon-list span {
display: inline-block;
padding: 10px;
cursor: pointer;
border: 1px solid transparent;
font-size: 180%;
}
.note-icon-widget .icon-list span:hover {
border: 1px solid var(--main-border-color);
}

View File

@ -7,86 +7,6 @@ import type { EventData } from "../components/app_context.js";
import type { Icon } from "./icon_list.js"; import type { Icon } from "./icon_list.js";
import { Dropdown } from "bootstrap"; import { Dropdown } from "bootstrap";
const TPL = /*html*/`
<div class="note-icon-widget dropdown">
<style>
.note-icon-widget {
padding-top: 3px;
padding-left: 7px;
margin-right: 0;
width: 50px;
height: 50px;
}
.note-icon-widget button.note-icon {
font-size: 180%;
background-color: transparent;
border: 1px solid transparent;
cursor: pointer;
padding: 6px;
color: var(--main-text-color);
}
.note-icon-widget button.note-icon:hover {
border: 1px solid var(--main-border-color);
}
.note-icon-widget .dropdown-menu {
border-radius: 10px;
border-width: 2px;
box-shadow: 10px 10px 93px -25px black;
padding: 10px 15px 10px 15px !important;
}
.note-icon-widget .filter-row {
padding-top: 10px;
padding-bottom: 10px;
padding-right: 20px;
display: flex;
align-items: baseline;
}
.note-icon-widget .filter-row span {
display: block;
padding-left: 15px;
padding-right: 15px;
font-weight: bold;
}
.note-icon-widget .icon-list {
height: 500px;
overflow: auto;
}
.note-icon-widget .icon-list span {
display: inline-block;
padding: 10px;
cursor: pointer;
border: 1px solid transparent;
font-size: 180%;
}
.note-icon-widget .icon-list span:hover {
border: 1px solid var(--main-border-color);
}
</style>
<button class="btn dropdown-toggle note-icon" type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="${t("note_icon.change_note_icon")}"></button>
<div class="dropdown-menu" aria-labelledby="note-path-list-button" style="width: 610px;">
<div class="filter-row">
<span>${t("note_icon.category")}</span> <select name="icon-category" class="form-select"></select>
<span>${t("note_icon.search")}</span> <input type="text" name="icon-search" class="form-control" />
</div>
<div class="icon-list"></div>
</div>
</div>`;
interface IconToCountCache {
iconClassToCountMap: Record<string, number>;
}
export default class NoteIconWidget extends NoteContextAwareWidget { export default class NoteIconWidget extends NoteContextAwareWidget {
private dropdown!: bootstrap.Dropdown; private dropdown!: bootstrap.Dropdown;
@ -94,12 +14,8 @@ export default class NoteIconWidget extends NoteContextAwareWidget {
private $iconList!: JQuery<HTMLElement>; private $iconList!: JQuery<HTMLElement>;
private $iconCategory!: JQuery<HTMLElement>; private $iconCategory!: JQuery<HTMLElement>;
private $iconSearch!: JQuery<HTMLElement>; private $iconSearch!: JQuery<HTMLElement>;
private iconToCountCache!: Promise<IconToCountCache | null> | null;
doRender() { doRender() {
this.$widget = $(TPL);
this.dropdown = Dropdown.getOrCreateInstance(this.$widget.find("[data-bs-toggle='dropdown']")[0]);
this.$icon = this.$widget.find("button.note-icon"); this.$icon = this.$widget.find("button.note-icon");
this.$iconList = this.$widget.find(".icon-list"); this.$iconList = this.$widget.find(".icon-list");
this.$iconList.on("click", "span", async (e) => { this.$iconList.on("click", "span", async (e) => {
@ -153,9 +69,6 @@ export default class NoteIconWidget extends NoteContextAwareWidget {
} }
async renderDropdown() { async renderDropdown() {
const iconToCount = await this.getIconToCountMap();
const { icons } = (await import("./icon_list.js")).default;
this.$iconList.empty(); this.$iconList.empty();
if (this.getIconLabels().length > 0) { if (this.getIconLabels().length > 0) {
@ -205,21 +118,6 @@ export default class NoteIconWidget extends NoteContextAwareWidget {
this.$iconSearch.focus(); this.$iconSearch.focus();
} }
async getIconToCountMap() {
if (!this.iconToCountCache) {
this.iconToCountCache = server.get<IconToCountCache>("other/icon-usage");
setTimeout(() => (this.iconToCountCache = null), 20000); // invalidate cache after 20 seconds
}
return (await this.iconToCountCache)?.iconClassToCountMap;
}
renderIcon(icon: Icon) {
return $("<span>")
.addClass("bx " + icon.className)
.attr("title", icon.name);
}
getIconLabels() { getIconLabels() {
if (!this.note) { if (!this.note) {
return []; return [];

View File

@ -0,0 +1,99 @@
import Dropdown from "./react/Dropdown";
import "./note_icon.css";
import { t } from "i18next";
import { useNoteContext } from "./react/hooks";
import { useCallback, useEffect, useState } from "preact/hooks";
import server from "../services/server";
import type { Category, Icon } from "./icon_list";
import FormTextBox from "./react/FormTextBox";
interface IconToCountCache {
iconClassToCountMap: Record<string, number>;
}
interface IconData {
iconToCount: Record<string, number>;
icons: Icon[];
}
let fullIconData: {
categories: Category[];
icons: Icon[];
};
let iconToCountCache!: Promise<IconToCountCache> | null;
export default function NoteIcon() {
const { note } = useNoteContext();
const [ icon, setIcon ] = useState("bx bx-empty");
const refreshIcon = useCallback(() => {
if (note) {
setIcon(note.getIcon());
}
}, [ note ]);
useEffect(refreshIcon, [ note ]);
return (
<Dropdown
className="note-icon-widget"
title={t("note_icon.change_note_icon")}
dropdownContainerStyle={{ width: "610px" }}
buttonClassName={`note-icon ${icon}`}
hideToggleArrow
>
<NoteIconList />
</Dropdown>
)
}
function NoteIconList() {
const [ filter, setFilter ] = useState<string>();
const [ iconData, setIconData ] = useState<IconData>();
useEffect(() => {
async function loadIcons() {
const iconToCount = await getIconToCountMap();
if (!fullIconData) {
fullIconData = (await import("./icon_list.js")).default;
}
setIconData({
iconToCount,
icons: fullIconData.icons
})
}
loadIcons();
}, []);
return (
<>
<div class="filter-row">
<span>{t("note_icon.category")}</span> <select name="icon-category" class="form-select"></select>
<span>{t("note_icon.search")}</span>
<FormTextBox
type="text"
name="icon-search"
currentValue={filter} onChange={setFilter}
/>
</div>
<div class="icon-list">
{(iconData?.icons ?? []).map(({className, name}) => (
<span class={`bx ${className}`} title={name} />
))}
</div>
</>
);
}
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;
}

View File

@ -1,14 +1,20 @@
import { Dropdown as BootstrapDropdown } from "bootstrap"; import { Dropdown as BootstrapDropdown } from "bootstrap";
import { ComponentChildren } from "preact"; import { ComponentChildren } from "preact";
import { CSSProperties } from "preact/compat";
import { useEffect, useRef } from "preact/hooks"; import { useEffect, useRef } from "preact/hooks";
import { useUniqueName } from "./hooks";
interface DropdownProps { interface DropdownProps {
className?: string; className?: string;
buttonClassName?: string;
isStatic?: boolean; isStatic?: boolean;
children: ComponentChildren; children: ComponentChildren;
title?: string;
dropdownContainerStyle?: CSSProperties;
hideToggleArrow?: boolean;
} }
export default function Dropdown({ className, isStatic, children }: DropdownProps) { export default function Dropdown({ className, buttonClassName, isStatic, children, title, dropdownContainerStyle, hideToggleArrow }: DropdownProps) {
const dropdownRef = useRef<HTMLDivElement | null>(null); const dropdownRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null); const triggerRef = useRef<HTMLButtonElement | null>(null);
@ -35,16 +41,27 @@ export default function Dropdown({ className, isStatic, children }: DropdownProp
}; };
}, []); // Add dependency array }, []); // Add dependency array
const ariaId = useUniqueName("button");
return ( return (
<div ref={dropdownRef} class="dropdown" style={{ display: "flex" }}> <div ref={dropdownRef} class={`dropdown ${className ?? ""}`} style={{ display: "flex" }}>
<button <button
className={`btn ${buttonClassName ?? ""} ${!hideToggleArrow ? "dropdown-toggle" : ""}`}
ref={triggerRef} ref={triggerRef}
type="button" type="button"
style={{ display: "none" }}
data-bs-toggle="dropdown" data-bs-toggle="dropdown"
data-bs-display={ isStatic ? "static" : undefined } /> data-bs-display={ isStatic ? "static" : undefined }
aria-haspopup="true"
aria-expanded="false"
title={title}
id={ariaId}
/>
<div class={`dropdown-menu ${className ?? ""} ${isStatic ? "static" : undefined}`}> <div
class={`dropdown-menu ${isStatic ? "static" : undefined}`}
style={dropdownContainerStyle}
aria-labelledby={ariaId}
>
{children} {children}
</div> </div>
</div> </div>