Compare commits

...

15 Commits

Author SHA1 Message Date
Adorian Doran
d441bccf8b client/note color picker menu item: refactor 2025-11-18 02:18:14 +02:00
Adorian Doran
e6847355e7 client/note color picker menu item: improve the integration with the tree context menu 2025-11-18 02:12:41 +02:00
Adorian Doran
72051c8660 client/note color picker menu item: add a separator to the tree context menu 2025-11-18 01:24:05 +02:00
Adorian Doran
87afc64f16 client/note color picker menu item: fix current selection 2025-11-18 01:21:26 +02:00
Adorian Doran
870fef3ea6 client/note color picker menu item: fix a typo 2025-11-18 01:17:13 +02:00
Adorian Doran
e5ac8a0a67 client/note color picker menu item: refactor 2025-11-18 01:16:32 +02:00
Adorian Doran
87fcc0afe6 client/note color picker menu item: add to the table row context menu 2025-11-18 01:14:05 +02:00
Adorian Doran
79830870dd client/note color picker menu item: add to the geo map item context menu 2025-11-18 01:13:21 +02:00
Adorian Doran
69ad40c27f client/note color picker menu item: add to the board item context menu 2025-11-18 01:12:28 +02:00
Adorian Doran
1ac7ce00fb client/note color picker menu item: add to the calendar item context menu 2025-11-18 01:11:55 +02:00
Adorian Doran
e239bca0f2 client/note color picker menu item: fix data type 2025-11-18 01:10:10 +02:00
Adorian Doran
8729fe48c3 client/note color picker menu item: add support to operate with note IDs as well 2025-11-18 01:09:22 +02:00
Adorian Doran
441c55eb31 client/note color picker menu item: add initial implementation 2025-11-18 00:09:12 +02:00
Adorian Doran
5291a6856e client: create a placeholder for a color picker menu item 2025-11-17 19:14:34 +02:00
Adorian Doran
e011f99161 client: add support for custom menu items 2025-11-17 18:58:40 +02:00
8 changed files with 262 additions and 114 deletions

View File

@@ -2,7 +2,7 @@ import { KeyboardActionNames } from "@triliumnext/commons";
import keyboardActionService, { getActionSync } from "../services/keyboard_actions.js";
import note_tooltip from "../services/note_tooltip.js";
import utils from "../services/utils.js";
import { should } from "vitest";
import { h, JSX, render } from "preact";
export interface ContextMenuOptions<T> {
x: number;
@@ -15,6 +15,11 @@ export interface ContextMenuOptions<T> {
onHide?: () => void;
}
export interface CustomMenuItem {
kind: "custom",
componentFn: () => JSX.Element | null;
}
export interface MenuSeparatorItem {
kind: "separator";
}
@@ -51,7 +56,7 @@ export interface MenuCommandItem<T> {
columns?: number;
}
export type MenuItem<T> = MenuCommandItem<T> | MenuSeparatorItem | MenuHeader;
export type MenuItem<T> = MenuCommandItem<T> | CustomMenuItem | MenuSeparatorItem | MenuHeader;
export type MenuHandler<T> = (item: MenuCommandItem<T>, e: JQuery.MouseDownEvent<HTMLElement, undefined, HTMLElement, HTMLElement>) => void;
export type ContextMenuEvent = PointerEvent | MouseEvent | JQuery.ContextMenuEvent;
@@ -202,118 +207,14 @@ class ContextMenu {
$group.append($("<h6>").addClass("dropdown-header").text(item.title));
shouldResetGroup = true;
} else {
const $icon = $("<span>");
if ("uiIcon" in item || "checked" in item) {
const icon = (item.checked ? "bx bx-check" : item.uiIcon);
if (icon) {
$icon.addClass(icon);
} else {
$icon.append("&nbsp;");
}
if ("kind" in item && item.kind === "custom") {
// Custom menu item
$group.append(this.createCustomMenuItem(item));
} else {
// Standard menu item
$group.append(this.createMenuItem(item));
}
const $link = $("<span>")
.append($icon)
.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 ("keyboardShortcut" in item && item.keyboardShortcut) {
const shortcuts = getActionSync(item.keyboardShortcut).effectiveShortcuts;
if (shortcuts) {
const allShortcuts: string[] = [];
for (const effectiveShortcut of shortcuts) {
allShortcuts.push(effectiveShortcut.split("+")
.map(key => `<kbd>${key}</kbd>`)
.join("+"));
}
if (allShortcuts.length) {
const container = $("<span>").addClass("keyboard-shortcut");
container.append($(allShortcuts.join(",")));
$link.append(container);
}
}
} else if ("shortcut" in item && item.shortcut) {
$link.append($("<kbd>").text(item.shortcut));
}
const $item = $("<li>")
.addClass("dropdown-item")
.append($link)
.on("contextmenu", (e) => false)
// important to use mousedown instead of click since the former does not change focus
// (especially important for focused text for spell check)
.on("mousedown", (e) => {
e.stopPropagation();
if (e.which !== 1) {
// only left click triggers menu items
return false;
}
if (this.isMobile && "items" in item && item.items) {
const $item = $(e.target).closest(".dropdown-item");
$item.toggleClass("submenu-open");
$item.find("ul.dropdown-menu").toggleClass("show");
return false;
}
if ("handler" in item && item.handler) {
item.handler(item, e);
}
this.options?.selectMenuItemHandler(item, e);
// it's important to stop the propagation especially for sub-menus, otherwise the event
// might be handled again by top-level menu
return false;
});
$item.on("mouseup", (e) => {
// Prevent submenu from failing to expand on mobile
if (!this.isMobile || !("items" in item && item.items)) {
e.stopPropagation();
// Hide the content menu on mouse up to prevent the mouse event from propagating to the elements below.
this.hide();
return false;
}
});
if ("enabled" in item && item.enabled !== undefined && !item.enabled) {
$item.addClass("disabled");
}
if ("items" in item && item.items) {
$item.addClass("dropdown-submenu");
$link.addClass("dropdown-toggle");
const $subMenu = $("<ul>").addClass("dropdown-menu");
const hasColumns = !!item.columns && item.columns > 1;
if (!this.isMobile && hasColumns) {
$subMenu.css("column-count", item.columns!);
}
this.addItems($subMenu, item.items, hasColumns);
$item.append($subMenu);
}
$group.append($item);
// After adding a menu item, if the previous item was a separator or header,
// reset the group so that the next item will be appended directly to the parent.
if (shouldResetGroup) {
@@ -324,6 +225,126 @@ class ContextMenu {
}
}
private createCustomMenuItem(item: CustomMenuItem) {
const element = document.createElement("li");
element.classList.add("dropdown-custom-item");
render(h(item.componentFn, {}), element);
return element;
}
private createMenuItem(item: MenuCommandItem<any>) {
const $icon = $("<span>");
if ("uiIcon" in item || "checked" in item) {
const icon = (item.checked ? "bx bx-check" : item.uiIcon);
if (icon) {
$icon.addClass(icon);
} else {
$icon.append("&nbsp;");
}
}
const $link = $("<span>")
.append($icon)
.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 ("keyboardShortcut" in item && item.keyboardShortcut) {
const shortcuts = getActionSync(item.keyboardShortcut).effectiveShortcuts;
if (shortcuts) {
const allShortcuts: string[] = [];
for (const effectiveShortcut of shortcuts) {
allShortcuts.push(effectiveShortcut.split("+")
.map(key => `<kbd>${key}</kbd>`)
.join("+"));
}
if (allShortcuts.length) {
const container = $("<span>").addClass("keyboard-shortcut");
container.append($(allShortcuts.join(",")));
$link.append(container);
}
}
} else if ("shortcut" in item && item.shortcut) {
$link.append($("<kbd>").text(item.shortcut));
}
const $item = $("<li>")
.addClass("dropdown-item")
.append($link)
.on("contextmenu", (e) => false)
// important to use mousedown instead of click since the former does not change focus
// (especially important for focused text for spell check)
.on("mousedown", (e) => {
e.stopPropagation();
if (e.which !== 1) {
// only left click triggers menu items
return false;
}
if (this.isMobile && "items" in item && item.items) {
const $item = $(e.target).closest(".dropdown-item");
$item.toggleClass("submenu-open");
$item.find("ul.dropdown-menu").toggleClass("show");
return false;
}
if ("handler" in item && item.handler) {
item.handler(item, e);
}
this.options?.selectMenuItemHandler(item, e);
// it's important to stop the propagation especially for sub-menus, otherwise the event
// might be handled again by top-level menu
return false;
});
$item.on("mouseup", (e) => {
// Prevent submenu from failing to expand on mobile
if (!this.isMobile || !("items" in item && item.items)) {
e.stopPropagation();
// Hide the content menu on mouse up to prevent the mouse event from propagating to the elements below.
this.hide();
return false;
}
});
if ("enabled" in item && item.enabled !== undefined && !item.enabled) {
$item.addClass("disabled");
}
if ("items" in item && item.items) {
$item.addClass("dropdown-submenu");
$link.addClass("dropdown-toggle");
const $subMenu = $("<ul>").addClass("dropdown-menu");
const hasColumns = !!item.columns && item.columns > 1;
if (!this.isMobile && hasColumns) {
$subMenu.css("column-count", item.columns!);
}
this.addItems($subMenu, item.items, hasColumns);
$item.append($subMenu);
}
return $item;
}
async hide() {
this.options?.onHide?.();
this.$widget.removeClass("show");

View File

@@ -0,0 +1,19 @@
.color-picker-menu-item {
display: flex;
gap: 10px;
}
.color-picker-menu-item > .color-cell {
width: 16px;
height: 16px;
border-radius: 4px;
background: transparent;
}
.color-picker-menu-item > .color-cell.disabled-color-cell {
cursor: not-allowed;
}
.color-picker-menu-item > .color-cell.selected {
outline: 2px solid royalblue;
}

View File

@@ -0,0 +1,69 @@
import "./NoteColorPickerMenuItem.css";
import { useEffect, useState } from "preact/hooks";
import attributes from "../../services/attributes";
import FNote from "../../entities/fnote";
import froca from "../../services/froca";
const COLORS = ["blue", "green", "cyan", "red", "magenta", "brown", "yellow", ""];
export interface NoteColorPickerMenuItemProps {
/** The target Note instance or its ID string. */
note: FNote | string | null;
}
export default function NoteColorPickerMenuItem(props: NoteColorPickerMenuItemProps) {
if (!props.note) return null;
const [note, setNote] = useState<FNote | null>(null);
const [currentColor, setCurrentColor] = useState<string | null>(null);
useEffect(() => {
const retrieveNote = async (noteId: string) => {
const result = await froca.getNote(noteId, true);
if (result) {
setNote(result);
}
}
if (typeof props.note === "string") {
retrieveNote(props.note); // Get the note from the given ID string
} else {
setNote(props.note);
}
}, []);
useEffect(() => {
setCurrentColor(note?.getLabel("color")?.value ?? "");
}, [note]);
const onColorCellClicked = (color: string) => {
if (note) {
attributes.setLabel(note.noteId, "color", color);
setCurrentColor(color);
}
}
return <div className="color-picker-menu-item">
{COLORS.map((color) => (
<ColorCell key={color}
color={color}
isSelected={(color === currentColor)}
isDisabled={(note === null)}
onClick={() => onColorCellClicked(color)} />
))}
</div>
}
interface ColorCellProps {
color: string,
isSelected: boolean,
isDisabled?: boolean,
onClick?: () => void
}
function ColorCell(props: ColorCellProps) {
return <div class={`color-cell ${props.isSelected ? "selected" : ""} ${props.isDisabled ? "disabled-color-cell" : ""}`}
style={`background-color: ${props.color}`}
onClick={props.onClick}>
</div>;
}

View File

@@ -1,3 +1,4 @@
import NoteColorPickerMenuItem from "./custom-items/NoteColorPickerMenuItem.jsx";
import treeService from "../services/tree.js";
import froca from "../services/froca.js";
import clipboard from "../services/clipboard.js";
@@ -255,7 +256,20 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
keyboardShortcut: "searchInSubtree",
uiIcon: "bx bx-search",
enabled: notSearch && noSelectedNotes
}
},
{ kind: "separator"},
{
kind: "custom",
componentFn: () => {
if (notOptionsOrHelp && selectedNotes.length === 1) {
return NoteColorPickerMenuItem({note});
} else {
return null;
}
}
},
];
return items.filter((row) => row !== null) as MenuItem<TreeCommandNames>[];
}

View File

@@ -1,4 +1,5 @@
import FNote from "../../../entities/fnote";
import NoteColorPickerMenuItem from "../../../menus/custom-items/NoteColorPickerMenuItem";
import contextMenu, { ContextMenuEvent } from "../../../menus/context_menu";
import link_context_menu from "../../../menus/link_context_menu";
import attributes from "../../../services/attributes";
@@ -74,6 +75,11 @@ export function openNoteContextMenu(api: Api, event: ContextMenuEvent, note: FNo
uiIcon: "bx bx-trash",
handler: () => branches.deleteNotes([ branchId ], false, false)
},
{ kind: "separator" },
{
kind: "custom",
componentFn: () => NoteColorPickerMenuItem({note})
}
],
selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, note.noteId),
});

View File

@@ -1,8 +1,10 @@
import NoteColorPickerMenuItem from "../../../menus/custom-items/NoteColorPickerMenuItem";
import FNote from "../../../entities/fnote";
import contextMenu, { ContextMenuEvent } from "../../../menus/context_menu";
import link_context_menu from "../../../menus/link_context_menu";
import branches from "../../../services/branches";
import froca from "../../../services/froca";
import { note } from "mermaid/dist/rendering-util/rendering-elements/shapes/note.js";
import { t } from "../../../services/i18n";
export function openCalendarContextMenu(e: ContextMenuEvent, noteId: string, parentNote: FNote) {
@@ -34,6 +36,11 @@ export function openCalendarContextMenu(e: ContextMenuEvent, noteId: string, par
await branches.deleteNotes([ branchIdToDelete ], false, false);
}
}
},
{ kind: "separator" },
{
kind: "custom",
componentFn: () => NoteColorPickerMenuItem({note: noteId})
}
],
selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, noteId),

View File

@@ -2,6 +2,7 @@ import type { LatLng, LeafletMouseEvent } from "leaflet";
import appContext, { type CommandMappings } from "../../../components/app_context.js";
import contextMenu, { type MenuItem } from "../../../menus/context_menu.js";
import linkContextMenu from "../../../menus/link_context_menu.js";
import NoteColorPickerMenuItem from "../../../menus/custom-items/NoteColorPickerMenuItem.jsx";
import { t } from "../../../services/i18n.js";
import { createNewNote } from "./api.js";
import { copyTextWithToast } from "../../../services/clipboard_ext.js";
@@ -18,7 +19,12 @@ export default function openContextMenu(noteId: string, e: LeafletMouseEvent, is
items = [
...items,
{ kind: "separator" },
{ title: t("geo-map-context.remove-from-map"), command: "deleteFromMap", uiIcon: "bx bx-trash" }
{ title: t("geo-map-context.remove-from-map"), command: "deleteFromMap", uiIcon: "bx bx-trash" },
{ kind: "separator"},
{
kind: "custom",
componentFn: () => NoteColorPickerMenuItem({note: noteId})
}
];
}

View File

@@ -7,6 +7,7 @@ import link_context_menu from "../../../menus/link_context_menu.js";
import froca from "../../../services/froca.js";
import branches from "../../../services/branches.js";
import Component from "../../../components/component.js";
import NoteColorPickerMenuItem from "../../../menus/custom-items/NoteColorPickerMenuItem.jsx";
import { RefObject } from "preact";
export function useContextMenu(parentNote: FNote, parentComponent: Component | null | undefined, tabulator: RefObject<Tabulator>): Partial<EventCallBackMethods> {
@@ -219,6 +220,11 @@ export function showRowContextMenu(parentComponent: Component, e: MouseEvent, ro
title: t("table_context_menu.delete_row"),
uiIcon: "bx bx-trash",
handler: () => branches.deleteNotes([ rowData.branchId ], false, false)
},
{ kind: "separator"},
{
kind: "custom",
componentFn: () => NoteColorPickerMenuItem({note: rowData.noteId})
}
],
selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, rowData.noteId),