Note Type Badges (#6229)

This commit is contained in:
Elian Doran 2025-07-06 09:54:27 +03:00 committed by GitHub
commit 8efef6842d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 235 additions and 105 deletions

View file

@ -17,11 +17,17 @@ interface MenuSeparatorItem {
title: "----";
}
export interface MenuItemBadge {
title: string;
className?: string;
}
export interface MenuCommandItem<T> {
title: string;
command?: T;
type?: string;
uiIcon?: string;
badges?: MenuItemBadge[];
templateNoteId?: string;
enabled?: boolean;
handler?: MenuHandler<T>;
@ -161,6 +167,18 @@ class ContextMenu {
.append(" &nbsp; ") // some space between icon and text
.append(item.title);
if ("badges" in item && item.badges) {
for (let badge of item.badges) {
const badgeElement = $(`<span class="badge">`).text(badge.title);
if (badge.className) {
badgeElement.addClass(badge.className);
}
$link.append(badgeElement);
}
}
if ("shortcut" in item && item.shortcut) {
$link.append($("<kbd>").text(item.shortcut));
}

View file

@ -1,42 +1,87 @@
import server from "./server.js";
import froca from "./froca.js";
import { t } from "./i18n.js";
import type { MenuItem } from "../menus/context_menu.js";
import froca from "./froca.js";
import server from "./server.js";
import type { MenuCommandItem, MenuItem, MenuItemBadge } from "../menus/context_menu.js";
import type { NoteType } from "../entities/fnote.js";
import type { TreeCommandNames } from "../menus/tree_context_menu.js";
export interface NoteTypeMapping {
type: NoteType;
mime?: string;
title: string;
icon?: string;
/** Indicates whether this type should be marked as a newly introduced feature. */
isNew?: boolean;
/** Indicates that this note type is part of a beta feature. */
isBeta?: boolean;
/** Indicates that this note type cannot be created by the user. */
reserved?: boolean;
/** Indicates that once a note of this type is created, its type can no longer be changed. */
static?: boolean;
}
export const NOTE_TYPES: NoteTypeMapping[] = [
// The suggested note type ordering method: insert the item into the corresponding group,
// then ensure the items within the group are ordered alphabetically.
// The default note type (always the first item)
{ type: "text", mime: "text/html", title: t("note_types.text"), icon: "bx-note" },
// Text notes group
{ type: "book", mime: "", title: t("note_types.book"), icon: "bx-book" },
// Graphic notes
{ type: "canvas", mime: "application/json", title: t("note_types.canvas"), icon: "bx-pen" },
{ type: "mermaid", mime: "text/mermaid", title: t("note_types.mermaid-diagram"), icon: "bx-selection" },
// Map notes
{ type: "geoMap", mime: "application/json", title: t("note_types.geo-map"), icon: "bx-map-alt", isBeta: true },
{ type: "mindMap", mime: "application/json", title: t("note_types.mind-map"), icon: "bx-sitemap" },
{ type: "noteMap", mime: "", title: t("note_types.note-map"), icon: "bxs-network-chart", static: true },
{ type: "relationMap", mime: "application/json", title: t("note_types.relation-map"), icon: "bxs-network-chart" },
// Misc note types
{ type: "render", mime: "", title: t("note_types.render-note"), icon: "bx-extension" },
{ type: "search", title: t("note_types.saved-search"), icon: "bx-file-find", static: true },
{ type: "webView", mime: "", title: t("note_types.web-view"), icon: "bx-globe-alt" },
// Code notes
{ type: "code", mime: "text/plain", title: t("note_types.code"), icon: "bx-code" },
// Reserved types (cannot be created by the user)
{ type: "contentWidget", mime: "", title: t("note_types.widget"), reserved: true },
{ type: "doc", mime: "", title: t("note_types.doc"), reserved: true },
{ type: "file", title: t("note_types.file"), reserved: true },
{ type: "image", title: t("note_types.image"), reserved: true },
{ type: "launcher", mime: "", title: t("note_types.launcher"), reserved: true },
{ type: "aiChat", mime: "application/json", title: t("note_types.ai-chat"), reserved: true }
];
/** The maximum age in days for a template to be marked with the "New" badge */
const NEW_TEMPLATE_MAX_AGE = 3;
/** The length of a day in milliseconds. */
const DAY_LENGTH = 1000 * 60 * 60 * 24;
/** The menu item badge used to mark new note types and templates */
const NEW_BADGE: MenuItemBadge = {
title: t("note_types.new-feature"),
className: "new-note-type-badge"
};
/** The menu item badge used to mark note types that are part of a beta feature */
const BETA_BADGE = {
title: t("note_types.beta-feature")
};
const SEPARATOR = { title: "----" };
const creationDateCache = new Map<string, Date>();
let rootCreationDate: Date | undefined;
async function getNoteTypeItems(command?: TreeCommandNames) {
const items: MenuItem<TreeCommandNames>[] = [
// The suggested note type ordering method: insert the item into the corresponding group,
// then ensure the items within the group are ordered alphabetically.
// Please keep the order synced with the listing found also in aps/client/src/widgets/note_types.ts.
// The default note type (always the first item)
{ title: t("note_types.text"), command, type: "text", uiIcon: "bx bx-note" },
// Text notes group
{ title: t("note_types.book"), command, type: "book", uiIcon: "bx bx-book" },
// Graphic notes
{ title: t("note_types.canvas"), command, type: "canvas", uiIcon: "bx bx-pen" },
{ title: t("note_types.mermaid-diagram"), command, type: "mermaid", uiIcon: "bx bx-selection" },
// Map notes
{ title: t("note_types.geo-map"), command, type: "geoMap", uiIcon: "bx bx-map-alt" },
{ title: t("note_types.mind-map"), command, type: "mindMap", uiIcon: "bx bx-sitemap" },
{ title: t("note_types.note-map"), command, type: "noteMap", uiIcon: "bx bxs-network-chart" },
{ title: t("note_types.relation-map"), command, type: "relationMap", uiIcon: "bx bxs-network-chart" },
// Misc note types
{ title: t("note_types.render-note"), command, type: "render", uiIcon: "bx bx-extension" },
{ title: t("note_types.saved-search"), command, type: "search", uiIcon: "bx bx-file-find" },
{ title: t("note_types.web-view"), command, type: "webView", uiIcon: "bx bx-globe-alt" },
// Code notes
{ title: t("note_types.code"), command, type: "code", uiIcon: "bx bx-code" },
// Templates
...getBlankNoteTypes(command),
...await getBuiltInTemplates(command),
...await getUserTemplates(command)
];
@ -44,6 +89,28 @@ async function getNoteTypeItems(command?: TreeCommandNames) {
return items;
}
function getBlankNoteTypes(command): MenuItem<TreeCommandNames>[] {
return NOTE_TYPES.filter((nt) => !nt.reserved).map((nt) => {
const menuItem: MenuCommandItem<TreeCommandNames> = {
title: nt.title,
command,
type: nt.type,
uiIcon: "bx " + nt.icon,
badges: []
}
if (nt.isNew) {
menuItem.badges?.push(NEW_BADGE);
}
if (nt.isBeta) {
menuItem.badges?.push(BETA_BADGE);
}
return menuItem;
});
}
async function getUserTemplates(command?: TreeCommandNames) {
const templateNoteIds = await server.get<string[]>("search-templates");
const templateNotes = await froca.getNotes(templateNoteIds);
@ -54,14 +121,21 @@ async function getUserTemplates(command?: TreeCommandNames) {
const items: MenuItem<TreeCommandNames>[] = [
SEPARATOR
];
for (const templateNote of templateNotes) {
items.push({
const item: MenuItem<TreeCommandNames> = {
title: templateNote.title,
uiIcon: templateNote.getIcon(),
command: command,
type: templateNote.type,
templateNoteId: templateNote.noteId
});
};
if (await isNewTemplate(templateNote.noteId)) {
item.badges = [NEW_BADGE];
}
items.push(item);
}
return items;
}
@ -81,18 +155,71 @@ async function getBuiltInTemplates(command?: TreeCommandNames) {
const items: MenuItem<TreeCommandNames>[] = [
SEPARATOR
];
for (const templateNote of childNotes) {
items.push({
const item: MenuItem<TreeCommandNames> = {
title: templateNote.title,
uiIcon: templateNote.getIcon(),
command: command,
type: templateNote.type,
templateNoteId: templateNote.noteId
});
};
if (await isNewTemplate(templateNote.noteId)) {
item.badges = [NEW_BADGE];
}
items.push(item);
}
return items;
}
async function isNewTemplate(templateNoteId) {
if (rootCreationDate === undefined) {
// Retrieve the root note creation date
try {
let rootNoteInfo: any = await server.get("notes/root");
if ("dateCreated" in rootNoteInfo) {
rootCreationDate = new Date(rootNoteInfo.dateCreated);
}
} catch (ex) {
console.error(ex);
}
}
// Try to retrieve the template's creation date from the cache
let creationDate: Date | undefined = creationDateCache.get(templateNoteId);
if (creationDate === undefined) {
// The creation date isn't available in the cache, try to retrieve it from the server
try {
const noteInfo: any = await server.get("notes/" + templateNoteId);
if ("dateCreated" in noteInfo) {
creationDate = new Date(noteInfo.dateCreated);
creationDateCache.set(templateNoteId, creationDate);
}
} catch (ex) {
console.error(ex);
}
}
if (creationDate) {
if (rootCreationDate && creationDate.getTime() - rootCreationDate.getTime() < 30000) {
// Ignore templates created within 30 seconds after the root note is created.
// This is useful to prevent predefined templates from being marked
// as 'New' after setting up a new database.
return false;
}
// Determine the difference in days between now and the template's creation date
const age = (new Date().getTime() - creationDate.getTime()) / DAY_LENGTH;
// Return true if the template is at most NEW_TEMPLATE_MAX_AGE days old
return (age <= NEW_TEMPLATE_MAX_AGE);
} else {
return false;
}
}
export default {
getNoteTypeItems
};

View file

@ -192,6 +192,13 @@ samp {
font-family: var(--monospace-font-family) !important;
}
.badge {
--bs-badge-color: var(--muted-text-color);
margin-left: 8px;
background: var(--accented-background-color);
}
.input-group-text {
background-color: var(--accented-background-color) !important;
color: var(--muted-text-color) !important;

View file

@ -178,6 +178,9 @@
--alert-bar-background: #6b6b6b3b;
--badge-background-color: #ffffff1a;
--badge-text-color: var(--muted-text-color);
--promoted-attribute-card-background-color: var(--card-background-color);
--promoted-attribute-card-shadow-color: #000000b3;

View file

@ -171,6 +171,9 @@
--alert-bar-background: #32637b29;
--badge-background-color: #00000011;
--badge-text-color: var(--muted-text-color);
--promoted-attribute-card-background-color: var(--card-background-color);
--promoted-attribute-card-shadow-color: #00000033;

View file

@ -171,6 +171,16 @@ html body .dropdown-item[disabled] {
opacity: var(--menu-item-disabled-opacity);
}
/* Badges */
:root .badge {
--bs-badge-color: var(--badge-text-color);
--bs-badge-font-weight: 500;
background: var(--badge-background-color);
text-transform: uppercase;
letter-spacing: .2pt;
}
/* Menu item icon */
.dropdown-item .bx {
transform: translateY(var(--menu-item-icon-vert-offset));

View file

@ -1627,7 +1627,8 @@
"geo-map": "Geo Map",
"beta-feature": "Beta",
"ai-chat": "AI Chat",
"task-list": "Task List"
"task-list": "Task List",
"new-feature": "New"
},
"protect_note": {
"toggle-on": "Protect the note",

View file

@ -154,13 +154,21 @@ export default class NoteTypeChooserDialog extends BasicWidget {
this.$noteTypeDropdown.append($('<h6 class="dropdown-header">').append(t("note_type_chooser.templates")));
} else {
const commandItem = noteType as MenuCommandItem<CommandNames>;
this.$noteTypeDropdown.append(
$('<a class="dropdown-item" tabindex="0">')
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}`)
);
.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);
}
}

View file

@ -1,60 +1,15 @@
import server from "../services/server.js";
import { Dropdown } from "bootstrap";
import { NOTE_TYPES } from "../services/note_types.js";
import { t } from "../services/i18n.js";
import dialogService from "../services/dialog.js";
import mimeTypesService from "../services/mime_types.js";
import NoteContextAwareWidget from "./note_context_aware_widget.js";
import dialogService from "../services/dialog.js";
import { t } from "../services/i18n.js";
import type FNote from "../entities/fnote.js";
import type { NoteType } from "../entities/fnote.js";
import server from "../services/server.js";
import type { EventData } from "../components/app_context.js";
import { Dropdown } from "bootstrap";
import type { NoteType } from "../entities/fnote.js";
import type FNote from "../entities/fnote.js";
interface NoteTypeMapping {
type: NoteType;
mime?: string;
title: string;
isBeta?: boolean;
selectable: boolean;
}
const NOTE_TYPES: NoteTypeMapping[] = [
// The suggested note type ordering method: insert the item into the corresponding group,
// then ensure the items within the group are ordered alphabetically.
// Please keep the order synced with the listing found also in apps/client/src/services/note_types.ts.
// The default note type (always the first item)
{ type: "text", mime: "text/html", title: t("note_types.text"), selectable: true },
// Text notes group
{ type: "book", mime: "", title: t("note_types.book"), selectable: true },
// Graphic notes
{ type: "canvas", mime: "application/json", title: t("note_types.canvas"), selectable: true },
{ type: "mermaid", mime: "text/mermaid", title: t("note_types.mermaid-diagram"), selectable: true },
// Map notes
{ type: "geoMap", mime: "application/json", title: t("note_types.geo-map"), isBeta: true, selectable: true },
{ type: "mindMap", mime: "application/json", title: t("note_types.mind-map"), selectable: true },
{ type: "relationMap", mime: "application/json", title: t("note_types.relation-map"), selectable: true },
// Misc note types
{ type: "render", mime: "", title: t("note_types.render-note"), selectable: true },
{ type: "webView", mime: "", title: t("note_types.web-view"), selectable: true },
// Code notes
{ type: "code", mime: "text/plain", title: t("note_types.code"), selectable: true },
// Reserved types (cannot be created by the user)
{ type: "contentWidget", mime: "", title: t("note_types.widget"), selectable: false },
{ type: "doc", mime: "", title: t("note_types.doc"), selectable: false },
{ type: "file", title: t("note_types.file"), selectable: false },
{ type: "image", title: t("note_types.image"), selectable: false },
{ type: "launcher", mime: "", title: t("note_types.launcher"), selectable: false },
{ type: "noteMap", mime: "", title: t("note_types.note-map"), selectable: false },
{ type: "search", title: t("note_types.saved-search"), selectable: false },
{ type: "aiChat", mime: "application/json", title: t("note_types.ai-chat"), selectable: false }
];
const NOT_SELECTABLE_NOTE_TYPES = NOTE_TYPES.filter((nt) => !nt.selectable).map((nt) => nt.type);
const NOT_SELECTABLE_NOTE_TYPES = NOTE_TYPES.filter((nt) => nt.reserved || nt.static).map((nt) => nt.type);
const TPL = /*html*/`
<div class="dropdown note-type-widget">
@ -64,13 +19,6 @@ const TPL = /*html*/`
overflow-y: auto;
overflow-x: hidden;
}
.note-type-dropdown .badge {
margin-left: 8px;
background: var(--accented-background-color);
font-weight: normal;
color: var(--menu-text-color);
}
</style>
<button type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" class="btn btn-sm dropdown-toggle select-button note-type-button">
<span class="note-type-desc"></span>
@ -117,10 +65,15 @@ export default class NoteTypeWidget extends NoteContextAwareWidget {
return;
}
for (const noteType of NOTE_TYPES.filter((nt) => nt.selectable)) {
for (const noteType of NOTE_TYPES.filter((nt) => !nt.reserved && !nt.static)) {
let $typeLink: JQuery<HTMLElement>;
const $title = $("<span>").text(noteType.title);
if (noteType.isNew) {
$title.append($(`<span class="badge new-note-type-badge">`).text(t("note_types.new-feature")));
}
if (noteType.isBeta) {
$title.append($(`<span class="badge">`).text(t("note_types.beta-feature")));
}