mirror of
https://github.com/zadam/trilium.git
synced 2025-11-01 02:45:54 +01:00
feat(react/dialog): port note_type_chooser
This commit is contained in:
@@ -234,7 +234,7 @@
|
||||
"modal_title": "选择笔记类型",
|
||||
"close": "关闭",
|
||||
"modal_body": "选择新笔记的类型或模板:",
|
||||
"templates": "模板:"
|
||||
"templates": "模板"
|
||||
},
|
||||
"password_not_set": {
|
||||
"title": "密码未设置",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -213,7 +213,7 @@
|
||||
"note_type_chooser": {
|
||||
"modal_title": "選擇筆記類型",
|
||||
"modal_body": "選擇新筆記的類型或模板:",
|
||||
"templates": "模板:"
|
||||
"templates": "模板"
|
||||
},
|
||||
"password_not_set": {
|
||||
"title": "密碼未設定",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
125
apps/client/src/widgets/dialogs/note_type_chooser.tsx
Normal file
125
apps/client/src/widgets/dialogs/note_type_chooser.tsx
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
44
apps/client/src/widgets/react/Dropdown.tsx
Normal file
44
apps/client/src/widgets/react/Dropdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
49
apps/client/src/widgets/react/FormList.tsx
Normal file
49
apps/client/src/widgets/react/FormList.tsx
Normal 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} />
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormListHeaderOpts {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function FormListHeader({ text }: FormListHeaderOpts) {
|
||||
return (
|
||||
<li>
|
||||
<h6 className="dropdown-header">{text}</h6>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
7
apps/client/src/widgets/react/Icon.tsx
Normal file
7
apps/client/src/widgets/react/Icon.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
interface IconProps {
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export default function Icon({ icon }: IconProps) {
|
||||
return <span class={icon ?? "bx bx-empty"}></span>
|
||||
}
|
||||
Reference in New Issue
Block a user