feat(react/dialog): port note_type_chooser

This commit is contained in:
Elian Doran
2025-08-06 11:39:31 +03:00
parent 2a40d6bb7e
commit 33e3112290
14 changed files with 238 additions and 214 deletions

View File

@@ -234,7 +234,7 @@
"modal_title": "选择笔记类型",
"close": "关闭",
"modal_body": "选择新笔记的类型或模板:",
"templates": "模板:"
"templates": "模板"
},
"password_not_set": {
"title": "密码未设置",

View File

@@ -233,7 +233,7 @@
"modal_title": "Wähle den Notiztyp aus",
"close": "Schließen",
"modal_body": "Wähle den Notiztyp / die Vorlage der neuen Notiz:",
"templates": "Vorlagen:"
"templates": "Vorlagen"
},
"password_not_set": {
"title": "Das Passwort ist nicht festgelegt",

View File

@@ -238,7 +238,8 @@
"modal_title": "Choose note type",
"close": "Close",
"modal_body": "Choose note type / template of the new note:",
"templates": "Templates:"
"templates": "Templates",
"builtin_templates": "Built-in Templates"
},
"password_not_set": {
"title": "Password is not set",

View File

@@ -236,7 +236,7 @@
"modal_title": "Elija el tipo de nota",
"close": "Cerrar",
"modal_body": "Elija el tipo de nota/plantilla de la nueva nota:",
"templates": "Plantillas:"
"templates": "Plantillas"
},
"password_not_set": {
"title": "La contraseña no está establecida",

View File

@@ -233,7 +233,7 @@
"modal_title": "Choisissez le type de note",
"close": "Fermer",
"modal_body": "Choisissez le type de note/le modèle de la nouvelle note :",
"templates": "Modèles :"
"templates": "Modèles"
},
"password_not_set": {
"title": "Le mot de passe n'est pas défini",

View File

@@ -896,7 +896,7 @@
"note_type_chooser": {
"modal_body": "Selectați tipul notiței/șablonul pentru noua notiță:",
"modal_title": "Selectați tipul notiței",
"templates": "Șabloane:",
"templates": "Șabloane",
"close": "Închide",
"change_path_prompt": "Selectați locul unde să se creeze noua notiță:",
"search_placeholder": "căutare cale notiță după nume (cea implicită dacă este necompletat)"

View File

@@ -238,7 +238,7 @@
"modal_title": "Izaberite tip beleške",
"close": "Zatvori",
"modal_body": "Izaberite tip beleške / šablon za novu belešku:",
"templates": "Šabloni:"
"templates": "Šabloni"
},
"password_not_set": {
"title": "Lozinka nije podešena",

View File

@@ -213,7 +213,7 @@
"note_type_chooser": {
"modal_title": "選擇筆記類型",
"modal_body": "選擇新筆記的類型或模板:",
"templates": "模板"
"templates": "模板"
},
"password_not_set": {
"title": "密碼未設定",

View File

@@ -1,204 +0,0 @@
import type { CommandNames } from "../../components/app_context.js";
import type { MenuCommandItem } from "../../menus/context_menu.js";
import { t } from "../../services/i18n.js";
import noteTypesService from "../../services/note_types.js";
import noteAutocompleteService from "../../services/note_autocomplete.js";
import BasicWidget from "../basic_widget.js";
import { Dropdown, Modal } from "bootstrap";
const TPL = /*html*/`
<div class="note-type-chooser-dialog modal mx-auto" tabindex="-1" role="dialog">
<style>
.note-type-chooser-dialog {
/* note type chooser needs to be higher than other dialogs from which it is triggered, e.g. "add link"*/
z-index: 1100 !important;
}
.note-type-chooser-dialog .input-group {
margin-top: 15px;
margin-bottom: 15px;
}
.note-type-chooser-dialog .note-type-dropdown {
position: relative;
font-size: large;
padding: 20px;
width: 100%;
margin-top: 15px;
max-height: 80vh;
overflow: auto;
}
</style>
<div class="modal-dialog" style="max-width: 500px;" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${t("note_type_chooser.modal_title")}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("note_type_chooser.close")}"></button>
</div>
<div class="modal-body">
${t("note_type_chooser.change_path_prompt")}
<div class="input-group">
<input class="choose-note-path form-control" placeholder="${t("note_type_chooser.search_placeholder")}">
</div>
${t("note_type_chooser.modal_body")}
<div class="dropdown" style="display: flex;">
<button class="note-type-dropdown-trigger" type="button" style="display: none;"
data-bs-toggle="dropdown" data-bs-display="static">
</button>
<div class="note-type-dropdown dropdown-menu static"></div>
</div>
</div>
</div>
</div>
</div>`;
export interface ChooseNoteTypeResponse {
success: boolean;
noteType?: string;
templateNoteId?: string;
notePath?: string;
}
type Callback = (data: ChooseNoteTypeResponse) => void;
export default class NoteTypeChooserDialog extends BasicWidget {
private resolve: Callback | null;
private dropdown!: Dropdown;
private modal!: Modal;
private $noteTypeDropdown!: JQuery<HTMLElement>;
private $autoComplete!: JQuery<HTMLElement>;
private $originalFocused: JQuery<HTMLElement> | null;
private $originalDialog: JQuery<HTMLElement> | null;
constructor() {
super();
this.resolve = null;
this.$originalFocused = null; // element focused before the dialog was opened, so we can return to it afterward
this.$originalDialog = null;
}
doRender() {
this.$widget = $(TPL);
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
this.$autoComplete = this.$widget.find(".choose-note-path");
this.$noteTypeDropdown = this.$widget.find(".note-type-dropdown");
this.dropdown = Dropdown.getOrCreateInstance(this.$widget.find(".note-type-dropdown-trigger")[0]);
this.$widget.on("hidden.bs.modal", () => {
if (this.resolve) {
this.resolve({ success: false });
}
if (this.$originalFocused) {
this.$originalFocused.trigger("focus");
this.$originalFocused = null;
}
glob.activeDialog = this.$originalDialog;
});
this.$noteTypeDropdown.on("click", ".dropdown-item", (e) => this.doResolve(e));
this.$noteTypeDropdown.on("focus", ".dropdown-item", (e) => {
this.$noteTypeDropdown.find(".dropdown-item").each((i, el) => {
$(el).toggleClass("active", el === e.target);
});
});
this.$noteTypeDropdown.on("keydown", ".dropdown-item", (e) => {
if (e.key === "Enter") {
this.doResolve(e);
e.preventDefault();
return false;
}
});
this.$noteTypeDropdown.parent().on("hide.bs.dropdown", (e) => {
// prevent closing dropdown by clicking outside
// TODO: Check if this actually works.
//@ts-ignore
if (e.clickEvent) {
e.preventDefault();
} else {
this.modal.hide();
}
});
}
async refresh() {
noteAutocompleteService
.initNoteAutocomplete(this.$autoComplete, {
allowCreatingNotes: false,
hideGoToSelectedNoteButton: true,
allowJumpToSearchNotes: false,
})
}
async chooseNoteTypeEvent({ callback }: { callback: Callback }) {
this.$originalFocused = $(":focus");
await this.refresh();
const noteTypes = await noteTypesService.getNoteTypeItems();
this.$noteTypeDropdown.empty();
for (const noteType of noteTypes) {
if (noteType.title === "----") {
this.$noteTypeDropdown.append($('<h6 class="dropdown-header">').append(t("note_type_chooser.templates")));
} else {
const commandItem = noteType as MenuCommandItem<CommandNames>;
const listItem = $('<a class="dropdown-item" tabindex="0">')
.attr("data-note-type", commandItem.type || "")
.attr("data-template-note-id", commandItem.templateNoteId || "")
.append($("<span>").addClass(commandItem.uiIcon || ""))
.append(` ${noteType.title}`);
if (commandItem.badges) {
for (let badge of commandItem.badges) {
listItem.append($(`<span class="badge">`)
.addClass(badge.className || "")
.text(badge.title));
}
}
this.$noteTypeDropdown.append(listItem);
}
}
this.dropdown.show();
this.$originalDialog = glob.activeDialog;
glob.activeDialog = this.$widget;
this.modal.show();
this.$noteTypeDropdown.find(".dropdown-item:first").focus();
this.resolve = callback;
}
doResolve(e: JQuery.KeyDownEvent | JQuery.ClickEvent) {
const $item = $(e.target).closest(".dropdown-item");
const noteType = $item.attr("data-note-type");
const templateNoteId = $item.attr("data-template-note-id");
const notePath = this.$autoComplete.getSelectedNotePath() || undefined;
if (this.resolve) {
this.resolve({
success: true,
noteType,
templateNoteId,
notePath
});
}
this.resolve = null;
this.modal.hide();
}
}

View File

@@ -0,0 +1,125 @@
import ReactBasicWidget from "../react/ReactBasicWidget";
import Modal from "../react/Modal";
import { closeActiveDialog, openDialog } from "../../services/dialog";
import { t } from "../../services/i18n";
import FormGroup from "../react/FormGroup";
import NoteAutocomplete from "../react/NoteAutocomplete";
import FormList, { FormListHeader, FormListItem } from "../react/FormList";
import { useEffect, useState } from "preact/hooks";
import note_types from "../../services/note_types";
import { MenuCommandItem, MenuItem } from "../../menus/context_menu";
import { TreeCommandNames } from "../../menus/tree_context_menu";
import { Suggestion } from "../../services/note_autocomplete";
export interface ChooseNoteTypeResponse {
success: boolean;
noteType?: string;
templateNoteId?: string;
notePath?: string;
}
type Callback = (data: ChooseNoteTypeResponse) => void;
const SEPARATOR_TITLE_REPLACEMENTS = [
t("note_type_chooser.builtin_templates"),
t("note_type_chooser.templates")
];
interface NoteTypeChooserDialogProps {
callback?: Callback;
}
function NoteTypeChooserDialogComponent({ callback }: NoteTypeChooserDialogProps) {
const [ parentNote, setParentNote ] = useState<Suggestion>();
const [ noteTypes, setNoteTypes ] = useState<MenuItem<TreeCommandNames>[]>([]);
if (!noteTypes.length) {
useEffect(() => {
note_types.getNoteTypeItems().then(noteTypes => {
let index = -1;
setNoteTypes((noteTypes ?? []).map((item, _index) => {
if (item.title === "----") {
index++;
return {
title: SEPARATOR_TITLE_REPLACEMENTS[index],
enabled: false
}
}
return item;
}));
});
});
}
function onNoteTypeSelected(value: string) {
const [ noteType, templateNoteId ] = value.split(",");
callback?.({
success: true,
noteType,
templateNoteId,
notePath: parentNote?.notePath
});
closeActiveDialog();
}
return (
<Modal
title={t("note_type_chooser.modal_title")}
className="note-type-chooser-dialog"
size="md"
zIndex={1100} // note type chooser needs to be higher than other dialogs from which it is triggered, e.g. "add link"
scrollable
onHidden={() => callback?.({ success: false })}
>
<FormGroup label={t("note_type_chooser.change_path_prompt")}>
<NoteAutocomplete
onChange={setParentNote}
placeholder={t("note_type_chooser.search_placeholder")}
opts={{
allowCreatingNotes: false,
hideGoToSelectedNoteButton: true,
allowJumpToSearchNotes: false,
}}
/>
</FormGroup>
<FormGroup label={t("note_type_chooser.modal_body")}>
<FormList onSelect={onNoteTypeSelected}>
{noteTypes.map((_item) => {
if (_item.title === "----") {
}
const item = _item as MenuCommandItem<TreeCommandNames>;
if (item.enabled === false) {
return <FormListHeader text={item.title} />
} else {
return <FormListItem
value={[ item.type, item.templateNoteId ].join(",") }
icon={item.uiIcon}
text={item.title} />;
}
})}
</FormList>
</FormGroup>
</Modal>
);
}
export default class NoteTypeChooserDialog extends ReactBasicWidget {
private props: NoteTypeChooserDialogProps = {};
get component() {
return <NoteTypeChooserDialogComponent {...this.props} />
}
async chooseNoteTypeEvent({ callback }: { callback: Callback }) {
this.props = { callback };
this.doRender();
openDialog(this.$widget);
}
}

View File

@@ -0,0 +1,44 @@
import { Dropdown as BootstrapDropdown } from "bootstrap";
import { ComponentChildren } from "preact";
import { useEffect, useRef } from "preact/hooks";
interface DropdownProps {
className?: string;
isStatic?: boolean;
children: ComponentChildren;
}
export default function Dropdown({ className, isStatic, children }: DropdownProps) {
const dropdownRef = useRef<HTMLDivElement>();
const triggerRef = useRef<HTMLButtonElement>();
if (triggerRef?.current) {
useEffect(() => {
const dropdown = BootstrapDropdown.getOrCreateInstance(triggerRef.current!);
return () => dropdown.dispose();
});
}
if (dropdownRef?.current) {
useEffect(() => {
$(dropdownRef.current!).on("hide.bs.dropdown", () => {
console.log("Hide");
});
});
}
return (
<div ref={dropdownRef} class="dropdown" style={{ display: "flex" }}>
<button
ref={triggerRef}
type="button"
style={{ display: "none" }}
data-bs-toggle="dropdown"
data-bs-display={ isStatic ? "static" : undefined } />
<div class={`dropdown-menu ${className ?? ""} ${isStatic ? "static" : undefined}`}>
{children}
</div>
</div>
)
}

View File

@@ -11,9 +11,11 @@ interface FormGroupProps {
export default function FormGroup({ label, title, className, children, description, labelRef }: FormGroupProps) {
return (
<div className={`form-group ${className}`} title={title}>
<div className={`form-group ${className}`} title={title}
style={{ "margin-bottom": "15px" }}>
<label style={{ width: "100%" }} ref={labelRef}>
{label} {children}
<div style={{ "margin-bottom": "10px" }}>{label}</div>
{children}
</label>
{description && <small className="form-text">{description}</small>}

View File

@@ -0,0 +1,49 @@
import { ComponentChildren } from "preact";
import Icon from "./Icon";
interface FormListOpts {
children: ComponentChildren;
onSelect?: (value: string) => void;
}
export default function FormList({ children, onSelect }: FormListOpts) {
return (
<div class="dropdown-menu static show" style={{
position: "relative"
}} onClick={(e) => {
const value = (e.target as HTMLElement)?.dataset?.value;
if (value && onSelect) {
onSelect(value);
}
}}>
{children}
</div>
);
}
interface FormListItemOpts {
text: string;
icon?: string;
value?: string;
}
export function FormListItem({ text, icon, value }: FormListItemOpts) {
return (
<a class="dropdown-item" data-value={value}>
<Icon icon={icon} />&nbsp;
{text}
</a>
);
}
interface FormListHeaderOpts {
text: string;
}
export function FormListHeader({ text }: FormListHeaderOpts) {
return (
<li>
<h6 className="dropdown-header">{text}</h6>
</li>
)
}

View File

@@ -0,0 +1,7 @@
interface IconProps {
icon?: string;
}
export default function Icon({ icon }: IconProps) {
return <span class={icon ?? "bx bx-empty"}></span>
}