Port collections to React (#6837)

This commit is contained in:
Elian Doran 2025-09-14 19:01:30 +03:00 committed by GitHub
commit 0ac2df8102
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
186 changed files with 5703 additions and 5917 deletions

View File

@ -1,6 +1,6 @@
root = true
[*.{js,ts,.tsx}]
[*.{js,ts,tsx}]
charset = utf-8
end_of_line = lf
indent_size = 4

View File

@ -116,7 +116,7 @@ export type CommandMappings = {
openedFileUpdated: CommandData & {
entityType: string;
entityId: string;
lastModifiedMs: number;
lastModifiedMs?: number;
filePath: string;
};
focusAndSelectTitle: CommandData & {
@ -650,7 +650,7 @@ export class AppContext extends Component {
}
getComponentByEl(el: HTMLElement) {
return $(el).closest(".component").prop("component");
return $(el).closest("[data-component-id]").prop("component");
}
addBeforeUnloadListener(obj: BeforeUploadListener | (() => boolean)) {

View File

@ -23,11 +23,11 @@ export default class TouchBarComponent extends Component {
this.$widget = $("<div>");
$(window).on("focusin", async (e) => {
const $target = $(e.target);
const focusedEl = e.target as unknown as HTMLElement;
const $target = $(focusedEl);
this.$activeModal = $target.closest(".modal-dialog");
const parentComponentEl = $target.closest(".component");
this.lastFocusedComponent = appContext.getComponentByEl(parentComponentEl[0]);
this.lastFocusedComponent = appContext.getComponentByEl(focusedEl);
this.#refreshTouchBar();
});
}

View File

@ -256,18 +256,20 @@ export default class FNote {
return this.children;
}
async getSubtreeNoteIds() {
async getSubtreeNoteIds(includeArchived = false) {
let noteIds: (string | string[])[] = [];
for (const child of await this.getChildNotes()) {
if (child.isArchived && !includeArchived) continue;
noteIds.push(child.noteId);
noteIds.push(await child.getSubtreeNoteIds());
noteIds.push(await child.getSubtreeNoteIds(includeArchived));
}
return noteIds.flat();
}
async getSubtreeNotes() {
const noteIds = await this.getSubtreeNoteIds();
return this.froca.getNotes(noteIds);
return (await this.froca.getNotes(noteIds));
}
async getChildNotes() {
@ -905,8 +907,8 @@ export default class FNote {
return this.getBlob();
}
async getBlob() {
return await this.froca.getBlob("notes", this.noteId);
getBlob() {
return this.froca.getBlob("notes", this.noteId);
}
toString() {

View File

@ -5,7 +5,6 @@ import NoteTreeWidget from "../widgets/note_tree.js";
import NoteTitleWidget from "../widgets/note_title.jsx";
import NoteDetailWidget from "../widgets/note_detail.js";
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
import NoteListWidget from "../widgets/note_list.js";
import NoteIconWidget from "../widgets/note_icon.jsx";
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
import RootContainer from "../widgets/containers/root_container.js";
@ -42,6 +41,7 @@ import LeftPaneToggle from "../widgets/buttons/left_pane_toggle.js";
import ApiLog from "../widgets/api_log.jsx";
import CloseZenModeButton from "../widgets/close_zen_button.jsx";
import SharedInfo from "../widgets/shared_info.jsx";
import NoteList from "../widgets/collections/NoteList.jsx";
export default class DesktopLayout {
@ -138,7 +138,7 @@ export default class DesktopLayout {
.child(new PromotedAttributesWidget())
.child(<SqlTableSchemas />)
.child(new NoteDetailWidget())
.child(new NoteListWidget(false))
.child(<NoteList />)
.child(<SearchResult />)
.child(<SqlResults />)
.child(<ScrollPadding />)

View File

@ -27,10 +27,10 @@ import FlexContainer from "../widgets/containers/flex_container.js";
import NoteIconWidget from "../widgets/note_icon";
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
import NoteDetailWidget from "../widgets/note_detail.js";
import NoteListWidget from "../widgets/note_list.js";
import CallToActionDialog from "../widgets/dialogs/call_to_action.jsx";
import NoteTitleWidget from "../widgets/note_title.jsx";
import { PopupEditorFormattingToolbar } from "../widgets/ribbon/FormattingToolbar.js";
import NoteList from "../widgets/collections/NoteList.jsx";
export function applyModals(rootContainer: RootContainer) {
rootContainer
@ -66,6 +66,6 @@ export function applyModals(rootContainer: RootContainer) {
.child(<PopupEditorFormattingToolbar />)
.child(new PromotedAttributesWidget())
.child(new NoteDetailWidget())
.child(new NoteListWidget(true)))
.child(<NoteList displayOnlyCollections />))
.child(<CallToActionDialog />);
}

View File

@ -5,7 +5,6 @@ import QuickSearchWidget from "../widgets/quick_search.js";
import NoteTreeWidget from "../widgets/note_tree.js";
import ScreenContainer from "../widgets/mobile_widgets/screen_container.js";
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
import NoteListWidget from "../widgets/note_list.js";
import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
import LauncherContainer from "../widgets/containers/launcher_container.js";
import RootContainer from "../widgets/containers/root_container.js";
@ -24,6 +23,7 @@ import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button
import CloseZenModeButton from "../widgets/close_zen_button.js";
import NoteWrapperWidget from "../widgets/note_wrapper.js";
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
import NoteList from "../widgets/collections/NoteList.jsx";
const MOBILE_CSS = `
<style>
@ -154,7 +154,7 @@ export default class MobileLayout {
.filling()
.contentSized()
.child(new NoteDetailWidget())
.child(new NoteListWidget(false))
.child(<NoteList />)
.child(<FilePropertiesWrapper />)
)
.child(<MobileEditorToolbar />)

View File

@ -13,6 +13,8 @@ import type NoteTreeWidget from "../widgets/note_tree.js";
import type FAttachment from "../entities/fattachment.js";
import type { SelectMenuItemEventListener } from "../components/events.js";
import utils from "../services/utils.js";
import attributes from "../services/attributes.js";
import { executeBulkActions } from "../services/bulk_action.js";
// TODO: Deduplicate once client/server is well split.
interface ConvertToAttachmentResponse {
@ -61,6 +63,11 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
// the only exception is when the only selected note is the one that was right-clicked, then
// it's clear what the user meant to do.
const selNodes = this.treeWidget.getSelectedNodes();
const selectedNotes = await froca.getNotes(selNodes.map(node => node.data.noteId));
if (note && !selectedNotes.includes(note)) selectedNotes.push(note);
const isArchived = selectedNotes.every(note => note.isArchived);
const canToggleArchived = !selectedNotes.some(note => note.isArchived !== isArchived);
const noSelectedNotes = selNodes.length === 0 || (selNodes.length === 1 && selNodes[0] === this.node);
const notSearch = note?.type !== "search";
@ -189,6 +196,34 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
enabled: parentNotSearch && isNotRoot && !isHoisted && notOptionsOrHelp
},
{
title: !isArchived ? t("tree-context-menu.archive") : t("tree-context-menu.unarchive"),
uiIcon: !isArchived ? "bx bx-archive" : "bx bx-archive-out",
enabled: canToggleArchived,
handler: () => {
if (!selectedNotes.length) return;
if (selectedNotes.length == 1) {
const note = selectedNotes[0];
if (!isArchived) {
attributes.addLabel(note.noteId, "archived");
} else {
attributes.removeOwnedLabelByName(note, "archived");
}
} else {
const noteIds = selectedNotes.map(note => note.noteId);
if (!isArchived) {
executeBulkActions(noteIds, [{
name: "addLabel", labelName: "archived"
}]);
} else {
executeBulkActions(noteIds, [{
name: "deleteLabel", labelName: "archived"
}]);
}
}
}
},
{
title: `${t("tree-context-menu.delete")} <kbd data-command="deleteNotes"></kbd>`,
command: "deleteNotes",

View File

@ -210,7 +210,7 @@ function makeToast(id: string, message: string): ToastOptions {
}
ws.subscribeToMessages(async (message) => {
if (message.taskType !== "deleteNotes") {
if (!("taskType" in message) || message.taskType !== "deleteNotes") {
return;
}
@ -228,7 +228,7 @@ ws.subscribeToMessages(async (message) => {
});
ws.subscribeToMessages(async (message) => {
if (message.taskType !== "undeleteNotes") {
if (!("taskType" in message) || message.taskType !== "undeleteNotes") {
return;
}

View File

@ -60,7 +60,7 @@ async function confirmDeleteNoteBoxWithNote(title: string) {
return new Promise<ConfirmDialogResult | undefined>((res) => appContext.triggerCommand("showConfirmDeleteNoteBoxWithNoteDialog", { title, callback: res }));
}
async function prompt(props: PromptDialogOptions) {
export async function prompt(props: PromptDialogOptions) {
return new Promise<string | null>((res) => appContext.triggerCommand("showPromptDialog", { ...props, callback: res }));
}

View File

@ -1,16 +1,8 @@
import ws from "./ws.js";
import appContext from "../components/app_context.js";
import { OpenedFileUpdateStatus } from "@triliumnext/commons";
// TODO: Deduplicate
interface Message {
type: string;
entityType: string;
entityId: string;
lastModifiedMs: number;
filePath: string;
}
const fileModificationStatus: Record<string, Record<string, Message>> = {
const fileModificationStatus: Record<string, Record<string, OpenedFileUpdateStatus>> = {
notes: {},
attachments: {}
};
@ -39,7 +31,7 @@ function ignoreModification(entityType: string, entityId: string) {
delete fileModificationStatus[entityType][entityId];
}
ws.subscribeToMessages(async (message: Message) => {
ws.subscribeToMessages(async message => {
if (message.type !== "openedFileUpdated") {
return;
}

View File

@ -4,6 +4,7 @@ import ws from "./ws.js";
import utils from "./utils.js";
import appContext from "../components/app_context.js";
import { t } from "./i18n.js";
import { WebSocketMessage } from "@triliumnext/commons";
type BooleanLike = boolean | "true" | "false";
@ -66,7 +67,7 @@ function makeToast(id: string, message: string): ToastOptions {
}
ws.subscribeToMessages(async (message) => {
if (message.taskType !== "importNotes") {
if (!("taskType" in message) || message.taskType !== "importNotes") {
return;
}
@ -87,8 +88,8 @@ ws.subscribeToMessages(async (message) => {
}
});
ws.subscribeToMessages(async (message) => {
if (message.taskType !== "importAttachments") {
ws.subscribeToMessages(async (message: WebSocketMessage) => {
if (!("taskType" in message) || message.taskType !== "importAttachments") {
return;
}

View File

@ -1,6 +1,6 @@
import { NoteType } from "@triliumnext/commons";
import { ViewTypeOptions } from "./note_list_renderer";
import FNote from "../entities/fnote";
import { ViewTypeOptions } from "../widgets/collections/interface";
export const byNoteType: Record<Exclude<NoteType, "book">, string | null> = {
canvas: null,

View File

@ -1,71 +0,0 @@
import type FNote from "../entities/fnote.js";
import BoardView from "../widgets/view_widgets/board_view/index.js";
import CalendarView from "../widgets/view_widgets/calendar_view.js";
import GeoView from "../widgets/view_widgets/geo_view/index.js";
import ListOrGridView from "../widgets/view_widgets/list_or_grid_view.js";
import TableView from "../widgets/view_widgets/table_view/index.js";
import type { ViewModeArgs } from "../widgets/view_widgets/view_mode.js";
import type ViewMode from "../widgets/view_widgets/view_mode.js";
const allViewTypes = ["list", "grid", "calendar", "table", "geoMap", "board"] as const;
export type ArgsWithoutNoteId = Omit<ViewModeArgs, "noteIds">;
export type ViewTypeOptions = typeof allViewTypes[number];
export default class NoteListRenderer {
private viewType: ViewTypeOptions;
private args: ArgsWithoutNoteId;
public viewMode?: ViewMode<any>;
constructor(args: ArgsWithoutNoteId) {
this.args = args;
this.viewType = this.#getViewType(args.parentNote);
}
#getViewType(parentNote: FNote): ViewTypeOptions {
const viewType = parentNote.getLabelValue("viewType");
if (!(allViewTypes as readonly string[]).includes(viewType || "")) {
// when not explicitly set, decide based on the note type
return parentNote.type === "search" ? "list" : "grid";
} else {
return viewType as ViewTypeOptions;
}
}
get isFullHeight() {
switch (this.viewType) {
case "list":
case "grid":
return false;
default:
return true;
}
}
async renderList() {
const args = this.args;
const viewMode = this.#buildViewMode(args);
this.viewMode = viewMode;
await viewMode.beforeRender();
return await viewMode.renderList();
}
#buildViewMode(args: ViewModeArgs) {
switch (this.viewType) {
case "calendar":
return new CalendarView(args);
case "table":
return new TableView(args);
case "geoMap":
return new GeoView(args);
case "board":
return new BoardView(args);
case "list":
case "grid":
default:
return new ListOrGridView(this.viewType, args);
}
}
}

View File

@ -107,11 +107,11 @@ function makeToast(message: Message, title: string, text: string): ToastOptions
}
ws.subscribeToMessages(async (message) => {
if (message.taskType !== "protectNotes") {
if (!("taskType" in message) || message.taskType !== "protectNotes") {
return;
}
const isProtecting = message.data.protect;
const isProtecting = message.data?.protect;
const title = isProtecting ? t("protected_session.protecting-title") : t("protected_session.unprotecting-title");
if (message.type === "taskError") {

View File

@ -47,27 +47,6 @@ function parseDate(str: string) {
}
}
// Source: https://stackoverflow.com/a/30465299/4898894
function getMonthsInDateRange(startDate: string, endDate: string) {
const start = startDate.split("-");
const end = endDate.split("-");
const startYear = parseInt(start[0]);
const endYear = parseInt(end[0]);
const dates: string[] = [];
for (let i = startYear; i <= endYear; i++) {
const endMonth = i != endYear ? 11 : parseInt(end[1]) - 1;
const startMon = i === startYear ? parseInt(start[1]) - 1 : 0;
for (let j = startMon; j <= endMonth; j = j > 12 ? j % 12 || 11 : j + 1) {
const month = j + 1;
const displayMonth = month < 10 ? "0" + month : month;
dates.push([i, displayMonth].join("-"));
}
}
return dates;
}
function padNum(num: number) {
return `${num <= 9 ? "0" : ""}${num}`;
}
@ -496,7 +475,7 @@ function sleep(time_ms: number) {
});
}
function escapeRegExp(str: string) {
export function escapeRegExp(str: string) {
return str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
}
@ -883,7 +862,6 @@ export default {
restartDesktopApp,
reloadTray,
parseDate,
getMonthsInDateRange,
formatDateISO,
formatDateTime,
formatTimeInterval,

View File

@ -6,9 +6,10 @@ import frocaUpdater from "./froca_updater.js";
import appContext from "../components/app_context.js";
import { t } from "./i18n.js";
import type { EntityChange } from "../server_types.js";
import { WebSocketMessage } from "@triliumnext/commons";
type MessageHandler = (message: any) => void;
const messageHandlers: MessageHandler[] = [];
type MessageHandler = (message: WebSocketMessage) => void;
let messageHandlers: MessageHandler[] = [];
let ws: WebSocket;
let lastAcceptedEntityChangeId = window.glob.maxEntityChangeIdAtLoad;
@ -47,10 +48,14 @@ function logInfo(message: string) {
window.logError = logError;
window.logInfo = logInfo;
function subscribeToMessages(messageHandler: MessageHandler) {
export function subscribeToMessages(messageHandler: MessageHandler) {
messageHandlers.push(messageHandler);
}
export function unsubscribeToMessage(messageHandler: MessageHandler) {
messageHandlers = messageHandlers.filter(handler => handler !== messageHandler);
}
// used to serialize frontend update operations
let consumeQueuePromise: Promise<void> | null = null;

View File

@ -294,6 +294,11 @@ button.close:hover {
pointer-events: none;
}
.icon-action.btn {
padding: 0 8px;
min-width: unset !important;
}
.ui-widget-content a:not(.ui-tabs-anchor) {
color: #337ab7 !important;
}

View File

@ -96,6 +96,11 @@ button.btn.btn-success kbd {
color: var(--icon-button-color);
}
:root .btn-group .icon-action:last-child {
border-top-left-radius: unset !important;
border-bottom-left-radius: unset !important;
}
.btn-group .tn-tool-button + .tn-tool-button {
margin-left: 4px !important;
}

View File

@ -592,7 +592,18 @@
"september": "September",
"october": "October",
"november": "November",
"december": "December"
"december": "December",
"week": "Week",
"week_previous": "Previous week",
"week_next": "Next week",
"month": "Month",
"month_previous": "Previous month",
"month_next": "Next month",
"year": "Year",
"year_previous": "Previous year",
"year_next": "Next year",
"list": "List",
"today": "Today"
},
"close_pane_button": {
"close_this_pane": "Close this pane"
@ -753,7 +764,8 @@
"calendar": "Calendar",
"table": "Table",
"geo-map": "Geo Map",
"board": "Board"
"board": "Board",
"include_archived_notes": "Show archived notes"
},
"edited_notes": {
"no_edited_notes_found": "No edited notes on this day yet...",
@ -954,7 +966,9 @@
"no_attachments": "This note has no attachments."
},
"book": {
"no_children_help": "This collection doesn't have any child notes so there's nothing to display. See <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a> for details."
"no_children_help": "This collection doesn't have any child notes so there's nothing to display. See <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a> for details.",
"drag_locked_title": "Locked for editing",
"drag_locked_message": "Dragging not allowed since the collection is locked for editing."
},
"editable_code": {
"placeholder": "Type the content of your code note here..."
@ -1580,6 +1594,8 @@
"open-in-a-new-split": "Open in a new split",
"insert-note-after": "Insert note after",
"insert-child-note": "Insert child note",
"archive": "Archive",
"unarchive": "Unarchive",
"delete": "Delete",
"search-in-subtree": "Search in subtree",
"hoist-note": "Hoist note",
@ -1982,14 +1998,21 @@
"delete_row": "Delete row"
},
"board_view": {
"delete-note": "Delete Note",
"delete-note": "Delete note...",
"remove-from-board": "Remove from board",
"archive-note": "Archive note",
"unarchive-note": "Unarchive note",
"move-to": "Move to",
"insert-above": "Insert above",
"insert-below": "Insert below",
"delete-column": "Delete column",
"delete-column-confirmation": "Are you sure you want to delete this column? The corresponding attribute will be deleted in the notes under this column as well.",
"new-item": "New item",
"add-column": "Add Column"
"new-item-placeholder": "Enter note title...",
"add-column": "Add Column",
"add-column-placeholder": "Enter column name...",
"edit-note-title": "Click to edit note title",
"edit-column-title": "Click to edit column title"
},
"command_palette": {
"tree-action-name": "Tree: {{name}}",
@ -2031,5 +2054,12 @@
},
"units": {
"percentage": "%"
},
"pagination": {
"page_title": "Page of {{startIndex}} - {{endIndex}}",
"total_notes": "{{count}} notes"
},
"collections": {
"rendering_error": "Unable to show content due to an error."
}
}

View File

@ -6,7 +6,7 @@ import { ParentComponent } from "./react/react_utils";
import { EventData, EventNames } from "../components/app_context";
import { type FloatingButtonsList, type FloatingButtonContext } from "./FloatingButtonsDefinitions";
import ActionButton from "./react/ActionButton";
import { ViewTypeOptions } from "../services/note_list_renderer";
import { ViewTypeOptions } from "./collections/interface";
interface FloatingButtonsProps {
items: FloatingButtonsList;

View File

@ -19,7 +19,7 @@ import { getHelpUrlForNote } from "../services/in_app_help";
import froca from "../services/froca";
import NoteLink from "./react/NoteLink";
import RawHtml from "./react/RawHtml";
import { ViewTypeOptions } from "../services/note_list_renderer";
import { ViewTypeOptions } from "./collections/interface";
export interface FloatingButtonContext {
parentComponent: Component;

View File

@ -0,0 +1,25 @@
.note-list-widget {
min-height: 0;
overflow: auto;
contain: none !important;
}
.note-list-widget .note-list {
padding: 10px;
}
.note-list-widget.full-height,
.note-list-widget.full-height .note-list-widget-content {
height: 100%;
}
.note-list-widget video {
height: 100%;
}
/* #region Pagination */
.note-list-pager span.current-page {
text-decoration: underline;
font-weight: bold;
}
/* #endregion */

View File

@ -0,0 +1,187 @@
import { allViewTypes, ViewModeProps, ViewTypeOptions } from "./interface";
import { useNoteContext, useNoteLabel, useNoteLabelBoolean, useTriliumEvent } from "../react/hooks";
import FNote from "../../entities/fnote";
import "./NoteList.css";
import { ListView, GridView } from "./legacy/ListOrGridView";
import { useEffect, useRef, useState } from "preact/hooks";
import GeoView from "./geomap";
import ViewModeStorage from "./view_mode_storage";
import CalendarView from "./calendar";
import TableView from "./table";
import BoardView from "./board";
import { subscribeToMessages, unsubscribeToMessage as unsubscribeFromMessage } from "../../services/ws";
import { WebSocketMessage } from "@triliumnext/commons";
import froca from "../../services/froca";
interface NoteListProps<T extends object> {
note?: FNote | null;
/** if set to `true` then only collection-type views are displayed such as geo-map and the calendar. The original book types grid and list will be ignored. */
displayOnlyCollections?: boolean;
highlightedTokens?: string[] | null;
viewStorage?: ViewModeStorage<T>;
}
export default function NoteList<T extends object>({ note: providedNote, highlightedTokens, displayOnlyCollections }: NoteListProps<T>) {
const widgetRef = useRef<HTMLDivElement>(null);
const { note: contextNote, noteContext, notePath } = useNoteContext();
const note = providedNote ?? contextNote;
const viewType = useNoteViewType(note);
const noteIds = useNoteIds(note, viewType);
const isFullHeight = (viewType && viewType !== "list" && viewType !== "grid");
const [ isIntersecting, setIsIntersecting ] = useState(false);
const shouldRender = (isFullHeight || isIntersecting || note?.type === "book");
const isEnabled = (note && noteContext?.hasNoteList() && !!viewType && shouldRender);
useEffect(() => {
if (isFullHeight || displayOnlyCollections || note?.type === "book") {
// Double role: no need to check if the note list is visible if the view is full-height or book, but also prevent legacy views if `displayOnlyCollections` is true.
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (!isIntersecting) {
setIsIntersecting(entries[0].isIntersecting);
observer.disconnect();
}
},
{
rootMargin: "50px",
threshold: 0.1
}
);
// there seems to be a race condition on Firefox which triggers the observer only before the widget is visible
// (intersection is false). https://github.com/zadam/trilium/issues/4165
setTimeout(() => widgetRef.current && observer.observe(widgetRef.current), 10);
return () => observer.disconnect();
}, [ widgetRef, isFullHeight, displayOnlyCollections, note ]);
// Preload the configuration.
let props: ViewModeProps<any> | undefined | null = null;
const viewModeConfig = useViewModeConfig(note, viewType);
if (note && notePath && viewModeConfig) {
props = {
note, noteIds, notePath,
highlightedTokens,
viewConfig: viewModeConfig[0],
saveConfig: viewModeConfig[1]
}
}
return (
<div ref={widgetRef} className={`note-list-widget component ${isFullHeight ? "full-height" : ""}`}>
{props && isEnabled && (
<div className="note-list-widget-content">
{getComponentByViewType(viewType, props)}
</div>
)}
</div>
);
}
function getComponentByViewType(viewType: ViewTypeOptions, props: ViewModeProps<any>) {
switch (viewType) {
case "list":
return <ListView {...props} />;
case "grid":
return <GridView {...props} />;
case "geoMap":
return <GeoView {...props} />;
case "calendar":
return <CalendarView {...props} />
case "table":
return <TableView {...props} />
case "board":
return <BoardView {...props} />
}
}
function useNoteViewType(note?: FNote | null): ViewTypeOptions | undefined {
const [ viewType ] = useNoteLabel(note, "viewType");
if (!note) {
return undefined;
} else if (!(allViewTypes as readonly string[]).includes(viewType || "")) {
// when not explicitly set, decide based on the note type
return note.type === "search" ? "list" : "grid";
} else {
return viewType as ViewTypeOptions;
}
}
function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOptions | undefined) {
const [ noteIds, setNoteIds ] = useState<string[]>([]);
const [ includeArchived ] = useNoteLabelBoolean(note, "includeArchived");
async function refreshNoteIds() {
if (!note) {
setNoteIds([]);
} else {
setNoteIds(await getNoteIds(note));
}
}
async function getNoteIds(note: FNote) {
if (viewType === "list" || viewType === "grid") {
return note.getChildNoteIds();
} else {
return await note.getSubtreeNoteIds(includeArchived);
}
}
// Refresh on note switch.
useEffect(() => { refreshNoteIds() }, [ note, includeArchived ]);
// Refresh on alterations to the note subtree.
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
if (note && loadResults.getBranchRows().some(branch =>
branch.parentNoteId === note.noteId
|| noteIds.includes(branch.parentNoteId ?? ""))
|| loadResults.getAttributeRows().some(attr => attr.name === "archived" && attr.noteId && noteIds.includes(attr.noteId))
) {
refreshNoteIds();
}
})
// Refresh on import.
useEffect(() => {
async function onImport(message: WebSocketMessage) {
if (!("taskType" in message) || message.taskType !== "importNotes" || message.type !== "taskSucceeded") return;
const { parentNoteId, importedNoteId } = message.result;
if (!parentNoteId || !importedNoteId) return;
if (importedNoteId && (parentNoteId === note?.noteId || noteIds.includes(parentNoteId))) {
const importedNote = await froca.getNote(importedNoteId);
if (!importedNote) return;
setNoteIds([
...noteIds,
...await getNoteIds(importedNote),
importedNoteId
])
}
}
subscribeToMessages(onImport);
return () => unsubscribeFromMessage(onImport);
}, [ note, noteIds, setNoteIds ])
return noteIds;
}
function useViewModeConfig<T extends object>(note: FNote | null | undefined, viewType: ViewTypeOptions | undefined) {
const [ viewConfig, setViewConfig ] = useState<[T | undefined, (data: T) => void]>();
useEffect(() => {
if (!note || !viewType) return;
const viewStorage = new ViewModeStorage<T>(note, viewType);
viewStorage.restore().then(config => {
const storeFn = (config: T) => {
setViewConfig([ config, storeFn ]);
viewStorage.store(config);
};
setViewConfig([ config, storeFn ]);
});
}, [ note, viewType ]);
return viewConfig;
}

View File

@ -0,0 +1,85 @@
import { ComponentChildren } from "preact";
import { Dispatch, StateUpdater, useEffect, useState } from "preact/hooks";
import FNote from "../../entities/fnote";
import froca from "../../services/froca";
import { useNoteLabelInt } from "../react/hooks";
import { t } from "../../services/i18n";
interface PaginationContext {
page: number;
setPage: Dispatch<StateUpdater<number>>;
pageNotes?: FNote[];
pageCount: number;
pageSize: number;
totalNotes: number;
}
export function Pager({ page, pageSize, setPage, pageCount, totalNotes }: Omit<PaginationContext, "pageNotes">) {
if (pageCount < 2) return;
let lastPrinted = false;
let children: ComponentChildren[] = [];
for (let i = 1; i <= pageCount; i++) {
if (pageCount < 20 || i <= 5 || pageCount - i <= 5 || Math.abs(page - i) <= 2) {
lastPrinted = true;
const startIndex = (i - 1) * pageSize + 1;
const endIndex = Math.min(totalNotes, i * pageSize);
if (i !== page) {
children.push((
<a
href="javascript:"
title={t("pagination.page_title", { startIndex, endIndex })}
onClick={() => setPage(i)}
>
{i}
</a>
))
} else {
// Current page
children.push(<span className="current-page">{i}</span>)
}
children.push(<>{" "}&nbsp;{" "}</>);
} else if (lastPrinted) {
children.push(<>{"... "}&nbsp;{" "}</>);
lastPrinted = false;
}
}
return (
<div class="note-list-pager">
{children}
<span className="note-list-pager-total-count">({t("pagination.total_notes", { count: totalNotes })})</span>
</div>
)
}
export function usePagination(note: FNote, noteIds: string[]): PaginationContext {
const [ page, setPage ] = useState(1);
const [ pageNotes, setPageNotes ] = useState<FNote[]>();
// Parse page size.
const [ pageSize ] = useNoteLabelInt(note, "pageSize");
const normalizedPageSize = (pageSize && pageSize > 0 ? pageSize : 20);
// Calculate start/end index.
const startIdx = (page - 1) * normalizedPageSize;
const endIdx = startIdx + normalizedPageSize;
const pageCount = Math.ceil(noteIds.length / normalizedPageSize);
// Obtain notes within the range.
const pageNoteIds = noteIds.slice(startIdx, Math.min(endIdx, noteIds.length));
useEffect(() => {
froca.getNotes(pageNoteIds).then(setPageNotes);
}, [ note, noteIds, page, pageSize ]);
return {
page, setPage, pageNotes, pageCount,
pageSize: normalizedPageSize,
totalNotes: noteIds.length
};
}

View File

@ -0,0 +1,210 @@
import { BoardViewData } from ".";
import appContext from "../../../components/app_context";
import FNote from "../../../entities/fnote";
import attributes from "../../../services/attributes";
import branches from "../../../services/branches";
import { executeBulkActions } from "../../../services/bulk_action";
import froca from "../../../services/froca";
import { t } from "../../../services/i18n";
import note_create from "../../../services/note_create";
import server from "../../../services/server";
import { ColumnMap } from "./data";
export default class BoardApi {
constructor(
private byColumn: ColumnMap | undefined,
public columns: string[],
private parentNote: FNote,
private statusAttribute: string,
private viewConfig: BoardViewData,
private saveConfig: (newConfig: BoardViewData) => void,
private setBranchIdToEdit: (branchId: string | undefined) => void
) {};
async createNewItem(column: string, title: string) {
try {
// Get the parent note path
const parentNotePath = this.parentNote.noteId;
// Create a new note as a child of the parent note
const { note: newNote, branch: newBranch } = await note_create.createNote(parentNotePath, {
activate: false,
title
});
if (newNote && newBranch) {
await this.changeColumn(newNote.noteId, column);
}
} catch (error) {
console.error("Failed to create new item:", error);
}
}
async changeColumn(noteId: string, newColumn: string) {
await attributes.setLabel(noteId, this.statusAttribute, newColumn);
}
async addNewColumn(columnName: string) {
if (!columnName.trim()) {
return;
}
if (!this.viewConfig) {
this.viewConfig = {};
}
if (!this.viewConfig.columns) {
this.viewConfig.columns = [];
}
// Add the new column to persisted data if it doesn't exist
const existingColumn = this.viewConfig.columns.find(col => col.value === columnName);
if (!existingColumn) {
this.viewConfig.columns.push({ value: columnName });
this.saveConfig(this.viewConfig);
}
}
async removeColumn(column: string) {
// Remove the value from the notes.
const noteIds = this.byColumn?.get(column)?.map(item => item.note.noteId) || [];
await executeBulkActions(noteIds, [
{
name: "deleteLabel",
labelName: this.statusAttribute
}
]);
this.viewConfig.columns = (this.viewConfig.columns ?? []).filter(col => col.value !== column);
this.saveConfig(this.viewConfig);
}
async renameColumn(oldValue: string, newValue: string) {
const noteIds = this.byColumn?.get(oldValue)?.map(item => item.note.noteId) || [];
// Change the value in the notes.
await executeBulkActions(noteIds, [
{
name: "updateLabelValue",
labelName: this.statusAttribute,
labelValue: newValue
}
]);
// Rename the column in the persisted data.
for (const column of this.viewConfig.columns || []) {
if (column.value === oldValue) {
column.value = newValue;
}
}
this.saveConfig(this.viewConfig);
}
reorderColumn(fromIndex: number, toIndex: number) {
if (!this.columns || fromIndex === toIndex) return;
const newColumns = [...this.columns];
const [movedColumn] = newColumns.splice(fromIndex, 1);
// Adjust toIndex after removing the element
// When moving forward (right), the removal shifts indices left
let adjustedToIndex = toIndex;
if (fromIndex < toIndex) {
adjustedToIndex = toIndex - 1;
}
newColumns.splice(adjustedToIndex, 0, movedColumn);
// Update view config with new column order
const newViewConfig = {
...this.viewConfig,
columns: newColumns.map(col => ({ value: col }))
};
this.saveConfig(newViewConfig);
return newColumns;
}
async insertRowAtPosition(
column: string,
relativeToBranchId: string,
direction: "before" | "after") {
const { note, branch } = await note_create.createNote(this.parentNote.noteId, {
activate: false,
targetBranchId: relativeToBranchId,
target: direction,
title: t("board_view.new-item")
});
if (!note || !branch) {
throw new Error("Failed to create note");
}
const { noteId } = note;
await this.changeColumn(noteId, column);
this.startEditing(branch.branchId);
return note;
}
openNote(noteId: string) {
appContext.triggerCommand("openInPopup", { noteIdOrPath: noteId });
}
startEditing(branchId: string) {
this.setBranchIdToEdit(branchId);
}
dismissEditingTitle() {
this.setBranchIdToEdit(undefined);
}
renameCard(noteId: string, newTitle: string) {
return server.put(`notes/${noteId}/title`, { title: newTitle.trim() });
}
removeFromBoard(noteId: string) {
const note = froca.getNoteFromCache(noteId);
if (!note) return;
return attributes.removeOwnedLabelByName(note, this.statusAttribute);
}
async moveWithinBoard(noteId: string, sourceBranchId: string, sourceIndex: number, targetIndex: number, sourceColumn: string, targetColumn: string) {
const targetItems = this.byColumn?.get(targetColumn) ?? [];
const note = froca.getNoteFromCache(noteId);
if (!note) return;
if (sourceColumn !== targetColumn) {
// Moving to a different column
await this.changeColumn(noteId, targetColumn);
// If there are items in the target column, reorder
if (targetItems.length > 0 && targetIndex < targetItems.length) {
const targetBranch = targetItems[targetIndex].branch;
await branches.moveBeforeBranch([ sourceBranchId ], targetBranch.branchId);
}
} else if (sourceIndex !== targetIndex) {
// Reordering within the same column
let targetBranchId: string | null = null;
if (targetIndex < targetItems.length) {
// Moving before an existing item
const adjustedIndex = sourceIndex < targetIndex ? targetIndex : targetIndex;
if (adjustedIndex < targetItems.length) {
targetBranchId = targetItems[adjustedIndex].branch.branchId;
if (targetBranchId) {
await branches.moveBeforeBranch([ sourceBranchId ], targetBranchId);
}
}
} else if (targetIndex > 0) {
// Moving to the end - place after the last item
const lastItem = targetItems[targetItems.length - 1];
await branches.moveAfterBranch([ sourceBranchId ], lastItem.branch.branchId);
}
}
}
}

View File

@ -0,0 +1,115 @@
import { useCallback, useContext, useEffect, useRef, useState } from "preact/hooks";
import FBranch from "../../../entities/fbranch";
import FNote from "../../../entities/fnote";
import BoardApi from "./api";
import { BoardViewContext, TitleEditor } from ".";
import { ContextMenuEvent } from "../../../menus/context_menu";
import { openNoteContextMenu } from "./context_menu";
import { t } from "../../../services/i18n";
export const CARD_CLIPBOARD_TYPE = "trilium/board-card";
export interface CardDragData {
noteId: string;
branchId: string;
index: number;
fromColumn: string;
}
export default function Card({
api,
note,
branch,
column,
index,
isDragging
}: {
api: BoardApi,
note: FNote,
branch: FBranch,
column: string,
index: number,
isDragging: boolean
}) {
const { branchIdToEdit, setBranchIdToEdit, setDraggedCard } = useContext(BoardViewContext)!;
const isEditing = branch.branchId === branchIdToEdit;
const colorClass = note.getColorClass() || '';
const editorRef = useRef<HTMLInputElement>(null);
const isArchived = note.isArchived;
const [ isVisible, setVisible ] = useState(true);
const [ title, setTitle ] = useState(note.title);
const handleDragStart = useCallback((e: DragEvent) => {
e.dataTransfer!.effectAllowed = 'move';
const data: CardDragData = { noteId: note.noteId, branchId: branch.branchId, fromColumn: column, index };
setDraggedCard(data);
e.dataTransfer!.setData(CARD_CLIPBOARD_TYPE, JSON.stringify(data));
}, [note.noteId, branch.branchId, column, index]);
const handleDragEnd = useCallback((e: DragEvent) => {
setDraggedCard(null);
}, [setDraggedCard]);
const handleContextMenu = useCallback((e: ContextMenuEvent) => {
openNoteContextMenu(api, e, note, branch.branchId, column);
}, [ api, note, branch, column ]);
const handleOpen = useCallback(() => {
api.openNote(note.noteId);
}, [ api, note ]);
const handleEdit = useCallback((e: MouseEvent) => {
e.stopPropagation(); // don't also open the note
setBranchIdToEdit?.(branch.branchId);
}, [ setBranchIdToEdit, branch ]);
useEffect(() => {
editorRef.current?.focus();
}, [ isEditing ]);
useEffect(() => {
setTitle(note.title);
}, [ note ]);
useEffect(() => {
setVisible(!isDragging);
}, [ isDragging ]);
return (
<div
className={`board-note ${colorClass} ${isDragging ? 'dragging' : ''} ${isEditing ? "editing" : ""} ${isArchived ? "archived" : ""}`}
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onContextMenu={handleContextMenu}
onClick={!isEditing ? handleOpen : undefined}
style={{
display: !isVisible ? "none" : undefined
}}
>
{!isEditing ? (
<>
<span className="title">
<span class={`icon ${note.getIcon()}`} />
{title}
</span>
<span
className="edit-icon icon bx bx-edit"
title={t("board_view.edit-note-title")}
onClick={handleEdit}
/>
</>
) : (
<TitleEditor
currentValue={note.title}
save={newTitle => {
api.renameCard(note.noteId, newTitle);
setTitle(newTitle);
}}
dismiss={() => api.dismissEditingTitle()}
multiline
/>
)}
</div>
)
}

View File

@ -0,0 +1,277 @@
import { useCallback, useContext, useEffect, useRef, useState } from "preact/hooks";
import FBranch from "../../../entities/fbranch";
import FNote from "../../../entities/fnote";
import { BoardViewContext, TitleEditor } from ".";
import branches from "../../../services/branches";
import { openColumnContextMenu } from "./context_menu";
import { ContextMenuEvent } from "../../../menus/context_menu";
import Icon from "../../react/Icon";
import { t } from "../../../services/i18n";
import BoardApi from "./api";
import Card, { CARD_CLIPBOARD_TYPE, CardDragData } from "./card";
import { JSX } from "preact/jsx-runtime";
import froca from "../../../services/froca";
import { DragData, TREE_CLIPBOARD_TYPE } from "../../note_tree";
interface DragContext {
column: string;
columnIndex: number,
columnItems?: { note: FNote, branch: FBranch }[];
}
export default function Column({
column,
columnIndex,
isDraggingColumn,
columnItems,
api,
onColumnHover,
isAnyColumnDragging,
}: {
columnItems?: { note: FNote, branch: FBranch }[];
isDraggingColumn: boolean,
api: BoardApi,
onColumnHover?: (index: number, mouseX: number, rect: DOMRect) => void,
isAnyColumnDragging?: boolean
} & DragContext) {
const [ isVisible, setVisible ] = useState(true);
const { columnNameToEdit, setColumnNameToEdit, dropTarget, draggedCard, dropPosition } = useContext(BoardViewContext)!;
const isEditing = (columnNameToEdit === column);
const editorRef = useRef<HTMLInputElement>(null);
const { handleColumnDragStart, handleColumnDragEnd, handleDragOver, handleDragLeave, handleDrop } = useDragging({
column, columnIndex, columnItems, isEditing
});
const handleEdit = useCallback(() => {
setColumnNameToEdit?.(column);
}, [column]);
const handleContextMenu = useCallback((e: ContextMenuEvent) => {
openColumnContextMenu(api, e, column);
}, [ api, column ]);
/** Allow using mouse wheel to scroll inside card, while also maintaining column horizontal scrolling. */
const handleScroll = useCallback((event: JSX.TargetedWheelEvent<HTMLDivElement>) => {
const el = event.currentTarget;
if (!el) return;
const needsScroll = el.scrollHeight > el.clientHeight;
if (needsScroll) {
event.stopPropagation();
}
}, []);
useEffect(() => {
editorRef.current?.focus();
}, [ isEditing ]);
useEffect(() => {
setVisible(!isDraggingColumn);
}, [ isDraggingColumn ]);
const handleColumnDragOver = useCallback((e: DragEvent) => {
if (!isAnyColumnDragging || !onColumnHover) return;
e.preventDefault();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
onColumnHover(columnIndex, e.clientX, rect);
}, [isAnyColumnDragging, onColumnHover, columnIndex]);
return (
<div
className={`board-column ${dropTarget === column && draggedCard?.fromColumn !== column ? 'drag-over' : ''}`}
onDragOver={isAnyColumnDragging ? handleColumnDragOver : handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onWheel={handleScroll}
style={{
display: !isVisible ? "none" : undefined
}}
>
<h3
className={`${isEditing ? "editing" : ""}`}
draggable
onDragStart={handleColumnDragStart}
onDragEnd={handleColumnDragEnd}
onContextMenu={handleContextMenu}
>
{!isEditing ? (
<>
<span className="title">{column}</span>
<span
className="edit-icon icon bx bx-edit-alt"
title={t("board_view.edit-column-title")}
onClick={handleEdit}
/>
</>
) : (
<TitleEditor
currentValue={column}
save={newTitle => api.renameColumn(column, newTitle)}
dismiss={() => setColumnNameToEdit?.(undefined)}
/>
)}
</h3>
{(columnItems ?? []).map(({ note, branch }, index) => {
const showIndicatorBefore = dropPosition?.column === column &&
dropPosition.index === index &&
draggedCard?.noteId !== note.noteId;
return (
<>
{showIndicatorBefore && (
<div className="board-drop-placeholder show" />
)}
<Card
key={note.noteId}
api={api}
note={note}
branch={branch}
column={column}
index={index}
isDragging={draggedCard?.noteId === note.noteId}
/>
</>
);
})}
{dropPosition?.column === column && dropPosition.index === (columnItems?.length ?? 0) && (
<div className="board-drop-placeholder show" />
)}
<AddNewItem api={api} column={column} />
</div>
)
}
function AddNewItem({ column, api }: { column: string, api: BoardApi }) {
const [ isCreatingNewItem, setIsCreatingNewItem ] = useState(false);
const addItemCallback = useCallback(() => setIsCreatingNewItem(true), []);
return (
<div
className={`board-new-item ${isCreatingNewItem ? "editing" : ""}`}
onClick={addItemCallback}
>
{!isCreatingNewItem ? (
<>
<Icon icon="bx bx-plus" />{" "}
{t("board_view.new-item")}
</>
) : (
<TitleEditor
placeholder={t("board_view.new-item-placeholder")}
save={(title) => api.createNewItem(column, title)}
dismiss={() => setIsCreatingNewItem(false)}
multiline isNewItem
/>
)}
</div>
);
}
function useDragging({ column, columnIndex, columnItems, isEditing }: DragContext & { isEditing: boolean }) {
const { api, parentNote, draggedColumn, setDraggedColumn, setDropTarget, setDropPosition, dropPosition } = useContext(BoardViewContext)!;
/** Needed to track if current column is dragged in real-time, since {@link draggedColumn} is populated one render cycle later. */
const isDraggingRef = useRef(false);
const handleColumnDragStart = useCallback((e: DragEvent) => {
if (isEditing) return;
isDraggingRef.current = true;
e.dataTransfer!.effectAllowed = 'move';
e.dataTransfer!.setData('text/plain', column);
setDraggedColumn({ column, index: columnIndex });
e.stopPropagation(); // Prevent card drag from interfering
}, [column, columnIndex, setDraggedColumn, isEditing]);
const handleColumnDragEnd = useCallback(() => {
isDraggingRef.current = false;
setDraggedColumn(null);
}, [setDraggedColumn]);
const handleDragOver = useCallback((e: DragEvent) => {
if (isEditing || draggedColumn || isDraggingRef.current) return; // Don't handle card drops when dragging columns
if (!e.dataTransfer?.types.includes(CARD_CLIPBOARD_TYPE) && !e.dataTransfer?.types.includes(TREE_CLIPBOARD_TYPE)) return;
e.preventDefault();
setDropTarget(column);
// Calculate drop position based on mouse position
const cards = Array.from((e.currentTarget as HTMLElement)?.querySelectorAll('.board-note'));
const mouseY = e.clientY;
let newIndex = cards.length;
for (let i = 0; i < cards.length; i++) {
const card = cards[i] as HTMLElement;
const rect = card.getBoundingClientRect();
const cardMiddle = rect.top + rect.height / 2;
if (mouseY < cardMiddle) {
newIndex = i;
break;
}
}
if (!(dropPosition?.column === column && dropPosition.index === newIndex)) {
setDropPosition({ column, index: newIndex });
}
}, [column, setDropTarget, dropPosition, setDropPosition, isEditing]);
const handleDragLeave = useCallback((e: DragEvent) => {
const relatedTarget = e.relatedTarget as HTMLElement;
const currentTarget = e.currentTarget as HTMLElement;
if (!currentTarget.contains(relatedTarget)) {
setDropTarget(null);
setDropPosition(null);
}
}, [setDropTarget, setDropPosition]);
const handleDrop = useCallback(async (e: DragEvent) => {
if (draggedColumn) return; // Don't handle card drops when dragging columns
e.preventDefault();
setDropTarget(null);
setDropPosition(null);
const data = e.dataTransfer?.getData(CARD_CLIPBOARD_TYPE) || e.dataTransfer?.getData("text");
if (!data) return;
let draggedCard: CardDragData | DragData[];
try {
draggedCard = JSON.parse(data);
} catch (e) {
return;
}
if (Array.isArray(draggedCard)) {
// From note tree.
const { noteId, branchId } = draggedCard[0];
const targetNote = await froca.getNote(noteId, true);
const parentNoteId = parentNote?.noteId;
if (!parentNoteId || !dropPosition) return;
const targetIndex = dropPosition.index - 1;
const targetItems = columnItems || [];
const targetBranch = targetIndex >= 0 ? targetItems[targetIndex].branch : null;
await api?.changeColumn(noteId, column);
const parents = targetNote?.getParentNoteIds();
if (!parents?.includes(parentNoteId)) {
if (!targetBranch) {
// First.
await branches.cloneNoteToParentNote(noteId, parentNoteId);
} else {
await branches.cloneNoteAfter(noteId, targetBranch.branchId);
}
} else if (targetBranch) {
await branches.moveAfterBranch([ branchId ], targetBranch.branchId);
}
} else if (draggedCard && dropPosition) {
api?.moveWithinBoard(draggedCard.noteId, draggedCard.branchId, draggedCard.index, dropPosition.index, draggedCard.fromColumn, column);
}
}, [ api, draggedColumn, dropPosition, columnItems, column, setDropTarget, setDropPosition ]);
return { handleColumnDragStart, handleColumnDragEnd, handleDragOver, handleDragLeave, handleDrop };
}

View File

@ -0,0 +1,98 @@
import FNote from "../../../entities/fnote";
import contextMenu, { ContextMenuEvent } from "../../../menus/context_menu";
import link_context_menu from "../../../menus/link_context_menu";
import attributes from "../../../services/attributes";
import branches from "../../../services/branches";
import dialog from "../../../services/dialog";
import { t } from "../../../services/i18n";
import Api from "./api";
export function openColumnContextMenu(api: Api, event: ContextMenuEvent, column: string) {
event.preventDefault();
event.stopPropagation();
contextMenu.show({
x: event.pageX,
y: event.pageY,
items: [
{
title: t("board_view.delete-column"),
uiIcon: "bx bx-trash",
async handler() {
const confirmed = await dialog.confirm(t("board_view.delete-column-confirmation"));
if (!confirmed) {
return;
}
await api.removeColumn(column);
}
}
],
selectMenuItemHandler() {}
});
}
export function openNoteContextMenu(api: Api, event: ContextMenuEvent, note: FNote, branchId: string, column: string) {
event.preventDefault();
event.stopPropagation();
contextMenu.show({
x: event.pageX,
y: event.pageY,
items: [
...link_context_menu.getItems(),
{ title: "----" },
{
title: t("board_view.move-to"),
uiIcon: "bx bx-transfer",
items: api.columns.map(columnToMoveTo => ({
title: columnToMoveTo,
enabled: columnToMoveTo !== column,
handler: () => api.changeColumn(note.noteId, columnToMoveTo)
})),
},
getArchiveMenuItem(note),
{ title: "----" },
{
title: t("board_view.insert-above"),
uiIcon: "bx bx-list-plus",
handler: () => api.insertRowAtPosition(column, branchId, "before")
},
{
title: t("board_view.insert-below"),
uiIcon: "bx bx-empty",
handler: () => api.insertRowAtPosition(column, branchId, "after")
},
{ title: "----" },
{
title: t("board_view.remove-from-board"),
uiIcon: "bx bx-task-x",
handler: () => api.removeFromBoard(note.noteId)
},
{
title: t("board_view.delete-note"),
uiIcon: "bx bx-trash",
handler: () => branches.deleteNotes([ branchId ], false, false)
},
],
selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, note.noteId),
});
}
function getArchiveMenuItem(note: FNote) {
if (!note.isArchived) {
return {
title: t("board_view.archive-note"),
uiIcon: "bx bx-archive",
handler: () => attributes.addLabel(note.noteId, "archived")
}
} else {
return {
title: t("board_view.unarchive-note"),
uiIcon: "bx bx-archive-out",
handler: async () => {
attributes.removeOwnedLabelByName(note, "archived")
}
}
}
}

View File

@ -1,17 +1,17 @@
import FBranch from "../../../entities/fbranch";
import FNote from "../../../entities/fnote";
import { BoardData } from "./config";
import { BoardViewData } from "./index";
export type ColumnMap = Map<string, {
branch: FBranch;
note: FNote;
}[]>;
export async function getBoardData(parentNote: FNote, groupByColumn: string, persistedData: BoardData) {
export async function getBoardData(parentNote: FNote, groupByColumn: string, persistedData: BoardViewData, includeArchived: boolean) {
const byColumn: ColumnMap = new Map();
// First, scan all notes to find what columns actually exist
await recursiveGroupBy(parentNote.getChildBranches(), byColumn, groupByColumn);
await recursiveGroupBy(parentNote.getChildBranches(), byColumn, groupByColumn, includeArchived);
// Get all columns that exist in the notes
const columnsFromNotes = [...byColumn.keys()];
@ -43,7 +43,7 @@ export async function getBoardData(parentNote: FNote, groupByColumn: string, per
}
// Return updated persisted data only if there were changes
let newPersistedData: BoardData | undefined;
let newPersistedData: BoardViewData | undefined;
const hasChanges = newColumnValues.length > 0 ||
existingPersistedColumns.length !== deduplicatedColumns.length ||
!existingPersistedColumns.every((col, idx) => deduplicatedColumns[idx]?.value === col.value);
@ -61,15 +61,13 @@ export async function getBoardData(parentNote: FNote, groupByColumn: string, per
};
}
async function recursiveGroupBy(branches: FBranch[], byColumn: ColumnMap, groupByColumn: string) {
async function recursiveGroupBy(branches: FBranch[], byColumn: ColumnMap, groupByColumn: string, includeArchived: boolean) {
for (const branch of branches) {
const note = await branch.getNote();
if (!note) {
continue;
}
if (!note || (!includeArchived && note.isArchived)) continue;
if (note.hasChildren()) {
await recursiveGroupBy(note.getChildBranches(), byColumn, groupByColumn);
await recursiveGroupBy(note.getChildBranches(), byColumn, groupByColumn, includeArchived);
}
const group = note.getLabelValue(groupByColumn);

View File

@ -0,0 +1,313 @@
.board-view {
overflow-x: auto;
position: relative;
height: 100%;
user-select: none;
--card-font-size: 0.9em;
--card-line-height: 1.2;
--card-padding: 0.6em;
}
.board-view-container {
height: 100%;
display: flex;
gap: 1em;
padding: 1em;
padding-bottom: 0;
align-items: flex-start;
}
.board-view-container .board-column {
width: 250px;
flex-shrink: 0;
border: 2px solid transparent;
border-radius: 8px;
padding: 0.5em;
background-color: var(--accented-background-color);
transition: border-color 0.2s ease;
overflow-y: auto;
max-height: 100%;
}
.board-view-container .board-column.drag-over {
border-color: var(--main-text-color);
background-color: var(--hover-item-background-color);
}
.board-view-container .board-column h3 {
font-size: 1em;
margin-bottom: 0.75em;
padding: 0.5em 0.5em 0.5em 0.5em;
border-bottom: 1px solid var(--main-border-color);
cursor: grab;
position: relative;
transition: background-color 0.2s ease, border-radius 0.2s ease;
box-sizing: border-box;
background-color: transparent;
}
.board-view-container .board-column h3 {
display: flex;
align-items: center;
}
.board-view-container .board-column h3 > .title {
flex-grow: 1;
}
.board-view-container .board-column h3:active {
cursor: grabbing;
}
.board-view-container .board-column h3.editing {
cursor: default;
}
.board-view-container .board-column h3.editing input {
padding: 0;
}
.board-view-container .board-column h3:hover {
background-color: var(--hover-item-background-color);
border-radius: 4px;
}
.board-view-container .board-column h3.editing {
background-color: var(--main-background-color);
border: 1px solid var(--main-text-color);
border-radius: 4px;
}
.board-view-container .board-column h3 input {
background: transparent;
border: none;
outline: none;
font-size: inherit;
font-weight: inherit;
color: inherit;
width: 100%;
font-family: inherit;
}
.board-view-container .board-column .edit-icon {
opacity: 0;
margin-left: 0.5em;
transition: opacity 0.2s ease;
color: var(--muted-text-color);
cursor: pointer;
}
.board-view-container .board-column h3:hover .edit-icon,
.board-view-container .board-note:hover .edit-icon {
opacity: 1;
}
.board-view-container .board-note,
.board-view-container .board-new-item.editing {
box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.1);
margin: 0.65em 0;
padding: var(--card-padding);
border-radius: 5px;
cursor: move;
position: relative;
background-color: var(--main-background-color);
border: 1px solid var(--main-border-color);
opacity: 1;
line-height: var(--card-line-height);
overflow-wrap: break-word;
overflow: hidden;
font-size: var(--card-font-size);
}
.board-view-container .board-note {
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.15s ease, margin-top 0.2s ease;
}
.board-view-container .board-note.archived {
opacity: 0.5;
}
.board-view-container .board-note .icon {
margin-right: 0.25em;
display: inline;
}
.board-view-container .board-note > .edit-icon {
position: absolute;
top: 8px;
right: 4px;
padding: 2px;
background-color: var(--main-background-color);
}
.board-view-container .board-note:hover {
box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.1);
}
.board-view-container .board-note:hover > .edit-icon {
position: absolute;
top: 8px;
right: 4px;
color: var(--main-text-color);
background-color: var(--main-background-color);
padding-left: 6px;
}
.board-view-container .board-note.fade-in {
animation: fadeIn 0.15s ease-in;
}
.board-view-container .board-note.fade-out {
animation: fadeOut 0.15s ease-out forwards;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeOut {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-10px); }
}
.board-view-container .board-note.dragging {
opacity: 0.5;
transform: rotate(5deg);
z-index: 1000;
box-shadow: 4px 8px 16px rgba(0, 0, 0, 0.5);
}
.board-view-container .board-note.editing,
.board-view-container .board-new-item.editing {
box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.2);
border-color: var(--main-text-color);
display: flex;
align-items: center;
padding: 0;
}
.board-view-container .board-note.editing textarea,
.board-view-container .board-new-item textarea.form-control {
background: transparent;
border: none;
outline: none;
font-family: inherit;
font-size: inherit;
color: var(--main-text-color);
width: 100%;
padding: var(--card-padding);
line-height: var(--card-line-height);
height: auto;
field-sizing: content;
resize: none;
}
.board-drop-placeholder {
height: 40px;
margin: 0.65em 0;
padding: 0.5em;
border-radius: 5px;
background-color: rgba(0, 0, 0, 0.15);
opacity: 0;
transition: opacity 0.15s ease, height 0.2s ease;
box-sizing: border-box;
}
.board-drop-placeholder.show {
opacity: 0.6;
}
.column-drop-placeholder {
width: 250px;
flex-shrink: 0;
height: 200px;
border-radius: 8px;
background-color: rgba(0, 0, 0, 0.1);
opacity: 0;
transition: opacity 0.15s ease;
margin: 0 0.5em;
}
.column-drop-placeholder.show {
opacity: 0.6;
}
.board-new-item {
margin-top: 0.5em;
padding: 0.25em 0.5em;
border-radius: 5px;
color: var(--muted-text-color);
cursor: pointer;
transition: all 0.2s ease;
background-color: transparent;
font-size: var(--card-font-size);
}
.board-new-item:hover {
border-color: var(--main-text-color);
color: var(--main-text-color);
background-color: var(--hover-item-background-color);
}
.board-new-item .icon {
margin-right: 0.25em;
}
.board-add-column {
width: 180px;
flex-shrink: 0;
height: 60px;
border-radius: 8px;
padding: 0.5em;
background-color: var(--accented-background-color);
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--muted-text-color);
font-size: 0.9em;
align-self: flex-start;
}
.board-add-column:hover {
border-color: var(--main-text-color);
color: var(--main-text-color);
background-color: var(--hover-item-background-color);
}
.board-add-column .icon {
margin-right: 0.5em;
font-size: 1.2em;
}
.board-add-column input {
background: var(--main-background-color);
border: 1px solid var(--main-text-color);
border-radius: 4px;
padding: 0.5em;
color: var(--main-text-color);
font-family: inherit;
font-size: inherit;
width: 100%;
text-align: center;
}
.board-drag-preview {
position: fixed;
z-index: 10000;
pointer-events: none;
opacity: 0.8;
transform: rotate(5deg);
box-shadow: 4px 8px 16px rgba(0, 0, 0, 0.5);
background-color: var(--main-background-color);
border: 1px solid var(--main-border-color);
border-radius: 5px;
padding: 0.5em;
font-size: 0.9em;
max-width: 200px;
word-wrap: break-word;
}

View File

@ -0,0 +1,276 @@
import { Dispatch, StateUpdater, useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { ViewModeProps } from "../interface";
import "./index.css";
import { ColumnMap, getBoardData } from "./data";
import { useNoteLabelBoolean, useNoteLabelWithDefault, useTriliumEvent } from "../../react/hooks";
import Icon from "../../react/Icon";
import { t } from "../../../services/i18n";
import Api from "./api";
import FormTextBox from "../../react/FormTextBox";
import { createContext, JSX } from "preact";
import { onWheelHorizontalScroll } from "../../widget_utils";
import Column from "./column";
import BoardApi from "./api";
import FormTextArea from "../../react/FormTextArea";
import FNote from "../../../entities/fnote";
export interface BoardViewData {
columns?: BoardColumnData[];
}
export interface BoardColumnData {
value: string;
}
interface BoardViewContextData {
api?: BoardApi;
parentNote?: FNote;
branchIdToEdit?: string;
columnNameToEdit?: string;
setColumnNameToEdit?: Dispatch<StateUpdater<string | undefined>>;
setBranchIdToEdit?: Dispatch<StateUpdater<string | undefined>>;
draggedColumn: { column: string, index: number } | null;
setDraggedColumn: (column: { column: string, index: number } | null) => void;
dropPosition: { column: string, index: number } | null;
setDropPosition: (position: { column: string, index: number } | null) => void;
setDropTarget: (target: string | null) => void,
dropTarget: string | null;
draggedCard: { noteId: string, branchId: string, fromColumn: string, index: number } | null;
setDraggedCard: Dispatch<StateUpdater<{ noteId: string; branchId: string; fromColumn: string; index: number; } | null>>;
}
export const BoardViewContext = createContext<BoardViewContextData | undefined>(undefined);
export default function BoardView({ note: parentNote, noteIds, viewConfig, saveConfig }: ViewModeProps<BoardViewData>) {
const [ statusAttribute ] = useNoteLabelWithDefault(parentNote, "board:groupBy", "status");
const [ includeArchived ] = useNoteLabelBoolean(parentNote, "includeArchived");
const [ byColumn, setByColumn ] = useState<ColumnMap>();
const [ columns, setColumns ] = useState<string[]>();
const [ draggedCard, setDraggedCard ] = useState<{ noteId: string, branchId: string, fromColumn: string, index: number } | null>(null);
const [ dropTarget, setDropTarget ] = useState<string | null>(null);
const [ dropPosition, setDropPosition ] = useState<{ column: string, index: number } | null>(null);
const [ draggedColumn, setDraggedColumn ] = useState<{ column: string, index: number } | null>(null);
const [ columnDropPosition, setColumnDropPosition ] = useState<number | null>(null);
const [ columnHoverIndex, setColumnHoverIndex ] = useState<number | null>(null);
const [ branchIdToEdit, setBranchIdToEdit ] = useState<string>();
const [ columnNameToEdit, setColumnNameToEdit ] = useState<string>();
const api = useMemo(() => {
return new Api(byColumn, columns ?? [], parentNote, statusAttribute, viewConfig ?? {}, saveConfig, setBranchIdToEdit );
}, [ byColumn, columns, parentNote, statusAttribute, viewConfig, saveConfig, setBranchIdToEdit ]);
const boardViewContext = useMemo<BoardViewContextData>(() => ({
api,
parentNote,
branchIdToEdit, setBranchIdToEdit,
columnNameToEdit, setColumnNameToEdit,
draggedColumn, setDraggedColumn,
dropPosition, setDropPosition,
draggedCard, setDraggedCard,
dropTarget, setDropTarget
}), [
api,
parentNote,
branchIdToEdit, setBranchIdToEdit,
columnNameToEdit, setColumnNameToEdit,
draggedColumn, setDraggedColumn,
dropPosition, setDropPosition,
draggedCard, setDraggedCard,
dropTarget, setDropTarget
]);
function refresh() {
getBoardData(parentNote, statusAttribute, viewConfig ?? {}, includeArchived).then(({ byColumn, newPersistedData }) => {
setByColumn(byColumn);
if (newPersistedData) {
viewConfig = { ...newPersistedData };
saveConfig(newPersistedData);
}
// Use the order from persistedData.columns, then add any new columns found
const orderedColumns = viewConfig?.columns?.map(col => col.value) || [];
const allColumns = Array.from(byColumn.keys());
const newColumns = allColumns.filter(col => !orderedColumns.includes(col));
setColumns([...orderedColumns, ...newColumns]);
});
}
useEffect(refresh, [ parentNote, noteIds, viewConfig ]);
const handleColumnDrop = useCallback((fromIndex: number, toIndex: number) => {
const newColumns = api.reorderColumn(fromIndex, toIndex);
if (newColumns) {
setColumns(newColumns);
}
setDraggedColumn(null);
setDraggedCard(null);
setColumnDropPosition(null);
}, [api]);
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
// Check if any changes affect our board
const hasRelevantChanges =
// React to changes in status attribute for notes in this board
loadResults.getAttributeRows().some(attr => attr.name === statusAttribute && noteIds.includes(attr.noteId!)) ||
// React to changes in note title
loadResults.getNoteIds().some(noteId => noteIds.includes(noteId)) ||
// React to changes in branches for subchildren (e.g., moved, added, or removed notes)
loadResults.getBranchRows().some(branch => noteIds.includes(branch.noteId!)) ||
// React to changes in note icon or color.
loadResults.getAttributeRows().some(attr => [ "iconClass", "color" ].includes(attr.name ?? "") && noteIds.includes(attr.noteId ?? "")) ||
// React to attachment change
loadResults.getAttachmentRows().some(att => att.ownerId === parentNote.noteId && att.title === "board.json") ||
// React to changes in "groupBy"
loadResults.getAttributeRows().some(attr => attr.name === "board:groupBy" && attr.noteId === parentNote.noteId);
if (hasRelevantChanges) {
refresh();
}
});
const handleColumnDragOver = useCallback((e: DragEvent) => {
if (!draggedColumn) return;
e.preventDefault();
}, [draggedColumn]);
const handleColumnHover = useCallback((index: number, mouseX: number, columnRect: DOMRect) => {
if (!draggedColumn) return;
const columnMiddle = columnRect.left + columnRect.width / 2;
// Determine if we should insert before or after this column
const insertBefore = mouseX < columnMiddle;
// Calculate the target position
let targetIndex = insertBefore ? index : index + 1;
setColumnDropPosition(targetIndex);
}, [draggedColumn]);
const handleContainerDrop = useCallback((e: DragEvent) => {
e.preventDefault();
if (draggedColumn && columnDropPosition !== null) {
handleColumnDrop(draggedColumn.index, columnDropPosition);
}
setColumnHoverIndex(null);
}, [draggedColumn, columnDropPosition, handleColumnDrop]);
return (
<div
className="board-view"
onWheel={onWheelHorizontalScroll}
>
<BoardViewContext.Provider value={boardViewContext}>
<div
className="board-view-container"
onDragOver={handleColumnDragOver}
onDrop={handleContainerDrop}
>
{byColumn && columns?.map((column, index) => (
<>
{columnDropPosition === index && (
<div className="column-drop-placeholder show" />
)}
<Column
api={api}
column={column}
columnIndex={index}
columnItems={byColumn.get(column)}
isDraggingColumn={draggedColumn?.column === column}
onColumnHover={handleColumnHover}
isAnyColumnDragging={!!draggedColumn}
/>
</>
))}
{columnDropPosition === columns?.length && draggedColumn && (
<div className="column-drop-placeholder show" />
)}
<AddNewColumn api={api} />
</div>
</BoardViewContext.Provider>
</div>
)
}
function AddNewColumn({ api }: { api: BoardApi }) {
const [ isCreatingNewColumn, setIsCreatingNewColumn ] = useState(false);
const addColumnCallback = useCallback(() => {
setIsCreatingNewColumn(true);
}, []);
return (
<div className={`board-add-column ${isCreatingNewColumn ? "editing" : ""}`} onClick={addColumnCallback}>
{!isCreatingNewColumn
? <>
<Icon icon="bx bx-plus" />{" "}
{t("board_view.add-column")}
</>
: (
<TitleEditor
placeholder={t("board_view.add-column-placeholder")}
save={(columnName) => api.addNewColumn(columnName)}
dismiss={() => setIsCreatingNewColumn(false)}
isNewItem
/>
)}
</div>
)
}
export function TitleEditor({ currentValue, placeholder, save, dismiss, multiline, isNewItem }: {
currentValue?: string;
placeholder?: string;
save: (newValue: string) => void;
dismiss: () => void;
multiline?: boolean;
isNewItem?: boolean;
}) {
const inputRef = useRef<any>(null);
const dismissOnNextRefreshRef = useRef(false);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, [ inputRef ]);
const Element = multiline ? FormTextArea : FormTextBox;
useEffect(() => {
if (dismissOnNextRefreshRef.current) {
dismiss();
dismissOnNextRefreshRef.current = false;
}
});
return (
<Element
inputRef={inputRef}
currentValue={currentValue ?? ""}
placeholder={placeholder}
autoComplete="trilium-title-entry" // forces the auto-fill off better than the "off" value.
rows={multiline ? 4 : undefined}
onKeyDown={(e: JSX.TargetedKeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (e.key === "Enter") {
const newValue = e.currentTarget?.value;
if (newValue.trim() && (newValue !== currentValue || isNewItem)) {
save(newValue);
dismissOnNextRefreshRef.current = true;
}
}
if (e.key === "Escape") {
dismiss();
}
}}
onBlur={(newValue) => {
if (newValue.trim() && (newValue !== currentValue || isNewItem)) {
save(newValue);
dismissOnNextRefreshRef.current = true;
} else {
dismiss();
}
}}
/>
);
}

View File

@ -0,0 +1,65 @@
import { CreateChildrenResponse } from "@triliumnext/commons";
import server from "../../../services/server";
import FNote from "../../../entities/fnote";
import { setAttribute, setLabel } from "../../../services/attributes";
import froca from "../../../services/froca";
interface NewEventOpts {
title: string;
startDate: string;
endDate?: string | null;
startTime?: string | null;
endTime?: string | null;
}
interface ChangeEventOpts {
startDate: string;
endDate?: string | null;
startTime?: string | null;
endTime?: string | null;
}
export async function newEvent(parentNote: FNote, { title, startDate, endDate, startTime, endTime }: NewEventOpts) {
// Create the note.
const { note } = await server.post<CreateChildrenResponse>(`notes/${parentNote.noteId}/children?target=into`, {
title,
content: "",
type: "text"
});
// Set the attributes.
setLabel(note.noteId, "startDate", startDate);
if (endDate) {
setLabel(note.noteId, "endDate", endDate);
}
if (startTime) {
setLabel(note.noteId, "startTime", startTime);
}
if (endTime) {
setLabel(note.noteId, "endTime", endTime);
}
}
export async function changeEvent(note: FNote, { startDate, endDate, startTime, endTime }: ChangeEventOpts) {
// Don't store the end date if it's empty.
if (endDate === startDate) {
endDate = undefined;
}
// Since they can be customized via calendar:startDate=$foo and calendar:endDate=$bar we need to determine the
// attributes to be effectively updated
let startAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:startDate").shift()?.value||"startDate";
let endAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:endDate").shift()?.value||"endDate";
const noteId = note.noteId;
setLabel(noteId, startAttribute, startDate);
setAttribute(note, "label", endAttribute, endDate);
startAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:startTime").shift()?.value||"startTime";
endAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:endTime").shift()?.value||"endTime";
if (startTime && endTime) {
setAttribute(note, "label", startAttribute, startTime);
setAttribute(note, "label", endAttribute, endTime);
}
}

View File

@ -0,0 +1,32 @@
import { useEffect, useLayoutEffect, useRef } from "preact/hooks";
import { CalendarOptions, Calendar as FullCalendar, PluginDef } from "@fullcalendar/core";
import { RefObject } from "preact";
interface CalendarProps extends CalendarOptions {
calendarRef?: RefObject<FullCalendar>;
}
export default function Calendar({ calendarRef, ...options }: CalendarProps) {
const containerRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (!containerRef.current) return;
const calendar = new FullCalendar(containerRef.current, options);
calendar.render();
if (calendarRef) {
calendarRef.current = calendar;
}
return () => calendar.destroy();
}, [ ]);
useEffect(() => {
calendarRef?.current?.resetOptions(options);
}, [ options ]);
return (
<div ref={containerRef} className="calendar-container" />
);
}

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { buildNote, buildNotes } from "../../test/easy-froca.js";
import CalendarView, { getFullCalendarLocale } from "./calendar_view.js";
import { buildNote, buildNotes } from "../../../test/easy-froca.js";
import { buildEvent, buildEvents } from "./event_builder.js";
import { LOCALE_MAPPINGS } from "./index.js";
import { LOCALES } from "@triliumnext/commons";
describe("Building events", () => {
@ -9,7 +10,7 @@ describe("Building events", () => {
{ title: "Note 1", "#startDate": "2025-05-05" },
{ title: "Note 2", "#startDate": "2025-05-07" },
]);
const events = await CalendarView.buildEvents(noteIds);
const events = await buildEvents(noteIds);
expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({ title: "Note 1", start: "2025-05-05", end: "2025-05-06" });
@ -21,7 +22,7 @@ describe("Building events", () => {
{ title: "Note 1", "#endDate": "2025-05-05" },
{ title: "Note 2", "#endDateDate": "2025-05-07" }
]);
const events = await CalendarView.buildEvents(noteIds);
const events = await buildEvents(noteIds);
expect(events).toHaveLength(0);
});
@ -31,7 +32,7 @@ describe("Building events", () => {
{ title: "Note 1", "#startDate": "2025-05-05", "#endDate": "2025-05-05" },
{ title: "Note 2", "#startDate": "2025-05-07", "#endDate": "2025-05-08" },
]);
const events = await CalendarView.buildEvents(noteIds);
const events = await buildEvents(noteIds);
expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({ title: "Note 1", start: "2025-05-05", end: "2025-05-06" });
@ -43,7 +44,7 @@ describe("Building events", () => {
{ title: "Note 1", "#myStartDate": "2025-05-05", "#calendar:startDate": "myStartDate" },
{ title: "Note 2", "#startDate": "2025-05-07", "#calendar:startDate": "myStartDate" },
]);
const events = await CalendarView.buildEvents(noteIds);
const events = await buildEvents(noteIds);
expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({
@ -65,7 +66,7 @@ describe("Building events", () => {
{ title: "Note 3", "#startDate": "2025-05-05", "#myEndDate": "2025-05-05", "#calendar:startDate": "myStartDate", "#calendar:endDate": "myEndDate" },
{ title: "Note 4", "#startDate": "2025-05-07", "#myEndDate": "2025-05-08", "#calendar:startDate": "myStartDate", "#calendar:endDate": "myEndDate" },
]);
const events = await CalendarView.buildEvents(noteIds);
const events = await buildEvents(noteIds);
expect(events).toHaveLength(4);
expect(events[0]).toMatchObject({ title: "Note 1", start: "2025-05-05", end: "2025-05-06" });
@ -79,7 +80,7 @@ describe("Building events", () => {
{ title: "Note 1", "#myTitle": "My Custom Title 1", "#startDate": "2025-05-05", "#calendar:title": "myTitle" },
{ title: "Note 2", "#startDate": "2025-05-07", "#calendar:title": "myTitle" },
]);
const events = await CalendarView.buildEvents(noteIds);
const events = await buildEvents(noteIds);
expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({ title: "My Custom Title 1", start: "2025-05-05" });
@ -92,7 +93,7 @@ describe("Building events", () => {
{ title: "Note 1", "~myTitle": "mySharedTitle", "#startDate": "2025-05-05", "#calendar:title": "myTitle" },
{ title: "Note 2", "#startDate": "2025-05-07", "#calendar:title": "myTitle" },
]);
const events = await CalendarView.buildEvents(noteIds);
const events = await buildEvents(noteIds);
expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({ title: "My shared title", start: "2025-05-05" });
@ -105,7 +106,7 @@ describe("Building events", () => {
{ title: "Note 1", "~myTitle": "mySharedTitle", "#startDate": "2025-05-05", "#calendar:title": "myTitle" },
{ title: "Note 2", "#startDate": "2025-05-07", "#calendar:title": "myTitle" },
]);
const events = await CalendarView.buildEvents(noteIds);
const events = await buildEvents(noteIds);
expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({ title: "My shared custom title", start: "2025-05-05" });
@ -125,7 +126,7 @@ describe("Promoted attributes", () => {
"#calendar:displayedAttributes": "weight,mood"
});
const event = await CalendarView.buildEvent(note, { startDate: "2025-04-04" });
const event = await buildEvent(note, { startDate: "2025-04-04" });
expect(event).toHaveLength(1);
expect(event[0]?.promotedAttributes).toMatchObject([
[ "weight", "75" ],
@ -143,7 +144,7 @@ describe("Promoted attributes", () => {
"#relation:assignee": "promoted,alias=Assignee,single,text",
});
const event = await CalendarView.buildEvent(note, { startDate: "2025-04-04" });
const event = await buildEvent(note, { startDate: "2025-04-04" });
expect(event).toHaveLength(1);
expect(event[0]?.promotedAttributes).toMatchObject([
[ "assignee", "Target note" ]
@ -155,7 +156,7 @@ describe("Promoted attributes", () => {
{ title: "Note 1", "#startDate": "2025-05-05", "#startTime": "13:36", "#endTime": "14:56" },
{ title: "Note 2", "#startDate": "2025-05-07", "#endDate": "2025-05-08", "#startTime": "13:36", "#endTime": "14:56" },
]);
const events = await CalendarView.buildEvents(noteIds);
const events = await buildEvents(noteIds);
expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({ title: "Note 1", start: "2025-05-05T13:36:00", end: "2025-05-05T14:56:00" });
@ -167,7 +168,7 @@ describe("Promoted attributes", () => {
{ title: "Note 1", "#startDate": "2025-05-05", "#startTime": "13:30" },
{ title: "Note 2", "#startDate": "2025-05-07", "#endDate": "2025-05-08", "#startTime": "13:36" },
]);
const events = await CalendarView.buildEvents(noteIds);
const events = await buildEvents(noteIds);
expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({ title: "Note 1", start: "2025-05-05T13:30:00" });
@ -183,12 +184,12 @@ describe("Building locales", () => {
continue;
}
const fullCalendarLocale = await getFullCalendarLocale(id);
const fullCalendarLocale = LOCALE_MAPPINGS[id];
if (id !== "en") {
expect(fullCalendarLocale, `For locale ${id}`).toBeDefined();
} else {
expect(fullCalendarLocale).toBeUndefined();
expect(fullCalendarLocale).toBeNull();
}
}
});

View File

@ -0,0 +1,160 @@
import { EventInput, EventSourceFuncArg, EventSourceInput } from "@fullcalendar/core/index.js";
import froca from "../../../services/froca";
import { formatDateToLocalISO, getCustomisableLabel, getMonthsInDateRange, offsetDate } from "./utils";
import FNote from "../../../entities/fnote";
import server from "../../../services/server";
interface Event {
startDate: string,
endDate?: string | null,
startTime?: string | null,
endTime?: string | null,
isArchived?: boolean;
}
export async function buildEvents(noteIds: string[]) {
const notes = await froca.getNotes(noteIds);
const events: EventSourceInput = [];
for (const note of notes) {
const startDate = getCustomisableLabel(note, "startDate", "calendar:startDate");
if (!startDate) {
continue;
}
const endDate = getCustomisableLabel(note, "endDate", "calendar:endDate");
const startTime = getCustomisableLabel(note, "startTime", "calendar:startTime");
const endTime = getCustomisableLabel(note, "endTime", "calendar:endTime");
const isArchived = note.hasLabel("archived");
events.push(await buildEvent(note, { startDate, endDate, startTime, endTime, isArchived }));
}
return events.flat();
}
export async function buildEventsForCalendar(note: FNote, e: EventSourceFuncArg) {
const events: EventInput[] = [];
// Gather all the required date note IDs.
const dateRange = getMonthsInDateRange(e.startStr, e.endStr);
let allDateNoteIds: string[] = [];
for (const month of dateRange) {
// TODO: Deduplicate get type.
const dateNotesForMonth = await server.get<Record<string, string>>(`special-notes/notes-for-month/${month}?calendarRoot=${note.noteId}`);
const dateNoteIds = Object.values(dateNotesForMonth);
allDateNoteIds = [...allDateNoteIds, ...dateNoteIds];
}
// Request all the date notes.
const dateNotes = await froca.getNotes(allDateNoteIds);
const childNoteToDateMapping: Record<string, string> = {};
for (const dateNote of dateNotes) {
const startDate = dateNote.getLabelValue("dateNote");
if (!startDate) {
continue;
}
events.push(await buildEvent(dateNote, { startDate }));
if (dateNote.hasChildren()) {
const childNoteIds = await dateNote.getSubtreeNoteIds();
for (const childNoteId of childNoteIds) {
childNoteToDateMapping[childNoteId] = startDate;
}
}
}
// Request all child notes of date notes in a single run.
const childNoteIds = Object.keys(childNoteToDateMapping);
const childNotes = await froca.getNotes(childNoteIds);
for (const childNote of childNotes) {
const startDate = childNoteToDateMapping[childNote.noteId];
const event = await buildEvent(childNote, { startDate });
events.push(event);
}
return events.flat();
}
export async function buildEvent(note: FNote, { startDate, endDate, startTime, endTime, isArchived }: Event) {
const customTitleAttributeName = note.getLabelValue("calendar:title");
const titles = await parseCustomTitle(customTitleAttributeName, note);
const color = note.getLabelValue("calendar:color") ?? note.getLabelValue("color");
const events: EventInput[] = [];
const calendarDisplayedAttributes = note.getLabelValue("calendar:displayedAttributes")?.split(",");
let displayedAttributesData: Array<[string, string]> | null = null;
if (calendarDisplayedAttributes) {
displayedAttributesData = await buildDisplayedAttributes(note, calendarDisplayedAttributes);
}
for (const title of titles) {
if (startTime && endTime && !endDate) {
endDate = startDate;
}
startDate = (startTime ? `${startDate}T${startTime}:00` : startDate);
if (!startTime) {
const endDateOffset = offsetDate(endDate ?? startDate, 1);
if (endDateOffset) {
endDate = formatDateToLocalISO(endDateOffset);
}
}
endDate = (endTime ? `${endDate}T${endTime}:00` : endDate);
const eventData: EventInput = {
title: title,
start: startDate,
url: `#${note.noteId}?popup`,
noteId: note.noteId,
color: color ?? undefined,
iconClass: note.getLabelValue("iconClass"),
promotedAttributes: displayedAttributesData,
className: isArchived ? "archived" : ""
};
if (endDate) {
eventData.end = endDate;
}
events.push(eventData);
}
return events;
}
async function parseCustomTitle(customTitlettributeName: string | null, note: FNote, allowRelations = true): Promise<string[]> {
if (customTitlettributeName) {
const labelValue = note.getAttributeValue("label", customTitlettributeName);
if (labelValue) return [labelValue];
if (allowRelations) {
const relations = note.getRelations(customTitlettributeName);
if (relations.length > 0) {
const noteIds = relations.map((r) => r.targetNoteId);
const notesFromRelation = await froca.getNotes(noteIds);
const titles: string[][] = [];
for (const targetNote of notesFromRelation) {
const targetCustomTitleValue = targetNote.getAttributeValue("label", "calendar:title");
const targetTitles = await parseCustomTitle(targetCustomTitleValue, targetNote, false);
titles.push(targetTitles.flat());
}
return titles.flat();
}
}
}
return [note.title];
}
async function buildDisplayedAttributes(note: FNote, calendarDisplayedAttributes: string[]) {
const filteredDisplayedAttributes = note.getAttributes().filter((attr): boolean => calendarDisplayedAttributes.includes(attr.name))
const result: Array<[string, string]> = [];
for (const attribute of filteredDisplayedAttributes) {
if (attribute.type === "label") result.push([attribute.name, attribute.value]);
else result.push([attribute.name, (await attribute.getTargetNote())?.title || ""])
}
return result;
}

View File

@ -0,0 +1,76 @@
.calendar-view {
overflow: hidden;
position: relative;
height: 100%;
user-select: none;
padding: 10px;
}
.calendar-view a {
color: unset;
}
.search-result-widget-content .calendar-view {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.calendar-container {
height: 100%;
--fc-page-bg-color: var(--main-background-color);
--fc-border-color: var(--main-border-color);
--fc-neutral-bg-color: var(--launcher-pane-background-color);
--fc-list-event-hover-bg-color: var(--left-pane-item-hover-background);
}
.calendar-container .fc-list-sticky .fc-list-day > * {
z-index: 50;
}
.calendar-container a.fc-event {
text-decoration: none;
}
.calendar-container a.fc-event.archived {
opacity: 0.5;
}
.calendar-container .fc-button {
padding: 0.2em 0.5em;
}
.calendar-container .promoted-attribute {
font-size: 0.85em;
opacity: 0.85;
overflow: hidden;
}
/* #region Header */
.calendar-view .calendar-header {
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 10px;
}
.calendar-view .calendar-header .btn {
min-width: unset !important;
}
.calendar-view .calendar-header > .title {
flex-grow: 1;
font-size: 1.3rem;
font-weight: normal;
}
body.desktop:not(.zen) .calendar-view .calendar-header {
padding-right: 5em;
}
.search-result-widget-content .calendar-view .calendar-header {
padding-right: unset !important;
}
/* #endregion */

View File

@ -0,0 +1,354 @@
import { DateSelectArg, EventChangeArg, EventMountArg, EventSourceFuncArg, LocaleInput, PluginDef } from "@fullcalendar/core/index.js";
import { ViewModeProps } from "../interface";
import Calendar from "./calendar";
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import "./index.css";
import { useNoteLabel, useNoteLabelBoolean, useResizeObserver, useSpacedUpdate, useTouchBar, useTriliumEvent, useTriliumOption, useTriliumOptionInt } from "../../react/hooks";
import { LOCALE_IDS } from "@triliumnext/commons";
import { Calendar as FullCalendar } from "@fullcalendar/core";
import { parseStartEndDateFromEvent, parseStartEndTimeFromEvent } from "./utils";
import dialog from "../../../services/dialog";
import { t } from "../../../services/i18n";
import { buildEvents, buildEventsForCalendar } from "./event_builder";
import { changeEvent, newEvent } from "./api";
import froca from "../../../services/froca";
import date_notes from "../../../services/date_notes";
import appContext from "../../../components/app_context";
import { DateClickArg } from "@fullcalendar/interaction";
import FNote from "../../../entities/fnote";
import Button, { ButtonGroup } from "../../react/Button";
import ActionButton from "../../react/ActionButton";
import { RefObject } from "preact";
import TouchBar, { TouchBarButton, TouchBarLabel, TouchBarSegmentedControl, TouchBarSpacer } from "../../react/TouchBar";
interface CalendarViewData {
}
interface CalendarViewData {
type: string;
name: string;
previousText: string;
nextText: string;
}
const CALENDAR_VIEWS = [
{
type: "timeGridWeek",
name: t("calendar.week"),
previousText: t("calendar.week_previous"),
nextText: t("calendar.week_next")
},
{
type: "dayGridMonth",
name: t("calendar.month"),
previousText: t("calendar.month_previous"),
nextText: t("calendar.month_next")
},
{
type: "multiMonthYear",
name: t("calendar.year"),
previousText: t("calendar.year_previous"),
nextText: t("calendar.year_next")
},
{
type: "listMonth",
name: t("calendar.list"),
previousText: t("calendar.month_previous"),
nextText: t("calendar.month_next")
}
]
const SUPPORTED_CALENDAR_VIEW_TYPE = CALENDAR_VIEWS.map(v => v.type);
// Here we hard-code the imports in order to ensure that they are embedded by webpack without having to load all the languages.
export const LOCALE_MAPPINGS: Record<LOCALE_IDS, (() => Promise<{ default: LocaleInput }>) | null> = {
de: () => import("@fullcalendar/core/locales/de"),
es: () => import("@fullcalendar/core/locales/es"),
fr: () => import("@fullcalendar/core/locales/fr"),
cn: () => import("@fullcalendar/core/locales/zh-cn"),
tw: () => import("@fullcalendar/core/locales/zh-tw"),
ro: () => import("@fullcalendar/core/locales/ro"),
ru: () => import("@fullcalendar/core/locales/ru"),
ja: () => import("@fullcalendar/core/locales/ja"),
"pt_br": () => import("@fullcalendar/core/locales/pt-br"),
uk: () => import("@fullcalendar/core/locales/uk"),
en: null
};
export default function CalendarView({ note, noteIds }: ViewModeProps<CalendarViewData>) {
const containerRef = useRef<HTMLDivElement>(null);
const calendarRef = useRef<FullCalendar>(null);
const [ calendarRoot ] = useNoteLabelBoolean(note, "calendarRoot");
const [ workspaceCalendarRoot ] = useNoteLabelBoolean(note, "workspaceCalendarRoot");
const [ firstDayOfWeek ] = useTriliumOptionInt("firstDayOfWeek");
const [ hideWeekends ] = useNoteLabelBoolean(note, "calendar:hideWeekends");
const [ weekNumbers ] = useNoteLabelBoolean(note, "calendar:weekNumbers");
const [ calendarView, setCalendarView ] = useNoteLabel(note, "calendar:view");
const initialView = useRef(calendarView);
const viewSpacedUpdate = useSpacedUpdate(() => setCalendarView(initialView.current));
useResizeObserver(containerRef, () => calendarRef.current?.updateSize());
const isCalendarRoot = (calendarRoot || workspaceCalendarRoot);
const isEditable = !isCalendarRoot;
const eventBuilder = useMemo(() => {
if (!isCalendarRoot) {
return async () => await buildEvents(noteIds);
} else {
return async (e: EventSourceFuncArg) => await buildEventsForCalendar(note, e);
}
}, [isCalendarRoot, noteIds]);
const plugins = usePlugins(isEditable, isCalendarRoot);
const locale = useLocale();
const { eventDidMount } = useEventDisplayCustomization();
const editingProps = useEditing(note, isEditable, isCalendarRoot);
// React to changes.
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
if (loadResults.getNoteIds().some(noteId => noteIds.includes(noteId)) // note title change.
|| loadResults.getAttributeRows().some((a) => noteIds.includes(a.noteId ?? ""))) // subnote change.
{
calendarRef.current?.refetchEvents();
}
});
return (plugins &&
<div className="calendar-view" ref={containerRef} tabIndex={100}>
<CalendarHeader calendarRef={calendarRef} />
<Calendar
events={eventBuilder}
calendarRef={calendarRef}
plugins={plugins}
initialView={initialView.current && SUPPORTED_CALENDAR_VIEW_TYPE.includes(initialView.current) ? initialView.current : "dayGridMonth"}
headerToolbar={false}
firstDay={firstDayOfWeek ?? 0}
weekends={!hideWeekends}
weekNumbers={weekNumbers}
height="90%"
nowIndicator
handleWindowResize={false}
locale={locale}
{...editingProps}
eventDidMount={eventDidMount}
viewDidMount={({ view }) => {
if (initialView.current !== view.type) {
initialView.current = view.type;
viewSpacedUpdate.scheduleUpdate();
}
}}
/>
<CalendarTouchBar calendarRef={calendarRef} />
</div>
);
}
function CalendarHeader({ calendarRef }: { calendarRef: RefObject<FullCalendar> }) {
const { title, viewType: currentViewType } = useOnDatesSet(calendarRef);
const currentViewData = CALENDAR_VIEWS.find(v => calendarRef.current && v.type === currentViewType);
return (
<div className="calendar-header">
<span className="title">{title}</span>
<ButtonGroup>
{CALENDAR_VIEWS.map(viewData => (
<Button
text={viewData.name.toLocaleLowerCase()}
className={currentViewType === viewData.type ? "active" : ""}
onClick={() => calendarRef.current?.changeView(viewData.type)}
/>
))}
</ButtonGroup>
<Button text={t("calendar.today").toLocaleLowerCase()} onClick={() => calendarRef.current?.today()} />
<ButtonGroup>
<ActionButton icon="bx bx-chevron-left" text={currentViewData?.previousText ?? ""} frame onClick={() => calendarRef.current?.prev()} />
<ActionButton icon="bx bx-chevron-right" text={currentViewData?.nextText ?? ""} frame onClick={() => calendarRef.current?.next()} />
</ButtonGroup>
</div>
)
}
function usePlugins(isEditable: boolean, isCalendarRoot: boolean) {
const [ plugins, setPlugins ] = useState<PluginDef[]>();
useEffect(() => {
async function loadPlugins() {
const plugins: PluginDef[] = [];
plugins.push((await import("@fullcalendar/daygrid")).default);
plugins.push((await import("@fullcalendar/timegrid")).default);
plugins.push((await import("@fullcalendar/list")).default);
plugins.push((await import("@fullcalendar/multimonth")).default);
if (isEditable || isCalendarRoot) {
plugins.push((await import("@fullcalendar/interaction")).default);
}
setPlugins(plugins);
}
loadPlugins();
}, [ isEditable, isCalendarRoot ]);
return plugins;
}
function useLocale() {
const [ locale ] = useTriliumOption("locale");
const [ calendarLocale, setCalendarLocale ] = useState<LocaleInput>();
useEffect(() => {
const correspondingLocale = LOCALE_MAPPINGS[locale];
if (correspondingLocale) {
correspondingLocale().then((locale) => setCalendarLocale(locale.default));
} else {
setCalendarLocale(undefined);
}
});
return calendarLocale;
}
function useEditing(note: FNote, isEditable: boolean, isCalendarRoot: boolean) {
const onCalendarSelection = useCallback(async (e: DateSelectArg) => {
const { startDate, endDate } = parseStartEndDateFromEvent(e);
if (!startDate) return;
const { startTime, endTime } = parseStartEndTimeFromEvent(e);
// Ask for the title
const title = await dialog.prompt({ message: t("relation_map.enter_title_of_new_note"), defaultValue: t("relation_map.default_new_note_title") });
if (!title?.trim()) {
return;
}
newEvent(note, { title, startDate, endDate, startTime, endTime });
}, [ note ]);
const onEventChange = useCallback(async (e: EventChangeArg) => {
const { startDate, endDate } = parseStartEndDateFromEvent(e.event);
if (!startDate) return;
const { startTime, endTime } = parseStartEndTimeFromEvent(e.event);
const note = await froca.getNote(e.event.extendedProps.noteId);
if (!note) return;
changeEvent(note, { startDate, endDate, startTime, endTime });
}, []);
// Called upon when clicking the day number in the calendar, opens or creates the day note but only if in a calendar root.
const onDateClick = useCallback(async (e: DateClickArg) => {
const eventNote = await date_notes.getDayNote(e.dateStr);
if (eventNote) {
appContext.triggerCommand("openInPopup", { noteIdOrPath: eventNote.noteId });
}
}, []);
return {
select: onCalendarSelection,
eventChange: onEventChange,
dateClick: isCalendarRoot ? onDateClick : undefined,
editable: isEditable,
selectable: isEditable
};
}
function useEventDisplayCustomization() {
const eventDidMount = useCallback((e: EventMountArg) => {
const { iconClass, promotedAttributes } = e.event.extendedProps;
// Prepend the icon to the title, if any.
if (iconClass) {
let titleContainer;
switch (e.view.type) {
case "timeGridWeek":
case "dayGridMonth":
titleContainer = e.el.querySelector(".fc-event-title");
break;
case "multiMonthYear":
break;
case "listMonth":
titleContainer = e.el.querySelector(".fc-list-event-title a");
break;
}
if (titleContainer) {
const icon = /*html*/`<span class="${iconClass}"></span> `;
titleContainer.insertAdjacentHTML("afterbegin", icon);
}
}
// Append promoted attributes to the end of the event container.
if (promotedAttributes) {
let promotedAttributesHtml = "";
for (const [name, value] of promotedAttributes) {
promotedAttributesHtml = promotedAttributesHtml + /*html*/`\
<div class="promoted-attribute">
<span class="promoted-attribute-name">${name}</span>: <span class="promoted-attribute-value">${value}</span>
</div>`;
}
let mainContainer;
switch (e.view.type) {
case "timeGridWeek":
case "dayGridMonth":
mainContainer = e.el.querySelector(".fc-event-main");
break;
case "multiMonthYear":
break;
case "listMonth":
mainContainer = e.el.querySelector(".fc-list-event-title");
break;
}
$(mainContainer ?? e.el).append($(promotedAttributesHtml));
}
}, []);
return { eventDidMount };
}
function CalendarTouchBar({ calendarRef }: { calendarRef: RefObject<FullCalendar> }) {
const { title, viewType } = useOnDatesSet(calendarRef);
return (
<TouchBar>
<TouchBarSegmentedControl
mode="single"
segments={CALENDAR_VIEWS.map(({ name }) => ({
label: name,
}))}
selectedIndex={CALENDAR_VIEWS.findIndex(v => v.type === viewType) ?? 0}
onChange={(selectedIndex) => calendarRef.current?.changeView(CALENDAR_VIEWS[selectedIndex].type)}
/>
<TouchBarSpacer size="flexible" />
<TouchBarLabel label={title ?? ""} />
<TouchBarSpacer size="flexible" />
<TouchBarButton
label={t("calendar.today")}
click={() => calendarRef.current?.today()}
/>
<TouchBarButton
icon="NSImageNameTouchBarGoBackTemplate"
click={() => calendarRef.current?.prev()}
/>
<TouchBarButton
icon="NSImageNameTouchBarGoForwardTemplate"
click={() => calendarRef.current?.next()}
/>
</TouchBar>
);
}
function useOnDatesSet(calendarRef: RefObject<FullCalendar>) {
const [ title, setTitle ] = useState<string>();
const [ viewType ,setViewType ] = useState<string>();
useEffect(() => {
const api = calendarRef.current;
if (!api) return;
const handler = () => {
setTitle(api.view.title);
setViewType(api.view.type);
};
handler();
api.on("datesSet", handler);
return () => api.off("datesSet", handler);
}, [calendarRef]);
return { title, viewType };
}

View File

@ -0,0 +1,103 @@
import { DateSelectArg } from "@fullcalendar/core/index.js";
import { EventImpl } from "@fullcalendar/core/internal";
import FNote from "../../../entities/fnote";
export function parseStartEndDateFromEvent(e: DateSelectArg | EventImpl) {
const startDate = formatDateToLocalISO(e.start);
if (!startDate) {
return { startDate: null, endDate: null };
}
let endDate;
if (e.allDay) {
endDate = formatDateToLocalISO(offsetDate(e.end, -1));
} else {
endDate = formatDateToLocalISO(e.end);
}
return { startDate, endDate };
}
export function parseStartEndTimeFromEvent(e: DateSelectArg | EventImpl) {
let startTime: string | undefined | null = null;
let endTime: string | undefined | null = null;
if (!e.allDay) {
startTime = formatTimeToLocalISO(e.start);
endTime = formatTimeToLocalISO(e.end);
}
return { startTime, endTime };
}
export function formatDateToLocalISO(date: Date | null | undefined) {
if (!date) {
return undefined;
}
const offset = date.getTimezoneOffset();
const localDate = new Date(date.getTime() - offset * 60 * 1000);
return localDate.toISOString().split("T")[0];
}
export function offsetDate(date: Date | string | null | undefined, offset: number) {
if (!date) {
return undefined;
}
const newDate = new Date(date);
newDate.setDate(newDate.getDate() + offset);
return newDate;
}
export function formatTimeToLocalISO(date: Date | null | undefined) {
if (!date) {
return undefined;
}
const offset = date.getTimezoneOffset();
const localDate = new Date(date.getTime() - offset * 60 * 1000);
return localDate.toISOString()
.split("T")[1]
.substring(0, 5);
}
/**
* Allows the user to customize the attribute from which to obtain a particular value. For example, if `customLabelNameAttribute` is `calendar:startDate`
* and `defaultLabelName` is `startDate` and the note at hand has `#calendar:startDate=myStartDate #myStartDate=2025-02-26` then the value returned will
* be `2025-02-26`. If there is no custom attribute value, then the value of the default attribute is returned instead (e.g. `#startDate`).
*
* @param note the note from which to read the values.
* @param defaultLabelName the name of the label in case a custom value is not found.
* @param customLabelNameAttribute the name of the label to look for a custom value.
* @returns the value of either the custom label or the default label.
*/
export function getCustomisableLabel(note: FNote, defaultLabelName: string, customLabelNameAttribute: string) {
const customAttributeName = note.getLabelValue(customLabelNameAttribute);
if (customAttributeName) {
const customValue = note.getLabelValue(customAttributeName);
if (customValue) {
return customValue;
}
}
return note.getLabelValue(defaultLabelName);
}
// Source: https://stackoverflow.com/a/30465299/4898894
export function getMonthsInDateRange(startDate: string, endDate: string) {
const start = startDate.split("-");
const end = endDate.split("-");
const startYear = parseInt(start[0]);
const endYear = parseInt(end[0]);
const dates: string[] = [];
for (let i = startYear; i <= endYear; i++) {
const endMonth = i != endYear ? 11 : parseInt(end[1]) - 1;
const startMon = i === startYear ? parseInt(start[1]) - 1 : 0;
for (let j = startMon; j <= endMonth; j = j > 12 ? j % 12 || 11 : j + 1) {
const month = j + 1;
const displayMonth = month < 10 ? "0" + month : month;
dates.push([i, displayMonth].join("-"));
}
}
return dates;
}

View File

@ -0,0 +1,28 @@
import type { LatLng, LeafletMouseEvent } from "leaflet";
import { LOCATION_ATTRIBUTE } from ".";
import attributes from "../../../services/attributes";
import { prompt } from "../../../services/dialog";
import server from "../../../services/server";
import { t } from "../../../services/i18n";
import { CreateChildrenResponse } from "@triliumnext/commons";
const CHILD_NOTE_ICON = "bx bx-pin";
export async function moveMarker(noteId: string, latLng: LatLng | null) {
const value = latLng ? [latLng.lat, latLng.lng].join(",") : "";
await attributes.setLabel(noteId, LOCATION_ATTRIBUTE, value);
}
export async function createNewNote(noteId: string, e: LeafletMouseEvent) {
const title = await prompt({ message: t("relation_map.enter_title_of_new_note"), defaultValue: t("relation_map.default_new_note_title") });
if (title?.trim()) {
const { note } = await server.post<CreateChildrenResponse>(`notes/${noteId}/children?target=into`, {
title,
content: "",
type: "text"
});
attributes.setLabel(note.noteId, "iconClass", CHILD_NOTE_ICON);
moveMarker(note.noteId, e.latlng);
}
}

View File

@ -3,7 +3,7 @@ import appContext, { type CommandMappings } from "../../../components/app_contex
import contextMenu, { type MenuItem } from "../../../menus/context_menu.js";
import linkContextMenu from "../../../menus/link_context_menu.js";
import { t } from "../../../services/i18n.js";
import { createNewNote } from "./editing.js";
import { createNewNote } from "./api.js";
import { copyTextWithToast } from "../../../services/clipboard_ext.js";
import link from "../../../services/link.js";

View File

@ -0,0 +1,78 @@
.geo-view {
overflow: hidden;
position: relative;
height: 100%;
}
.geo-map-container {
height: 100%;
overflow: hidden;
}
.leaflet-pane {
z-index: 1;
}
.leaflet-top,
.leaflet-bottom {
z-index: 997;
}
.geo-view.placing-note .geo-map-container {
cursor: crosshair;
}
.geo-map-container .marker-pin {
position: relative;
}
.geo-map-container .leaflet-div-icon {
position: relative;
background: transparent;
border: 0;
overflow: visible;
}
.geo-map-container .leaflet-div-icon .icon-shadow {
position: absolute;
top: 0;
left: 0;
z-index: -1;
}
.geo-map-container .leaflet-div-icon .bx {
position: absolute;
top: 3px;
left: 2px;
background-color: white;
color: black;
padding: 2px;
border-radius: 50%;
font-size: 17px;
}
.geo-map-container .leaflet-div-icon .title-label {
display: block;
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
font-size: 0.75rem;
height: 1rem;
color: black;
width: 100px;
text-align: center;
text-overflow: ellipsis;
text-shadow: -1px -1px 0 white, 1px -1px 0 white, -1px 1px 0 white, 1px 1px 0 white;
white-space: no-wrap;
overflow: hidden;
}
.geo-map-container .leaflet-div-icon .archived {
opacity: 0.5;
}
.geo-map-container.dark .leaflet-div-icon .title-label {
color: white;
text-shadow: -1px -1px 0 black, 1px -1px 0 black, -1px 1px 0 black, 1px 1px 0 black;
}

View File

@ -0,0 +1,296 @@
import Map from "./map";
import "./index.css";
import { ViewModeProps } from "../interface";
import { useNoteBlob, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useNoteTreeDrag, useSpacedUpdate, useTouchBar, useTriliumEvent } from "../../react/hooks";
import { DEFAULT_MAP_LAYER_NAME } from "./map_layer";
import { divIcon, GPXOptions, LatLng, LeafletMouseEvent } from "leaflet";
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
import Marker, { GpxTrack } from "./marker";
import froca from "../../../services/froca";
import FNote from "../../../entities/fnote";
import markerIcon from "leaflet/dist/images/marker-icon.png";
import markerIconShadow from "leaflet/dist/images/marker-shadow.png";
import appContext from "../../../components/app_context";
import { createNewNote, moveMarker } from "./api";
import openContextMenu, { openMapContextMenu } from "./context_menu";
import toast from "../../../services/toast";
import { t } from "../../../services/i18n";
import server from "../../../services/server";
import branches from "../../../services/branches";
import TouchBar, { TouchBarButton, TouchBarLabel, TouchBarSlider } from "../../react/TouchBar";
import { ParentComponent } from "../../react/react_utils";
const DEFAULT_COORDINATES: [number, number] = [3.878638227135724, 446.6630455551659];
const DEFAULT_ZOOM = 2;
export const LOCATION_ATTRIBUTE = "geolocation";
interface MapData {
view?: {
center?: LatLng | [number, number];
zoom?: number;
};
}
enum State {
Normal,
NewNote
}
export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewModeProps<MapData>) {
const [ state, setState ] = useState(State.Normal);
const [ coordinates, setCoordinates ] = useState(viewConfig?.view?.center);
const [ zoom, setZoom ] = useState(viewConfig?.view?.zoom);
const [ layerName ] = useNoteLabel(note, "map:style");
const [ hasScale ] = useNoteLabelBoolean(note, "map:scale");
const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly");
const [ notes, setNotes ] = useState<FNote[]>([]);
const spacedUpdate = useSpacedUpdate(() => {
if (viewConfig) {
saveConfig(viewConfig);
}
}, 5000);
useEffect(() => { froca.getNotes(noteIds).then(setNotes) }, [ noteIds ]);
useEffect(() => {
if (!note) return;
setCoordinates(viewConfig?.view?.center ?? DEFAULT_COORDINATES);
setZoom(viewConfig?.view?.zoom ?? DEFAULT_ZOOM);
}, [ note, viewConfig ]);
// Note creation.
useTriliumEvent("geoMapCreateChildNote", () => {
toast.showPersistent({
icon: "plus",
id: "geo-new-note",
title: "New note",
message: t("geo-map.create-child-note-instruction")
});
setState(State.NewNote);
const globalKeyListener: (this: Window, ev: KeyboardEvent) => any = (e) => {
if (e.key === "Escape") {
setState(State.Normal);
window.removeEventListener("keydown", globalKeyListener);
toast.closePersistent("geo-new-note");
}
};
window.addEventListener("keydown", globalKeyListener);
});
useTriliumEvent("deleteFromMap", ({ noteId }) => {
moveMarker(noteId, null);
});
const onClick = useCallback(async (e: LeafletMouseEvent) => {
if (state === State.NewNote) {
toast.closePersistent("geo-new-note");
await createNewNote(note.noteId, e);
setState(State.Normal);
}
}, [ state ]);
const onContextMenu = useCallback((e: LeafletMouseEvent) => {
openMapContextMenu(note.noteId, e, !isReadOnly);
}, [ note.noteId, isReadOnly ]);
// Dragging
const containerRef = useRef<HTMLDivElement>(null);
const apiRef = useRef<L.Map>(null);
useNoteTreeDrag(containerRef, {
dragEnabled: !isReadOnly,
dragNotEnabledMessage: {
icon: "bx bx-lock-alt",
title: t("book.drag_locked_title"),
message: t("book.drag_locked_message")
},
async callback(treeData, e) {
const api = apiRef.current;
if (!note || !api || isReadOnly) return;
const { noteId } = treeData[0];
const offset = containerRef.current?.getBoundingClientRect();
const x = e.clientX - (offset?.left ?? 0);
const y = e.clientY - (offset?.top ?? 0);
const latlng = api.containerPointToLatLng([ x, y ]);
const targetNote = await froca.getNote(noteId, true);
const parents = targetNote?.getParentNoteIds();
if (parents?.includes(note.noteId)) {
await moveMarker(noteId, latlng);
} else {
await branches.cloneNoteToParentNote(noteId, noteId);
await moveMarker(noteId, latlng);
}
}
});
return (
<div className={`geo-view ${state === State.NewNote ? "placing-note" : ""}`}>
{ coordinates && zoom && <Map
apiRef={apiRef} containerRef={containerRef}
coordinates={coordinates}
zoom={zoom}
layerName={layerName ?? DEFAULT_MAP_LAYER_NAME}
viewportChanged={(coordinates, zoom) => {
if (!viewConfig) viewConfig = {};
viewConfig.view = { center: coordinates, zoom };
spacedUpdate.scheduleUpdate();
}}
onClick={onClick}
onContextMenu={onContextMenu}
scale={hasScale}
>
{notes.map(note => <NoteWrapper note={note} isReadOnly={isReadOnly} />)}
</Map>}
<GeoMapTouchBar state={state} map={apiRef.current} />
</div>
);
}
function NoteWrapper({ note, isReadOnly }: { note: FNote, isReadOnly: boolean }) {
const mime = useNoteProperty(note, "mime");
const [ location ] = useNoteLabel(note, LOCATION_ATTRIBUTE);
if (mime === "application/gpx+xml") {
return <NoteGpxTrack note={note} />;
}
if (location) {
const latLng = location?.split(",", 2).map((el) => parseFloat(el)) as [ number, number ] | undefined;
if (!latLng) return;
return <NoteMarker note={note} editable={!isReadOnly} latLng={latLng} />;
}
}
function NoteMarker({ note, editable, latLng }: { note: FNote, editable: boolean, latLng: [number, number] }) {
// React to changes
const [ color ] = useNoteLabel(note, "color");
const [ iconClass ] = useNoteLabel(note, "iconClass");
const [ archived ] = useNoteLabelBoolean(note, "archived");
const title = useNoteProperty(note, "title");
const icon = useMemo(() => {
return buildIcon(note.getIcon(), note.getColorClass() ?? undefined, title, note.noteId, archived);
}, [ iconClass, color, title, note.noteId, archived]);
const onClick = useCallback(() => {
appContext.triggerCommand("openInPopup", { noteIdOrPath: note.noteId });
}, [ note.noteId ]);
// Middle click to open in new tab
const onMouseDown = useCallback((e: MouseEvent) => {
if (e.button === 1) {
const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId;
appContext.tabManager.openInNewTab(note.noteId, hoistedNoteId);
return true;
}
}, [ note.noteId ]);
const onDragged = useCallback((newCoordinates: LatLng) => {
moveMarker(note.noteId, newCoordinates);
}, [ note.noteId ]);
const onContextMenu = useCallback((e: LeafletMouseEvent) => openContextMenu(note.noteId, e, editable), [ note.noteId, editable ]);
return latLng && <Marker
coordinates={latLng}
icon={icon}
draggable={editable}
onMouseDown={onMouseDown}
onDragged={editable ? onDragged : undefined}
onClick={!editable ? onClick : undefined}
onContextMenu={onContextMenu}
/>
}
function NoteGpxTrack({ note }: { note: FNote }) {
const [ xmlString, setXmlString ] = useState<string>();
const blob = useNoteBlob(note);
useEffect(() => {
if (!blob) return;
server.get<string | Uint8Array>(`notes/${note.noteId}/open`, undefined, true).then(xmlResponse => {
if (xmlResponse instanceof Uint8Array) {
setXmlString(new TextDecoder().decode(xmlResponse));
} else {
setXmlString(xmlResponse);
}
});
}, [ blob ]);
// React to changes
const color = useNoteLabel(note, "color");
const iconClass = useNoteLabel(note, "iconClass");
const options = useMemo<GPXOptions>(() => ({
markers: {
startIcon: buildIcon(note.getIcon(), note.getColorClass(), note.title),
endIcon: buildIcon("bxs-flag-checkered"),
wptIcons: {
"": buildIcon("bx bx-pin")
}
},
polyline_options: {
color: note.getLabelValue("color") ?? "blue"
}
}), [ color, iconClass ]);
return xmlString && <GpxTrack gpxXmlString={xmlString} options={options} />
}
function buildIcon(bxIconClass: string, colorClass?: string, title?: string, noteIdLink?: string, archived?: boolean) {
let html = /*html*/`\
<img class="icon" src="${markerIcon}" />
<img class="icon-shadow" src="${markerIconShadow}" />
<span class="bx ${bxIconClass} ${colorClass ?? ""}"></span>
<span class="title-label">${title ?? ""}</span>`;
if (noteIdLink) {
html = `<div data-href="#root/${noteIdLink}" class="${archived ? "archived" : ""}">${html}</div>`;
}
return divIcon({
html,
iconSize: [25, 41],
iconAnchor: [12, 41]
});
}
function GeoMapTouchBar({ state, map }: { state: State, map: L.Map | null | undefined }) {
const [ currentZoom, setCurrentZoom ] = useState<number>();
const parentComponent = useContext(ParentComponent);
useEffect(() => {
if (!map) return;
function onZoomChanged() {
setCurrentZoom(map?.getZoom());
}
map.on("zoom", onZoomChanged);
return () => map.off("zoom", onZoomChanged);
}, [ map ]);
return map && currentZoom && (
<TouchBar>
<TouchBarSlider
label="Zoom"
value={currentZoom}
minValue={map.getMinZoom()}
maxValue={map.getMaxZoom()}
onChange={(newValue) => {
setCurrentZoom(newValue);
map.setZoom(newValue);
}}
/>
<TouchBarButton
label="New geo note"
click={() => parentComponent?.triggerCommand("geoMapCreateChildNote")}
enabled={state === State.Normal}
/>
</TouchBar>
)
}

View File

@ -0,0 +1,143 @@
import { useEffect, useImperativeHandle, useRef, useState } from "preact/hooks";
import L, { control, LatLng, Layer, LeafletMouseEvent } from "leaflet";
import "leaflet/dist/leaflet.css";
import { MAP_LAYERS } from "./map_layer";
import { ComponentChildren, createContext, RefObject } from "preact";
import { useElementSize, useSyncedRef } from "../../react/hooks";
export const ParentMap = createContext<L.Map | null>(null);
interface MapProps {
apiRef?: RefObject<L.Map | null>;
containerRef?: RefObject<HTMLDivElement>;
coordinates: LatLng | [number, number];
zoom: number;
layerName: string;
viewportChanged: (coordinates: LatLng, zoom: number) => void;
children: ComponentChildren;
onClick?: (e: LeafletMouseEvent) => void;
onContextMenu?: (e: LeafletMouseEvent) => void;
onZoom?: () => void;
scale: boolean;
}
export default function Map({ coordinates, zoom, layerName, viewportChanged, children, onClick, onContextMenu, scale, apiRef, containerRef: _containerRef, onZoom }: MapProps) {
const mapRef = useRef<L.Map>(null);
const containerRef = useSyncedRef<HTMLDivElement>(_containerRef);
useImperativeHandle(apiRef ?? null, () => mapRef.current);
useEffect(() => {
if (!containerRef.current) return;
const mapInstance = L.map(containerRef.current, {
worldCopyJump: true
});
mapRef.current = mapInstance;
return () => {
mapInstance.off();
mapInstance.remove();
};
}, []);
// Load the layer asynchronously.
const [ layer, setLayer ] = useState<Layer>();
useEffect(() => {
async function load() {
const layerData = MAP_LAYERS[layerName];
if (layerData.type === "vector") {
const style = (typeof layerData.style === "string" ? layerData.style : await layerData.style());
await import("@maplibre/maplibre-gl-leaflet");
setLayer(L.maplibreGL({
style: style as any
}));
} else {
setLayer(L.tileLayer(layerData.url, {
attribution: layerData.attribution,
detectRetina: true
}));
}
}
load();
}, [ layerName ]);
// Attach layer to the map.
useEffect(() => {
const map = mapRef.current;
const layerToAdd = layer;
if (!map || !layerToAdd) return;
layerToAdd.addTo(map);
return () => layerToAdd.removeFrom(map);
}, [ mapRef, layer ]);
// React to coordinate changes.
useEffect(() => {
if (!mapRef.current) return;
mapRef.current.setView(coordinates, zoom);
}, [ mapRef, coordinates, zoom ]);
// Viewport callback.
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const updateFn = () => viewportChanged(map.getBounds().getCenter(), map.getZoom());
map.on("moveend", updateFn);
map.on("zoomend", updateFn);
return () => {
map.off("moveend", updateFn);
map.off("zoomend", updateFn);
};
}, [ mapRef, viewportChanged ]);
useEffect(() => {
if (onClick && mapRef.current) {
mapRef.current.on("click", onClick);
return () => mapRef.current?.off("click", onClick);
}
}, [ mapRef, onClick ]);
useEffect(() => {
if (onContextMenu && mapRef.current) {
mapRef.current.on("contextmenu", onContextMenu);
return () => mapRef.current?.off("contextmenu", onContextMenu);
}
}, [ mapRef, onContextMenu ]);
useEffect(() => {
if (onZoom && mapRef.current) {
mapRef.current.on("zoom", onZoom);
return () => mapRef.current?.off("zoom", onZoom);
}
}, [ mapRef, onZoom ]);
// Scale
useEffect(() => {
const map = mapRef.current;
if (!scale || !map) return;
const scaleControl = control.scale();
scaleControl.addTo(map);
return () => scaleControl.remove();
}, [ mapRef, scale ]);
// Adapt to container size changes.
const size = useElementSize(containerRef);
useEffect(() => {
mapRef.current?.invalidateSize();
}, [ size?.width, size?.height ]);
return (
<div
ref={containerRef}
className={`geo-map-container ${MAP_LAYERS[layerName].isDarkTheme ? "dark" : ""}`}
>
<ParentMap.Provider value={mapRef.current}>
{children}
</ParentMap.Provider>
</div>
);
}

View File

@ -0,0 +1,71 @@
import { useContext, useEffect } from "preact/hooks";
import { ParentMap } from "./map";
import { DivIcon, GPX, GPXOptions, Icon, LatLng, Marker as LeafletMarker, LeafletMouseEvent, marker, MarkerOptions } from "leaflet";
import "leaflet-gpx";
export interface MarkerProps {
coordinates: [ number, number ];
icon?: Icon | DivIcon;
onClick?: () => void;
onMouseDown?: (e: MouseEvent) => void;
onDragged?: ((newCoordinates: LatLng) => void);
onContextMenu: (e: LeafletMouseEvent) => void;
draggable?: boolean;
}
export default function Marker({ coordinates, icon, draggable, onClick, onDragged, onMouseDown, onContextMenu }: MarkerProps) {
const parentMap = useContext(ParentMap);
useEffect(() => {
if (!parentMap) return;
const options: MarkerOptions = { icon };
if (draggable) {
options.draggable = true;
options.autoPan = true;
options.autoPanSpeed = 5;
}
const newMarker = marker(coordinates, options);
if (onClick) {
newMarker.on("click", () => onClick());
}
if (onMouseDown) {
newMarker.on("mousedown", e => onMouseDown(e.originalEvent));
}
if (onDragged) {
newMarker.on("moveend", e => {
const coordinates = (e.target as LeafletMarker).getLatLng();
onDragged(coordinates);
});
}
if (onContextMenu) {
newMarker.on("contextmenu", e => onContextMenu(e))
}
newMarker.addTo(parentMap);
return () => newMarker.removeFrom(parentMap);
}, [ parentMap, coordinates, onMouseDown, onDragged, icon ]);
return (<div />)
}
export function GpxTrack({ gpxXmlString, options }: { gpxXmlString: string, options: GPXOptions }) {
const parentMap = useContext(ParentMap);
useEffect(() => {
if (!parentMap) return;
const track = new GPX(gpxXmlString, options);
track.addTo(parentMap);
return () => track.removeFrom(parentMap);
}, [ parentMap, gpxXmlString, options ]);
return <div />;
}

View File

@ -0,0 +1,16 @@
import FNote from "../../entities/fnote";
export const allViewTypes = ["list", "grid", "calendar", "table", "geoMap", "board"] as const;
export type ViewTypeOptions = typeof allViewTypes[number];
export interface ViewModeProps<T extends object> {
note: FNote;
notePath: string;
/**
* We're using noteIds so that it's not necessary to load all notes at once when paging.
*/
noteIds: string[];
highlightedTokens: string[] | null | undefined;
viewConfig: T | undefined;
saveConfig(newConfig: T): void;
}

View File

@ -0,0 +1,134 @@
.note-list {
overflow: hidden;
position: relative;
height: 100%;
}
.note-book-card {
border-radius: 10px;
background-color: var(--accented-background-color);
padding: 10px 15px 15px 8px;
margin: 5px 5px 5px 5px;
overflow: hidden;
display: flex;
flex-direction: column;
flex-shrink: 0;
flex-grow: 1;
}
.note-book-card:not(.expanded) .note-book-content {
padding: 10px
}
.note-book-card.expanded .note-book-content {
display: block;
min-height: 0;
height: 100%;
padding-top: 10px;
}
.note-book-content .rendered-content {
height: 100%;
}
.note-book-header {
border-bottom: 1px solid var(--main-border-color);
margin-bottom: 0;
padding-bottom: .5rem;
word-break: break-all;
flex-shrink: 0;
}
/* not-expanded title is limited to one line only */
.note-book-card:not(.expanded) .note-book-header {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.note-book-header .rendered-note-attributes {
font-size: medium;
}
.note-book-header .rendered-note-attributes:before {
content: "\00a0\00a0";
}
.note-book-header .note-icon {
font-size: 100%;
display: inline-block;
padding-right: 7px;
position: relative;
}
.note-book-card .note-book-card {
border: 1px solid var(--main-border-color);
}
.note-book-content.type-image, .note-book-content.type-file, .note-book-content.type-protectedSession {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 10px;
}
.note-book-content.type-image img, .note-book-content.type-canvas svg {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.note-book-card.type-image .note-book-content img,
.note-book-card.type-text .note-book-content img,
.note-book-card.type-canvas .note-book-content img {
max-width: 100%;
max-height: 100%;
}
.note-book-header {
flex-grow: 0;
}
.note-list-wrapper {
height: 100%;
overflow: auto;
}
.note-expander {
font-size: x-large;
position: relative;
top: 3px;
cursor: pointer;
}
.note-list-pager {
text-align: center;
}
/* #region Grid view */
.note-list.grid-view .note-list-container {
display: flex;
flex-wrap: wrap;
}
.note-list.grid-view .note-book-card {
flex-basis: 300px;
border: 1px solid transparent;
}
.note-list.grid-view .note-book-card {
max-height: 300px;
}
.note-list.grid-view .note-book-card img {
max-height: 220px;
object-fit: contain;
}
.note-list.grid-view .note-book-card:hover {
cursor: pointer;
border: 1px solid var(--main-border-color);
background: var(--more-accented-background-color);
}
/* #endregion */

View File

@ -0,0 +1,183 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import FNote from "../../../entities/fnote";
import Icon from "../../react/Icon";
import { ViewModeProps } from "../interface";
import { useNoteLabelBoolean, useImperativeSearchHighlighlighting } from "../../react/hooks";
import NoteLink from "../../react/NoteLink";
import "./ListOrGridView.css";
import content_renderer from "../../../services/content_renderer";
import { Pager, usePagination } from "../Pagination";
import tree from "../../../services/tree";
import link from "../../../services/link";
import { t } from "../../../services/i18n";
import attribute_renderer from "../../../services/attribute_renderer";
export function ListView({ note, noteIds: unfilteredNoteIds, highlightedTokens }: ViewModeProps<{}>) {
const [ isExpanded ] = useNoteLabelBoolean(note, "expanded");
const noteIds = useFilteredNoteIds(note, unfilteredNoteIds);
const { pageNotes, ...pagination } = usePagination(note, noteIds);
return (
<div class="note-list list-view">
{ noteIds.length > 0 && <div class="note-list-wrapper">
<Pager {...pagination} />
<div class="note-list-container use-tn-links">
{pageNotes?.map(childNote => (
<ListNoteCard note={childNote} parentNote={note} expand={isExpanded} highlightedTokens={highlightedTokens} />
))}
</div>
<Pager {...pagination} />
</div>}
</div>
);
}
export function GridView({ note, noteIds: unfilteredNoteIds, highlightedTokens }: ViewModeProps<{}>) {
const noteIds = useFilteredNoteIds(note, unfilteredNoteIds);
const { pageNotes, ...pagination } = usePagination(note, noteIds);
return (
<div class="note-list grid-view">
<div class="note-list-wrapper">
<Pager {...pagination} />
<div class="note-list-container use-tn-links">
{pageNotes?.map(childNote => (
<GridNoteCard note={childNote} parentNote={note} highlightedTokens={highlightedTokens} />
))}
</div>
<Pager {...pagination} />
</div>
</div>
);
}
function ListNoteCard({ note, parentNote, expand, highlightedTokens }: { note: FNote, parentNote: FNote, expand?: boolean, highlightedTokens: string[] | null | undefined }) {
const [ isExpanded, setExpanded ] = useState(expand);
const notePath = getNotePath(parentNote, note);
// Reset expand state if switching to another note.
useEffect(() => setExpanded(expand), [ note ]);
return (
<div
className={`note-book-card no-tooltip-preview ${isExpanded ? "expanded" : ""}`}
data-note-id={note.noteId}
>
<h5 className="note-book-header">
<span
className={`note-expander ${isExpanded ? "bx bx-chevron-down" : "bx bx-chevron-right"}`}
onClick={() => setExpanded(!isExpanded)}
/>
<Icon className="note-icon" icon={note.getIcon()} />
<NoteLink className="note-book-title" notePath={notePath} noPreview showNotePath={note.type === "search"} highlightedTokens={highlightedTokens} />
<NoteAttributes note={note} />
</h5>
{isExpanded && <>
<NoteContent note={note} highlightedTokens={highlightedTokens} />
<NoteChildren note={note} parentNote={parentNote} highlightedTokens={highlightedTokens} />
</>}
</div>
)
}
function GridNoteCard({ note, parentNote, highlightedTokens }: { note: FNote, parentNote: FNote, highlightedTokens: string[] | null | undefined }) {
const titleRef = useRef<HTMLSpanElement>(null);
const [ noteTitle, setNoteTitle ] = useState<string>();
const notePath = getNotePath(parentNote, note);
const highlightSearch = useImperativeSearchHighlighlighting(highlightedTokens);
useEffect(() => {
tree.getNoteTitle(note.noteId, parentNote.noteId).then(setNoteTitle);
}, [ note ]);
useEffect(() => highlightSearch(titleRef.current), [ noteTitle, highlightedTokens ]);
return (
<div
className={`note-book-card no-tooltip-preview block-link`}
data-href={`#${notePath}`}
data-note-id={note.noteId}
onClick={(e) => link.goToLink(e)}
>
<h5 className="note-book-header">
<Icon className="note-icon" icon={note.getIcon()} />
<span ref={titleRef} className="note-book-title">{noteTitle}</span>
<NoteAttributes note={note} />
</h5>
<NoteContent note={note} trim highlightedTokens={highlightedTokens} />
</div>
)
}
function NoteAttributes({ note }: { note: FNote }) {
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
attribute_renderer.renderNormalAttributes(note).then(({$renderedAttributes}) => {
ref.current?.replaceChildren(...$renderedAttributes);
});
}, [ note ]);
return <span className="note-list-attributes" ref={ref} />
}
function NoteContent({ note, trim, highlightedTokens }: { note: FNote, trim?: boolean, highlightedTokens }) {
const contentRef = useRef<HTMLDivElement>(null);
const highlightSearch = useImperativeSearchHighlighlighting(highlightedTokens);
useEffect(() => {
content_renderer.getRenderedContent(note, { trim })
.then(({ $renderedContent, type }) => {
if (!contentRef.current) return;
contentRef.current.replaceChildren(...$renderedContent);
contentRef.current.classList.add(`type-${type}`);
highlightSearch(contentRef.current);
})
.catch(e => {
console.warn(`Caught error while rendering note '${note.noteId}' of type '${note.type}'`);
console.error(e);
contentRef.current?.replaceChildren(t("collections.rendering_error"));
})
}, [ note, highlightedTokens ]);
return <div ref={contentRef} className="note-book-content" />;
}
function NoteChildren({ note, parentNote, highlightedTokens }: { note: FNote, parentNote: FNote, highlightedTokens: string[] | null | undefined }) {
const imageLinks = note.getRelations("imageLink");
const [ childNotes, setChildNotes ] = useState<FNote[]>();
useEffect(() => {
note.getChildNotes().then(childNotes => {
const filteredChildNotes = childNotes.filter((childNote) => !imageLinks.find((rel) => rel.value === childNote.noteId));
setChildNotes(filteredChildNotes);
});
}, [ note ]);
return childNotes?.map(childNote => <ListNoteCard note={childNote} parentNote={parentNote} highlightedTokens={highlightedTokens} />)
}
/**
* Filters the note IDs for the legacy view to filter out subnotes that are already included in the note content such as images, included notes.
*/
function useFilteredNoteIds(note: FNote, noteIds: string[]) {
return useMemo(() => {
const includedLinks = note ? note.getRelations().filter((rel) => rel.name === "imageLink" || rel.name === "includeNoteLink") : [];
const includedNoteIds = new Set(includedLinks.map((rel) => rel.value));
return noteIds.filter((noteId) => !includedNoteIds.has(noteId) && noteId !== "_hidden");
}, noteIds);
}
function getNotePath(parentNote: FNote, childNote: FNote) {
if (parentNote.type === "search") {
// for search note parent, we want to display a non-search path
return childNote.noteId;
} else {
return `${parentNote.noteId}/${childNote.noteId}`
}
}

View File

@ -0,0 +1,148 @@
import { useLegacyImperativeHandlers } from "../../react/hooks";
import { Attribute } from "../../../services/attribute_parser";
import { RefObject } from "preact";
import { Tabulator } from "tabulator-tables";
import { useRef } from "preact/hooks";
import { CommandListenerData, EventData } from "../../../components/app_context";
import AttributeDetailWidget from "../../attribute_widgets/attribute_detail";
import attributes from "../../../services/attributes";
import FNote from "../../../entities/fnote";
import { getAttributeFromField } from "./utils";
import dialog from "../../../services/dialog";
import { t } from "i18next";
import { executeBulkActions } from "../../../services/bulk_action";
export default function useColTableEditing(api: RefObject<Tabulator>, attributeDetailWidget: AttributeDetailWidget, parentNote: FNote) {
const existingAttributeToEdit = useRef<Attribute>();
const newAttribute = useRef<Attribute>();
const newAttributePosition = useRef<number>();
useLegacyImperativeHandlers({
addNewTableColumnCommand({ referenceColumn, columnToEdit, direction, type }: EventData<"addNewTableColumn">) {
let attr: Attribute | undefined;
existingAttributeToEdit.current = undefined;
if (columnToEdit) {
attr = getAttributeFromField(parentNote, columnToEdit.getField());
if (attr) {
existingAttributeToEdit.current = { ...attr };
}
}
if (!attr) {
attr = {
type: "label",
name: `${type ?? "label"}:myLabel`,
value: "promoted,single,text",
isInheritable: true
};
}
if (referenceColumn && api.current) {
let newPosition = api.current.getColumns().indexOf(referenceColumn);
if (direction === "after") {
newPosition++;
}
newAttributePosition.current = newPosition;
} else {
newAttributePosition.current = undefined;
}
attributeDetailWidget.showAttributeDetail({
attribute: attr,
allAttributes: [ attr ],
isOwned: true,
x: 0,
y: 150,
focus: "name",
hideMultiplicity: true
});
},
async updateAttributeListCommand({ attributes }: CommandListenerData<"updateAttributeList">) {
newAttribute.current = attributes[0];
},
async saveAttributesCommand() {
if (!newAttribute.current || !api.current) {
return;
}
const { name, value, isInheritable } = newAttribute.current;
api.current.blockRedraw();
const isRename = (existingAttributeToEdit.current && existingAttributeToEdit.current.name !== name);
try {
if (isRename) {
const oldName = existingAttributeToEdit.current!.name.split(":")[1];
const [ type, newName ] = name.split(":");
await renameColumn(parentNote.noteId, type as "label" | "relation", oldName, newName);
}
if (existingAttributeToEdit.current && (isRename || existingAttributeToEdit.current.isInheritable !== isInheritable)) {
attributes.removeOwnedLabelByName(parentNote, existingAttributeToEdit.current.name);
}
attributes.setLabel(parentNote.noteId, name, value, isInheritable);
} finally {
api.current.restoreRedraw();
}
},
async deleteTableColumnCommand({ columnToDelete }: CommandListenerData<"deleteTableColumn">) {
if (!api.current || !columnToDelete || !await dialog.confirm(t("table_view.delete_column_confirmation"))) {
return;
}
let [ type, name ] = columnToDelete.getField()?.split(".", 2);
if (!type || !name) {
return;
}
type = type.replace("s", "");
api.current.blockRedraw();
try {
await deleteColumn(parentNote.noteId, type as "label" | "relation", name);
attributes.removeOwnedLabelByName(parentNote, `${type}:${name}`);
} finally {
api.current.restoreRedraw();
}
}
});
function resetNewAttributePosition() {
newAttribute.current = undefined;
newAttributePosition.current = undefined;
existingAttributeToEdit.current = undefined;
}
return { newAttributePosition, resetNewAttributePosition };
}
async function deleteColumn(parentNoteId: string, type: "label" | "relation", columnName: string) {
if (type === "label") {
return executeBulkActions([parentNoteId], [{
name: "deleteLabel",
labelName: columnName
}], true);
} else {
return executeBulkActions([parentNoteId], [{
name: "deleteRelation",
relationName: columnName
}], true);
}
}
async function renameColumn(parentNoteId: string, type: "label" | "relation", originalName: string, newName: string) {
if (type === "label") {
return executeBulkActions([parentNoteId], [{
name: "renameLabel",
oldLabelName: originalName,
newLabelName: newName
}], true);
} else {
return executeBulkActions([parentNoteId], [{
name: "renameRelation",
oldRelationName: originalName,
newRelationName: newName
}], true);
}
}

View File

@ -1,7 +1,11 @@
import { RelationEditor } from "./relation_editor.js";
import { MonospaceFormatter, NoteFormatter, NoteTitleFormatter, RowNumberFormatter } from "./formatters.js";
import type { ColumnDefinition } from "tabulator-tables";
import type { CellComponent, ColumnDefinition, EmptyCallback, FormatterParams, ValueBooleanCallback, ValueVoidCallback } from "tabulator-tables";
import { LabelType } from "../../../services/promoted_attribute_definition_parser.js";
import { JSX } from "preact";
import { renderReactWidget } from "../../react/react_utils.jsx";
import Icon from "../../react/Icon.jsx";
import { useEffect, useRef, useState } from "preact/hooks";
import froca from "../../../services/froca.js";
import NoteAutocomplete from "../../react/NoteAutocomplete.jsx";
type ColumnType = LabelType | "relation";
@ -45,8 +49,8 @@ const labelTypeMappings: Record<ColumnType, Partial<ColumnDefinition>> = {
}
},
relation: {
editor: RelationEditor,
formatter: NoteFormatter
editor: wrapEditor(RelationEditor),
formatter: wrapFormatter(NoteFormatter)
}
};
@ -58,6 +62,10 @@ interface BuildColumnArgs {
position?: number;
}
interface RowNumberFormatterParams {
movableRows?: boolean;
}
export function buildColumnDefinitions({ info, movableRows, existingColumnData, rowNumberHint, position }: BuildColumnArgs) {
let columnDefs: ColumnDefinition[] = [
{
@ -68,19 +76,28 @@ export function buildColumnDefinitions({ info, movableRows, existingColumnData,
frozen: true,
rowHandle: movableRows,
width: calculateIndexColumnWidth(rowNumberHint, movableRows),
formatter: RowNumberFormatter(movableRows)
formatter: wrapFormatter(({ cell, formatterParams }) => <div>
{(formatterParams as RowNumberFormatterParams).movableRows && <><span class="bx bx-dots-vertical-rounded"></span>{" "}</>}
{cell.getRow().getPosition(true)}
</div>),
formatterParams: { movableRows } satisfies RowNumberFormatterParams
},
{
field: "noteId",
title: "Note ID",
formatter: MonospaceFormatter,
formatter: wrapFormatter(({ cell }) => <code>{cell.getValue()}</code>),
visible: false
},
{
field: "title",
title: "Title",
editor: "input",
formatter: NoteTitleFormatter,
formatter: wrapFormatter(({ cell }) => {
const { noteId, iconClass, colorClass } = cell.getRow().getData();
return <span className={`reference-link ${colorClass}`} data-href={`#root/${noteId}`}>
<Icon icon={iconClass} />{" "}{cell.getValue()}
</span>;
}),
width: 400
}
];
@ -154,3 +171,64 @@ function calculateIndexColumnWidth(rowNumberHint: number, movableRows: boolean):
}
return columnWidth;
}
interface FormatterOpts {
cell: CellComponent
formatterParams: FormatterParams;
}
interface EditorOpts {
cell: CellComponent,
success: ValueBooleanCallback,
cancel: ValueVoidCallback,
editorParams: {}
}
function wrapFormatter(Component: (opts: FormatterOpts) => JSX.Element): ((cell: CellComponent, formatterParams: {}, onRendered: EmptyCallback) => string | HTMLElement) {
return (cell, formatterParams, onRendered) => {
const elWithParams = <Component cell={cell} formatterParams={formatterParams} />;
return renderReactWidget(null, elWithParams)[0];
};
}
function wrapEditor(Component: (opts: EditorOpts) => JSX.Element): ((
cell: CellComponent,
onRendered: EmptyCallback,
success: ValueBooleanCallback,
cancel: ValueVoidCallback,
editorParams: {},
) => HTMLElement | false) {
return (cell, _, success, cancel, editorParams) => {
const elWithParams = <Component cell={cell} success={success} cancel={cancel} editorParams={editorParams} />
return renderReactWidget(null, elWithParams)[0];
};
}
function NoteFormatter({ cell }: FormatterOpts) {
const noteId = cell.getValue();
const [ note, setNote ] = useState(noteId ? froca.getNoteFromCache(noteId) : null)
useEffect(() => {
if (!noteId || note?.noteId === noteId) return;
froca.getNote(noteId).then(setNote);
}, [ noteId ]);
return <span className={`reference-link ${note?.getColorClass()}`} data-href={`#root/${noteId}`}>
{note && <><Icon icon={note?.getIcon()} />{" "}{note.title}</>}
</span>;
}
function RelationEditor({ cell, success }: EditorOpts) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => inputRef.current?.focus());
return <NoteAutocomplete
inputRef={inputRef}
noteId={cell.getValue()}
opts={{
allowCreatingNotes: true,
hideAllButtons: true
}}
noteIdChanged={success}
/>
}

View File

@ -1,31 +1,35 @@
import { ColumnComponent, RowComponent, Tabulator } from "tabulator-tables";
import { ColumnComponent, EventCallBackMethods, RowComponent, Tabulator } from "tabulator-tables";
import contextMenu, { MenuItem } from "../../../menus/context_menu.js";
import { TableData } from "./rows.js";
import branches from "../../../services/branches.js";
import FNote from "../../../entities/fnote.js";
import { t } from "../../../services/i18n.js";
import { TableData } from "./rows.js";
import link_context_menu from "../../../menus/link_context_menu.js";
import type FNote from "../../../entities/fnote.js";
import froca from "../../../services/froca.js";
import type Component from "../../../components/component.js";
import branches from "../../../services/branches.js";
import Component from "../../../components/component.js";
import { RefObject } from "preact";
export function setupContextMenu(tabulator: Tabulator, parentNote: FNote) {
tabulator.on("rowContext", (e, row) => showRowContextMenu(e, row, parentNote, tabulator));
tabulator.on("headerContext", (e, col) => showColumnContextMenu(e, col, parentNote, tabulator));
tabulator.on("renderComplete", () => {
const headerRow = tabulator.element.querySelector(".tabulator-header-contents");
headerRow?.addEventListener("contextmenu", (e) => showHeaderContextMenu(e, tabulator));
});
export function useContextMenu(parentNote: FNote, parentComponent: Component | null | undefined, tabulator: RefObject<Tabulator>): Partial<EventCallBackMethods> {
const events: Partial<EventCallBackMethods> = {};
if (!tabulator || !parentComponent) return events;
// Pressing the expand button prevents bubbling and the context menu remains menu when it shouldn't.
if (tabulator.options.dataTree) {
const dismissContextMenu = () => contextMenu.hide();
tabulator.on("dataTreeRowExpanded", dismissContextMenu);
tabulator.on("dataTreeRowCollapsed", dismissContextMenu);
events["rowContext"] = (e, row) => tabulator.current && showRowContextMenu(parentComponent, e as MouseEvent, row, parentNote, tabulator.current);
events["headerContext"] = (e, col) => tabulator.current && showColumnContextMenu(parentComponent, e as MouseEvent, col, parentNote, tabulator.current);
events["renderComplete"] = () => {
const headerRow = tabulator.current?.element.querySelector(".tabulator-header-contents");
headerRow?.addEventListener("contextmenu", (e) => showHeaderContextMenu(parentComponent, e as MouseEvent, tabulator.current!));
}
// Pressing the expand button prevents bubbling and the context menu remains menu when it shouldn't.
if (tabulator.current?.options.dataTree) {
const dismissContextMenu = () => contextMenu.hide();
events["dataTreeRowExpanded"] = dismissContextMenu;
events["dataTreeRowCollapsed"] = dismissContextMenu;
}
return events;
}
function showColumnContextMenu(_e: UIEvent, column: ColumnComponent, parentNote: FNote, tabulator: Tabulator) {
const e = _e as MouseEvent;
function showColumnContextMenu(parentComponent: Component, e: MouseEvent, column: ColumnComponent, parentNote: FNote, tabulator: Tabulator) {
const { title, field } = column.getDefinition();
const sorters = tabulator.getSorters();
@ -87,16 +91,16 @@ function showColumnContextMenu(_e: UIEvent, column: ColumnComponent, parentNote:
title: t("table_view.add-column-to-the-left"),
uiIcon: "bx bx-horizontal-left",
enabled: !column.getDefinition().frozen,
items: buildInsertSubmenu(e, column, "before"),
handler: () => getParentComponent(e)?.triggerCommand("addNewTableColumn", {
items: buildInsertSubmenu(parentComponent, column, "before"),
handler: () => parentComponent?.triggerCommand("addNewTableColumn", {
referenceColumn: column
})
},
{
title: t("table_view.add-column-to-the-right"),
uiIcon: "bx bx-horizontal-right",
items: buildInsertSubmenu(e, column, "after"),
handler: () => getParentComponent(e)?.triggerCommand("addNewTableColumn", {
items: buildInsertSubmenu(parentComponent, column, "after"),
handler: () => parentComponent?.triggerCommand("addNewTableColumn", {
referenceColumn: column,
direction: "after"
})
@ -106,7 +110,7 @@ function showColumnContextMenu(_e: UIEvent, column: ColumnComponent, parentNote:
title: t("table_view.edit-column"),
uiIcon: "bx bxs-edit-alt",
enabled: isUserDefinedColumn,
handler: () => getParentComponent(e)?.triggerCommand("addNewTableColumn", {
handler: () => parentComponent?.triggerCommand("addNewTableColumn", {
referenceColumn: column,
columnToEdit: column
})
@ -115,7 +119,7 @@ function showColumnContextMenu(_e: UIEvent, column: ColumnComponent, parentNote:
title: t("table_view.delete-column"),
uiIcon: "bx bx-trash",
enabled: isUserDefinedColumn,
handler: () => getParentComponent(e)?.triggerCommand("deleteTableColumn", {
handler: () => parentComponent?.triggerCommand("deleteTableColumn", {
columnToDelete: column
})
}
@ -131,8 +135,7 @@ function showColumnContextMenu(_e: UIEvent, column: ColumnComponent, parentNote:
* Shows a context menu which has options dedicated to the header area (the part where the columns are, but in the empty space).
* Provides generic options such as toggling columns.
*/
function showHeaderContextMenu(_e: Event, tabulator: Tabulator) {
const e = _e as MouseEvent;
function showHeaderContextMenu(parentComponent: Component, e: MouseEvent, tabulator: Tabulator) {
contextMenu.show({
items: [
{
@ -146,7 +149,7 @@ function showHeaderContextMenu(_e: Event, tabulator: Tabulator) {
uiIcon: "bx bx-empty",
enabled: false
},
...buildInsertSubmenu(e)
...buildInsertSubmenu(parentComponent)
],
selectMenuItemHandler() {},
x: e.pageX,
@ -155,9 +158,9 @@ function showHeaderContextMenu(_e: Event, tabulator: Tabulator) {
e.preventDefault();
}
export function showRowContextMenu(_e: UIEvent, row: RowComponent, parentNote: FNote, tabulator: Tabulator) {
const e = _e as MouseEvent;
export function showRowContextMenu(parentComponent: Component, e: MouseEvent, row: RowComponent, parentNote: FNote, tabulator: Tabulator) {
const rowData = row.getData() as TableData;
const sorters = tabulator.getSorters();
let parentNoteId: string = parentNote.noteId;
@ -175,7 +178,8 @@ export function showRowContextMenu(_e: UIEvent, row: RowComponent, parentNote: F
{
title: t("table_view.row-insert-above"),
uiIcon: "bx bx-horizontal-left bx-rotate-90",
handler: () => getParentComponent(e)?.triggerCommand("addNewRow", {
enabled: !sorters.length,
handler: () => parentComponent?.triggerCommand("addNewRow", {
parentNotePath: parentNoteId,
customOpts: {
target: "before",
@ -189,7 +193,7 @@ export function showRowContextMenu(_e: UIEvent, row: RowComponent, parentNote: F
handler: async () => {
const branchId = row.getData().branchId;
const note = await froca.getBranch(branchId)?.getNote();
getParentComponent(e)?.triggerCommand("addNewRow", {
parentComponent?.triggerCommand("addNewRow", {
parentNotePath: note?.noteId,
customOpts: {
target: "after",
@ -201,7 +205,8 @@ export function showRowContextMenu(_e: UIEvent, row: RowComponent, parentNote: F
{
title: t("table_view.row-insert-below"),
uiIcon: "bx bx-horizontal-left bx-rotate-270",
handler: () => getParentComponent(e)?.triggerCommand("addNewRow", {
enabled: !sorters.length,
handler: () => parentComponent?.triggerCommand("addNewRow", {
parentNotePath: parentNoteId,
customOpts: {
target: "after",
@ -223,16 +228,6 @@ export function showRowContextMenu(_e: UIEvent, row: RowComponent, parentNote: F
e.preventDefault();
}
function getParentComponent(e: MouseEvent) {
if (!e.target) {
return;
}
return $(e.target)
.closest(".component")
.prop("component") as Component;
}
function buildColumnItems(tabulator: Tabulator) {
const items: MenuItem<unknown>[] = [];
for (const column of tabulator.getColumns()) {
@ -249,13 +244,13 @@ function buildColumnItems(tabulator: Tabulator) {
return items;
}
function buildInsertSubmenu(e: MouseEvent, referenceColumn?: ColumnComponent, direction?: "before" | "after"): MenuItem<unknown>[] {
function buildInsertSubmenu(parentComponent: Component, referenceColumn?: ColumnComponent, direction?: "before" | "after"): MenuItem<unknown>[] {
return [
{
title: t("table_view.new-column-label"),
uiIcon: "bx bx-hash",
handler: () => {
getParentComponent(e)?.triggerCommand("addNewTableColumn", {
parentComponent?.triggerCommand("addNewTableColumn", {
referenceColumn,
type: "label",
direction
@ -266,7 +261,7 @@ function buildInsertSubmenu(e: MouseEvent, referenceColumn?: ColumnComponent, di
title: t("table_view.new-column-relation"),
uiIcon: "bx bx-transfer",
handler: () => {
getParentComponent(e)?.triggerCommand("addNewTableColumn", {
parentComponent?.triggerCommand("addNewTableColumn", {
referenceColumn,
type: "relation",
direction

View File

@ -0,0 +1,71 @@
.table-view {
overflow: hidden;
position: relative;
height: 100%;
user-select: none;
padding: 0 5px 0 10px;
}
.table-view-container {
height: 100%;
}
.search-result-widget-content .table-view {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.tabulator-cell .autocomplete {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: transparent;
outline: none !important;
}
.tabulator .tabulator-header {
border-top: unset;
border-bottom-width: 1px;
}
.tabulator .tabulator-header .tabulator-frozen.tabulator-frozen-left,
.tabulator-row .tabulator-cell.tabulator-frozen.tabulator-frozen-left {
border-right-width: 1px;
}
.tabulator .tabulator-row.archived {
opacity: 0.5;
}
.tabulator .tabulator-footer {
background-color: unset;
padding: 5px 0;
}
.tabulator .tabulator-footer .tabulator-footer-contents {
justify-content: left;
gap: 0.5em;
}
.tabulator button.tree-expand,
.tabulator button.tree-collapse {
display: inline-block;
appearance: none;
border: 0;
background: transparent;
width: 1.5em;
position: relative;
vertical-align: middle;
}
.tabulator button.tree-expand span,
.tabulator button.tree-collapse span {
position: absolute;
top: 0;
left: 0;
font-size: 1.5em;
transform: translateY(-50%);
}

View File

@ -0,0 +1,171 @@
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { ViewModeProps } from "../interface";
import { buildColumnDefinitions } from "./columns";
import getAttributeDefinitionInformation, { buildRowDefinitions, TableData } from "./rows";
import { useLegacyWidget, useNoteLabelBoolean, useNoteLabelInt, useSpacedUpdate, useTriliumEvent } from "../../react/hooks";
import Tabulator from "./tabulator";
import { Tabulator as VanillaTabulator, SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, ColumnDefinition, DataTreeModule, Options, RowComponent} from 'tabulator-tables';
import { useContextMenu } from "./context_menu";
import { ParentComponent } from "../../react/react_utils";
import FNote from "../../../entities/fnote";
import { t } from "../../../services/i18n";
import Button from "../../react/Button";
import "./index.css";
import useRowTableEditing from "./row_editing";
import useColTableEditing from "./col_editing";
import AttributeDetailWidget from "../../attribute_widgets/attribute_detail";
import attributes from "../../../services/attributes";
import { RefObject } from "preact";
interface TableConfig {
tableData?: {
columns?: ColumnDefinition[];
};
}
export default function TableView({ note, noteIds, notePath, viewConfig, saveConfig }: ViewModeProps<TableConfig>) {
const tabulatorRef = useRef<VanillaTabulator>(null);
const parentComponent = useContext(ParentComponent);
const [ attributeDetailWidgetEl, attributeDetailWidget ] = useLegacyWidget(() => new AttributeDetailWidget().contentSized());
const contextMenuEvents = useContextMenu(note, parentComponent, tabulatorRef);
const persistenceProps = usePersistence(viewConfig, saveConfig);
const rowEditingEvents = useRowTableEditing(tabulatorRef, attributeDetailWidget, notePath);
const { newAttributePosition, resetNewAttributePosition } = useColTableEditing(tabulatorRef, attributeDetailWidget, note);
const { columnDefs, rowData, movableRows, hasChildren } = useData(note, noteIds, viewConfig, newAttributePosition, resetNewAttributePosition);
const dataTreeProps = useMemo<Options>(() => {
if (!hasChildren) return {};
return {
dataTree: true,
dataTreeStartExpanded: true,
dataTreeBranchElement: false,
dataTreeElementColumn: "title",
dataTreeChildIndent: 20,
dataTreeExpandElement: `<button class="tree-expand"><span class="bx bx-chevron-right"></span></button>`,
dataTreeCollapseElement: `<button class="tree-collapse"><span class="bx bx-chevron-down"></span></button>`
}
}, [ hasChildren ]);
const rowFormatter = useCallback((row: RowComponent) => {
const data = row.getData() as TableData;
row.getElement().classList.toggle("archived", !!data.isArchived);
}, []);
return (
<div className="table-view">
{columnDefs && (
<>
<Tabulator
tabulatorRef={tabulatorRef}
className="table-view-container"
columns={columnDefs}
data={rowData}
modules={[ SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, DataTreeModule ]}
footerElement={<TableFooter note={note} />}
events={{
...contextMenuEvents,
...rowEditingEvents
}}
persistence {...persistenceProps}
layout="fitDataFill"
index="branchId"
movableColumns
movableRows={movableRows}
rowFormatter={rowFormatter}
{...dataTreeProps}
/>
<TableFooter note={note} />
</>
)}
{attributeDetailWidgetEl}
</div>
)
}
function TableFooter({ note }: { note: FNote }) {
return (note.type !== "search" &&
<div className="tabulator-footer">
<div className="tabulator-footer-contents">
<Button triggerCommand="addNewRow" icon="bx bx-plus" text={t("table_view.new-row")} />
{" "}
<Button triggerCommand="addNewTableColumn" icon="bx bx-carousel" text={t("table_view.new-column")} />
</div>
</div>
)
}
function usePersistence(initialConfig: TableConfig | null | undefined, saveConfig: (newConfig: TableConfig) => void) {
const config = useRef<TableConfig | null | undefined>(initialConfig);
const spacedUpdate = useSpacedUpdate(() => {
if (config.current) {
saveConfig(config.current);
}
}, 5_000);
const persistenceWriterFunc = useCallback((_id, type: string, data: object) => {
if (!config.current) config.current = {};
if (!config.current.tableData) config.current.tableData = {};
(config.current.tableData as Record<string, {}>)[type] = data;
spacedUpdate.scheduleUpdate();
}, []);
const persistenceReaderFunc = useCallback((_id, type: string) => {
return config.current?.tableData?.[type];
}, []);
return { persistenceReaderFunc, persistenceWriterFunc };
}
function useData(note: FNote, noteIds: string[], viewConfig: TableConfig | undefined, newAttributePosition: RefObject<number | undefined>, resetNewAttributePosition: () => void) {
const [ maxDepth ] = useNoteLabelInt(note, "maxNestingDepth") ?? -1;
const [ includeArchived ] = useNoteLabelBoolean(note, "includeArchived");
const [ columnDefs, setColumnDefs ] = useState<ColumnDefinition[]>();
const [ rowData, setRowData ] = useState<TableData[]>();
const [ hasChildren, setHasChildren ] = useState<boolean>();
const [ isSorted ] = useNoteLabelBoolean(note, "sorted");
const [ movableRows, setMovableRows ] = useState(false);
function refresh() {
const info = getAttributeDefinitionInformation(note);
buildRowDefinitions(note, info, includeArchived, maxDepth).then(({ definitions: rowData, hasSubtree: hasChildren, rowNumber }) => {
const columnDefs = buildColumnDefinitions({
info,
movableRows,
existingColumnData: viewConfig?.tableData?.columns,
rowNumberHint: rowNumber,
position: newAttributePosition.current ?? undefined
});
setColumnDefs(columnDefs);
setRowData(rowData);
setHasChildren(hasChildren);
resetNewAttributePosition();
});
}
useEffect(refresh, [ note, noteIds, maxDepth, movableRows ]);
useTriliumEvent("entitiesReloaded", ({ loadResults}) => {
// React to column changes.
if (loadResults.getAttributeRows().find(attr =>
attr.type === "label" &&
(attr.name?.startsWith("label:") || attr.name?.startsWith("relation:")) &&
attributes.isAffecting(attr, note))) {
refresh();
return;
}
// React to external row updates.
if (loadResults.getBranchRows().some(branch => branch.parentNoteId === note.noteId || noteIds.includes(branch.parentNoteId ?? ""))
|| loadResults.getNoteIds().some(noteId => noteIds.includes(noteId))
|| loadResults.getAttributeRows().some(attr => noteIds.includes(attr.noteId!))
|| loadResults.getAttributeRows().some(attr => attr.name === "archived" && attr.noteId && noteIds.includes(attr.noteId))) {
refresh();
return;
}
});
// Identify if movable rows.
useEffect(() => {
setMovableRows(!isSorted && note.type !== "search" && !hasChildren);
}, [ isSorted, note, hasChildren ]);
return { columnDefs, rowData, movableRows, hasChildren };
}

View File

@ -0,0 +1,105 @@
import { EventCallBackMethods, RowComponent, Tabulator } from "tabulator-tables";
import { CommandListenerData } from "../../../components/app_context";
import note_create, { CreateNoteOpts } from "../../../services/note_create";
import { useLegacyImperativeHandlers } from "../../react/hooks";
import { RefObject } from "preact";
import { setAttribute, setLabel } from "../../../services/attributes";
import froca from "../../../services/froca";
import server from "../../../services/server";
import branches from "../../../services/branches";
import AttributeDetailWidget from "../../attribute_widgets/attribute_detail";
export default function useRowTableEditing(api: RefObject<Tabulator>, attributeDetailWidget: AttributeDetailWidget, parentNotePath: string): Partial<EventCallBackMethods> {
// Adding new rows
useLegacyImperativeHandlers({
addNewRowCommand({ customOpts, parentNotePath: customNotePath }: CommandListenerData<"addNewRow">) {
const notePath = customNotePath ?? parentNotePath;
if (notePath) {
const opts: CreateNoteOpts = {
activate: false,
...customOpts
}
note_create.createNote(notePath, opts).then(({ branch }) => {
if (branch) {
setTimeout(() => {
if (!api.current) return;
focusOnBranch(api.current, branch?.branchId);
}, 100);
}
})
}
}
});
// Editing existing rows.
return {
cellEdited: async (cell) => {
const noteId = cell.getRow().getData().noteId;
const field = cell.getField();
let newValue = cell.getValue();
if (field === "title") {
server.put(`notes/${noteId}/title`, { title: newValue });
return;
}
if (field.includes(".")) {
const [ type, name ] = field.split(".", 2);
if (type === "labels") {
if (typeof newValue === "boolean") {
newValue = newValue ? "true" : "false";
}
setLabel(noteId, name, newValue);
} else if (type === "relations") {
const note = await froca.getNote(noteId);
if (note) {
setAttribute(note, "relation", name, newValue);
}
}
}
},
rowMoved(row) {
const branchIdsToMove = [ row.getData().branchId ];
const prevRow = row.getPrevRow();
if (prevRow) {
branches.moveAfterBranch(branchIdsToMove, prevRow.getData().branchId);
return;
}
const nextRow = row.getNextRow();
if (nextRow) {
branches.moveBeforeBranch(branchIdsToMove, nextRow.getData().branchId);
}
}
};
}
function focusOnBranch(api: Tabulator, branchId: string) {
const row = findRowDataById(api.getRows(), branchId);
if (!row) return;
// Expand the parent tree if any.
if (api.options.dataTree) {
const parent = row.getTreeParent();
if (parent) {
parent.treeExpand();
}
}
row.getCell("title").edit();
}
function findRowDataById(rows: RowComponent[], branchId: string): RowComponent | null {
for (let row of rows) {
const item = row.getIndex() as string;
if (item === branchId) {
return row;
}
let found = findRowDataById(row.getTreeChildren(), branchId);
if (found) return found;
}
return null;
}

View File

@ -10,10 +10,11 @@ export type TableData = {
relations: Record<string, boolean | string | null>;
branchId: string;
colorClass: string | undefined;
isArchived: boolean;
_children?: TableData[];
};
export async function buildRowDefinitions(parentNote: FNote, infos: AttributeDefinitionInformation[], maxDepth = -1, currentDepth = 0) {
export async function buildRowDefinitions(parentNote: FNote, infos: AttributeDefinitionInformation[], includeArchived: boolean, maxDepth = -1, currentDepth = 0) {
const definitions: TableData[] = [];
const childBranches = parentNote.getChildBranches();
let hasSubtree = false;
@ -21,8 +22,8 @@ export async function buildRowDefinitions(parentNote: FNote, infos: AttributeDef
for (const branch of childBranches) {
const note = await branch.getNote();
if (!note) {
continue; // Skip if the note is not found
if (!note || (!includeArchived && note.isArchived)) {
continue;
}
const labels: typeof definitions[0]["labels"] = {};
@ -41,12 +42,13 @@ export async function buildRowDefinitions(parentNote: FNote, infos: AttributeDef
title: note.title,
labels,
relations,
isArchived: note.isArchived,
branchId: branch.branchId,
colorClass: note.getColorClass()
}
if (note.hasChildren() && (maxDepth < 0 || currentDepth < maxDepth)) {
const { definitions, rowNumber: subRowNumber } = (await buildRowDefinitions(note, infos, maxDepth, currentDepth + 1));
const { definitions, rowNumber: subRowNumber } = (await buildRowDefinitions(note, infos, includeArchived, maxDepth, currentDepth + 1));
def._children = definitions;
hasSubtree = true;
rowNumber += subRowNumber;

View File

@ -0,0 +1,72 @@
import { useContext, useEffect, useLayoutEffect, useRef } from "preact/hooks";
import { EventCallBackMethods, Module, Options, Tabulator as VanillaTabulator } from "tabulator-tables";
import "tabulator-tables/dist/css/tabulator.css";
import "../../../../src/stylesheets/table.css";
import { ParentComponent, renderReactWidget } from "../../react/react_utils";
import { JSX } from "preact/jsx-runtime";
import { isValidElement, RefObject } from "preact";
interface TableProps<T> extends Omit<Options, "data" | "footerElement" | "index"> {
tabulatorRef: RefObject<VanillaTabulator>;
className?: string;
data?: T[];
modules?: (new (table: VanillaTabulator) => Module)[];
events?: Partial<EventCallBackMethods>;
index: keyof T;
footerElement?: string | HTMLElement | JSX.Element;
}
export default function Tabulator<T>({ className, columns, data, modules, tabulatorRef: externalTabulatorRef, footerElement, events, index, ...restProps }: TableProps<T>) {
const parentComponent = useContext(ParentComponent);
const containerRef = useRef<HTMLDivElement>(null);
const tabulatorRef = useRef<VanillaTabulator>(null);
useLayoutEffect(() => {
if (!modules) return;
for (const module of modules) {
VanillaTabulator.registerModule(module);
}
}, [modules]);
useLayoutEffect(() => {
if (!containerRef.current) return;
const tabulator = new VanillaTabulator(containerRef.current, {
columns,
data,
footerElement: (parentComponent && isValidElement(footerElement) ? renderReactWidget(parentComponent, footerElement)[0] : undefined),
index: index as string | number | undefined,
...restProps
});
tabulator.on("tableBuilt", () => {
tabulatorRef.current = tabulator;
externalTabulatorRef.current = tabulator;
});
return () => tabulator.destroy();
}, []);
useEffect(() => {
const tabulator = tabulatorRef.current;
if (!tabulator || !events) return;
for (const [ eventName, handler ] of Object.entries(events)) {
tabulator.on(eventName as keyof EventCallBackMethods, handler);
}
return () => {
for (const [ eventName, handler ] of Object.entries(events)) {
tabulator.off(eventName as keyof EventCallBackMethods, handler);
}
}
}, Object.values(events ?? {}));
// Change in data.
useEffect(() => { tabulatorRef.current?.setData(data) }, [ data ]);
useEffect(() => { columns && tabulatorRef.current?.setColumns(columns)}, [ data]);
return (
<div ref={containerRef} className={className} />
);
}

View File

@ -0,0 +1,21 @@
import FNote from "../../../entities/fnote";
import { Attribute } from "../../../services/attribute_parser";
export function getFAttributeFromField(parentNote: FNote, field: string) {
const [ type, name ] = field.split(".", 2);
const attrName = `${type.replace("s", "")}:${name}`;
return parentNote.getLabel(attrName);
}
export function getAttributeFromField(parentNote: FNote, field: string): Attribute | undefined {
const fAttribute = getFAttributeFromField(parentNote, field);
if (fAttribute) {
return {
name: fAttribute.name,
value: fAttribute.value,
type: fAttribute.type,
isInheritable: fAttribute.isInheritable
};
}
return undefined;
}

View File

@ -1,6 +1,6 @@
import type FNote from "../../entities/fnote";
import type { ViewTypeOptions } from "../../services/note_list_renderer";
import server from "../../services/server";
import { ViewTypeOptions } from "../collections/interface";
const ATTACHMENT_ROLE = "viewConfig";

View File

@ -140,7 +140,7 @@ ws.subscribeToMessages(async (message) => {
};
}
if (message.taskType !== "export") {
if (!("taskType" in message) || message.taskType !== "export") {
return;
}

View File

@ -1,178 +0,0 @@
import NoteContextAwareWidget from "./note_context_aware_widget.js";
import NoteListRenderer from "../services/note_list_renderer.js";
import type FNote from "../entities/fnote.js";
import type { CommandListener, CommandListenerData, CommandMappings, CommandNames, EventData, EventNames } from "../components/app_context.js";
import type ViewMode from "./view_widgets/view_mode.js";
const TPL = /*html*/`
<div class="note-list-widget">
<style>
.note-list-widget {
min-height: 0;
overflow: auto;
}
.note-list-widget .note-list {
padding: 10px;
}
.note-list-widget.full-height,
.note-list-widget.full-height .note-list-widget-content {
height: 100%;
}
.note-list-widget video {
height: 100%;
}
</style>
<div class="note-list-widget-content">
</div>
</div>`;
export default class NoteListWidget extends NoteContextAwareWidget {
private $content!: JQuery<HTMLElement>;
private isIntersecting?: boolean;
private noteIdRefreshed?: string;
private shownNoteId?: string | null;
private viewMode?: ViewMode<any> | null;
private displayOnlyCollections: boolean;
/**
* @param displayOnlyCollections if set to `true` then only collection-type views are displayed such as geo-map and the calendar. The original book types grid and list will be ignored.
*/
constructor(displayOnlyCollections: boolean) {
super();
this.displayOnlyCollections = displayOnlyCollections;
}
isEnabled() {
if (!super.isEnabled()) {
return false;
}
if (this.displayOnlyCollections && this.note?.type !== "book") {
const viewType = this.note?.getLabelValue("viewType");
if (!viewType || ["grid", "list"].includes(viewType)) {
return false;
}
}
return this.noteContext?.hasNoteList();
}
doRender() {
this.$widget = $(TPL);
this.contentSized();
this.$content = this.$widget.find(".note-list-widget-content");
const observer = new IntersectionObserver(
(entries) => {
this.isIntersecting = entries[0].isIntersecting;
this.checkRenderStatus();
},
{
rootMargin: "50px",
threshold: 0.1
}
);
// there seems to be a race condition on Firefox which triggers the observer only before the widget is visible
// (intersection is false). https://github.com/zadam/trilium/issues/4165
setTimeout(() => observer.observe(this.$widget[0]), 10);
}
checkRenderStatus() {
// console.log("this.isIntersecting", this.isIntersecting);
// console.log(`${this.noteIdRefreshed} === ${this.noteId}`, this.noteIdRefreshed === this.noteId);
// console.log("this.shownNoteId !== this.noteId", this.shownNoteId !== this.noteId);
if (this.note && this.isIntersecting && this.noteIdRefreshed === this.noteId && this.shownNoteId !== this.noteId) {
this.shownNoteId = this.noteId;
this.renderNoteList(this.note);
}
}
async renderNoteList(note: FNote) {
const noteListRenderer = new NoteListRenderer({
$parent: this.$content,
parentNote: note,
parentNotePath: this.notePath
});
this.$widget.toggleClass("full-height", noteListRenderer.isFullHeight);
await noteListRenderer.renderList();
this.viewMode = noteListRenderer.viewMode;
}
async refresh() {
this.shownNoteId = null;
await super.refresh();
}
async refreshNoteListEvent({ noteId }: EventData<"refreshNoteList">) {
if (this.isNote(noteId) && this.note) {
await this.renderNoteList(this.note);
}
}
/**
* We have this event so that we evaluate intersection only after note detail is loaded.
* If it's evaluated before note detail, then it's clearly intersected (visible) although after note detail load
* it is not intersected (visible) anymore.
*/
noteDetailRefreshedEvent({ ntxId }: EventData<"noteDetailRefreshed">) {
if (!this.isNoteContext(ntxId)) {
return;
}
this.noteIdRefreshed = this.noteId;
setTimeout(() => this.checkRenderStatus(), 100);
}
notesReloadedEvent({ noteIds }: EventData<"notesReloaded">) {
if (this.noteId && noteIds.includes(this.noteId)) {
this.refresh();
}
}
entitiesReloadedEvent(e: EventData<"entitiesReloaded">) {
if (e.loadResults.getAttributeRows().find((attr) => attr.noteId === this.noteId && attr.name && ["viewType", "expanded", "pageSize"].includes(attr.name))) {
this.refresh();
this.checkRenderStatus();
}
}
buildTouchBarCommand(data: CommandListenerData<"buildTouchBar">) {
if (this.viewMode && "buildTouchBarCommand" in this.viewMode) {
return (this.viewMode as CommandListener<"buildTouchBar">).buildTouchBarCommand(data);
}
}
triggerCommand<K extends CommandNames>(name: K, data?: CommandMappings[K]): Promise<unknown> | undefined | null {
// Pass the commands to the view mode, which is not actually attached to the hierarchy.
if (this.viewMode?.triggerCommand(name, data)) {
return;
}
return super.triggerCommand(name, data);
}
handleEventInChildren<T extends EventNames>(name: T, data: EventData<T>): Promise<unknown[] | unknown> | null {
super.handleEventInChildren(name, data);
if (this.viewMode) {
const ret = this.viewMode.handleEvent(name, data);
if (ret) {
return ret;
}
}
return null;
}
}

View File

@ -195,6 +195,8 @@ export interface DragData {
title: string;
}
export const TREE_CLIPBOARD_TYPE = "application/x-fancytree-node";
export default class NoteTreeWidget extends NoteContextAwareWidget {
private $tree!: JQuery<HTMLElement>;
private $treeActions!: JQuery<HTMLElement>;

View File

@ -11,9 +11,10 @@ export interface ActionButtonProps {
onClick?: (e: MouseEvent) => void;
triggerCommand?: CommandNames;
noIconActionClass?: boolean;
frame?: boolean;
}
export default function ActionButton({ text, icon, className, onClick, triggerCommand, titlePosition, noIconActionClass }: ActionButtonProps) {
export default function ActionButton({ text, icon, className, onClick, triggerCommand, titlePosition, noIconActionClass, frame }: ActionButtonProps) {
const buttonRef = useRef<HTMLButtonElement>(null);
const [ keyboardShortcut, setKeyboardShortcut ] = useState<string[]>();
@ -31,7 +32,7 @@ export default function ActionButton({ text, icon, className, onClick, triggerCo
return <button
ref={buttonRef}
class={`${className ?? ""} ${!noIconActionClass ? "icon-action" : "btn"} ${icon}`}
class={`${className ?? ""} ${!noIconActionClass ? "icon-action" : "btn"} ${icon} ${frame ? "btn btn-primary" : ""}`}
onClick={onClick}
data-trigger-command={triggerCommand}
/>;

View File

@ -1,4 +1,4 @@
import type { RefObject } from "preact";
import type { ComponentChildren, RefObject } from "preact";
import type { CSSProperties } from "preact/compat";
import { useMemo } from "preact/hooks";
import { memo } from "preact/compat";
@ -72,4 +72,12 @@ const Button = memo(({ name, buttonRef, className, text, onClick, keyboardShortc
);
});
export function ButtonGroup({ children }: { children: ComponentChildren }) {
return (
<div className="btn-group" role="group">
{children}
</div>
)
}
export default Button;

View File

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

View File

@ -1,25 +1,35 @@
import { useEffect, useState } from "preact/hooks";
import { useEffect, useRef, useState } from "preact/hooks";
import link from "../../services/link";
import RawHtml from "./RawHtml";
import { useImperativeSearchHighlighlighting } from "./hooks";
interface NoteLinkOpts {
className?: string;
notePath: string | string[];
showNotePath?: boolean;
showNoteIcon?: boolean;
style?: Record<string, string | number>;
noPreview?: boolean;
noTnLink?: boolean;
highlightedTokens?: string[] | null | undefined;
}
export default function NoteLink({ notePath, showNotePath, showNoteIcon, style, noPreview, noTnLink }: NoteLinkOpts) {
export default function NoteLink({ className, notePath, showNotePath, showNoteIcon, style, noPreview, noTnLink, highlightedTokens }: NoteLinkOpts) {
const stringifiedNotePath = Array.isArray(notePath) ? notePath.join("/") : notePath;
const ref = useRef<HTMLSpanElement>(null);
const [ jqueryEl, setJqueryEl ] = useState<JQuery<HTMLElement>>();
const highlightSearch = useImperativeSearchHighlighlighting(highlightedTokens);
useEffect(() => {
link.createLink(stringifiedNotePath, { showNotePath, showNoteIcon })
.then(setJqueryEl);
}, [ stringifiedNotePath, showNotePath ]);
useEffect(() => {
if (!ref.current || !jqueryEl) return;
ref.current.replaceChildren(jqueryEl[0]);
highlightSearch(ref.current);
}, [ jqueryEl ]);
if (style) {
jqueryEl?.css(style);
}
@ -33,6 +43,10 @@ export default function NoteLink({ notePath, showNotePath, showNoteIcon, style,
$linkEl?.addClass("tn-link");
}
return <RawHtml html={jqueryEl} />
if (className) {
$linkEl?.addClass(className);
}
return <span ref={ref} />
}

View File

@ -1,4 +1,4 @@
import type { CSSProperties } from "preact/compat";
import type { CSSProperties, RefObject } from "preact/compat";
type HTMLElementLike = string | HTMLElement | JQuery<HTMLElement>;
@ -9,12 +9,12 @@ interface RawHtmlProps {
onClick?: (e: MouseEvent) => void;
}
export default function RawHtml(props: RawHtmlProps) {
return <span {...getProps(props)} />;
export default function RawHtml({containerRef, ...props}: RawHtmlProps & { containerRef?: RefObject<HTMLSpanElement>}) {
return <span ref={containerRef} {...getProps(props)} />;
}
export function RawHtmlBlock(props: RawHtmlProps) {
return <div {...getProps(props)} />
export function RawHtmlBlock({containerRef, ...props}: RawHtmlProps & { containerRef?: RefObject<HTMLDivElement>}) {
return <div ref={containerRef} {...getProps(props)} />
}
function getProps({ className, html, style, onClick }: RawHtmlProps) {

View File

@ -0,0 +1,204 @@
import { useContext, useEffect, useMemo, useState } from "preact/hooks";
import { ParentComponent } from "./react_utils";
import { ComponentChildren, createContext } from "preact";
import { TouchBarItem } from "../../components/touch_bar";
import { dynamicRequire, isElectron, isMac } from "../../services/utils";
interface TouchBarProps {
children: ComponentChildren;
}
interface LabelProps {
label: string;
}
interface SliderProps {
label: string;
value: number;
minValue: number;
maxValue: number;
onChange: (newValue: number) => void;
}
interface ButtonProps {
label?: string;
icon?: string;
click: () => void;
enabled?: boolean;
}
interface SpacerProps {
size: "flexible" | "large" | "small";
}
interface SegmentedControlProps {
mode: "single" | "buttons";
segments: {
label?: string;
icon?: string;
onClick?: () => void;
}[];
selectedIndex?: number;
onChange?: (selectedIndex: number, isSelected: boolean) => void;
}
interface TouchBarContextApi {
addItem(item: TouchBarItem): void;
TouchBar: typeof Electron.TouchBar;
nativeImage: typeof Electron.nativeImage;
}
const TouchBarContext = createContext<TouchBarContextApi | null>(null);
export default function TouchBar({ children }: TouchBarProps) {
if (!isElectron() || !isMac()) {
return;
}
const [ isFocused, setIsFocused ] = useState(false);
const parentComponent = useContext(ParentComponent);
const remote = dynamicRequire("@electron/remote") as typeof import("@electron/remote");
const items: TouchBarItem[] = [];
const api: TouchBarContextApi = {
TouchBar: remote.TouchBar,
nativeImage: remote.nativeImage,
addItem: (item) => {
items.push(item);
}
};
useEffect(() => {
const el = parentComponent?.$widget[0];
if (!el) return;
function onFocusGained() {
setIsFocused(true);
}
function onFocusLost() {
setIsFocused(false);
}
el.addEventListener("focusin", onFocusGained);
el.addEventListener("focusout", onFocusLost);
return () => {
el.removeEventListener("focusin", onFocusGained);
el.removeEventListener("focusout", onFocusLost);
}
}, []);
useEffect(() => {
if (isFocused) {
remote.getCurrentWindow().setTouchBar(new remote.TouchBar({ items }));
}
});
return (
<TouchBarContext.Provider value={api}>
{children}
</TouchBarContext.Provider>
);
}
export function TouchBarLabel({ label }: LabelProps) {
const api = useContext(TouchBarContext);
if (api) {
const item = new api.TouchBar.TouchBarLabel({
label
});
api.addItem(item);
}
return <></>;
}
export function TouchBarSlider({ label, value, minValue, maxValue, onChange }: SliderProps) {
const api = useContext(TouchBarContext);
if (api) {
const item = new api.TouchBar.TouchBarSlider({
label,
value, minValue, maxValue,
change: onChange
});
api.addItem(item);
}
return <></>;
}
export function TouchBarButton({ label, icon, click, enabled }: ButtonProps) {
const api = useContext(TouchBarContext);
const item = useMemo(() => {
if (!api) return null;
return new api.TouchBar.TouchBarButton({
label, click, enabled,
icon: icon ? buildIcon(api.nativeImage, icon) : undefined
});
}, [ label, icon ]);
if (item && api) {
api.addItem(item);
}
return <></>;
}
export function TouchBarSegmentedControl({ mode, segments, selectedIndex, onChange }: SegmentedControlProps) {
const api = useContext(TouchBarContext);
if (api) {
const processedSegments: Electron.SegmentedControlSegment[] = segments.map(({icon, ...restProps}) => ({
...restProps,
icon: icon ? buildIcon(api.nativeImage, icon) : undefined
}));
const item = new api.TouchBar.TouchBarSegmentedControl({
mode, selectedIndex,
segments: processedSegments,
change: (selectedIndex, isSelected) => {
if (segments[selectedIndex].onClick) {
segments[selectedIndex].onClick();
} else if (onChange) {
onChange(selectedIndex, isSelected);
}
}
});
api.addItem(item);
}
return <></>;
}
export function TouchBarSpacer({ size }: SpacerProps) {
const api = useContext(TouchBarContext);
if (api) {
const item = new api.TouchBar.TouchBarSpacer({
size
});
api.addItem(item);
}
return <></>;
}
function buildIcon(nativeImage: typeof Electron.nativeImage, name: string) {
const sourceImage = nativeImage.createFromNamedImage(name, [-1, 0, 1]);
const { width, height } = sourceImage.getSize();
const newImage = nativeImage.createEmpty();
newImage.addRepresentation({
scaleFactor: 1,
width: width / 2,
height: height / 2,
buffer: sourceImage.resize({ height: height / 2 }).toBitmap()
});
newImage.addRepresentation({
scaleFactor: 2,
width: width,
height: height,
buffer: sourceImage.toBitmap()
});
return newImage;
}

View File

@ -1,10 +1,10 @@
import { useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
import { EventData, EventNames } from "../../components/app_context";
import { Inputs, MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
import { CommandListenerData, EventData, EventNames } from "../../components/app_context";
import { ParentComponent } from "./react_utils";
import SpacedUpdate from "../../services/spaced_update";
import { KeyboardActionNames, OptionNames } from "@triliumnext/commons";
import { FilterLabelsByType, KeyboardActionNames, OptionNames, RelationNames } from "@triliumnext/commons";
import options, { type OptionValue } from "../../services/options";
import utils, { reloadFrontendApp } from "../../services/utils";
import utils, { escapeRegExp, reloadFrontendApp } from "../../services/utils";
import NoteContext from "../../components/note_context";
import BasicWidget, { ReactWrappedWidget } from "../basic_widget";
import FNote from "../../entities/fnote";
@ -15,6 +15,10 @@ import { RefObject, VNode } from "preact";
import { Tooltip } from "bootstrap";
import { CSSProperties } from "preact/compat";
import keyboard_actions from "../../services/keyboard_actions";
import Mark from "mark.js";
import { DragData } from "../note_tree";
import Component from "../../components/component";
import toast, { ToastOptions } from "../../services/toast";
export function useTriliumEvent<T extends EventNames>(eventName: T, handler: (data: EventData<T>) => void) {
const parentComponent = useContext(ParentComponent);
@ -51,21 +55,16 @@ export function useTriliumEvents<T extends EventNames>(eventNames: T[], handler:
export function useSpacedUpdate(callback: () => void | Promise<void>, interval = 1000) {
const callbackRef = useRef(callback);
const spacedUpdateRef = useRef<SpacedUpdate>();
const spacedUpdateRef = useRef<SpacedUpdate>(new SpacedUpdate(
() => callbackRef.current(),
interval
));
// Update callback ref when it changes
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
// Create SpacedUpdate instance only once
if (!spacedUpdateRef.current) {
spacedUpdateRef.current = new SpacedUpdate(
() => callbackRef.current(),
interval
);
}
// Update interval if it changes
useEffect(() => {
spacedUpdateRef.current?.setUpdateInterval(interval);
@ -259,7 +258,7 @@ export function useNoteProperty<T extends keyof FNote>(note: FNote | null | unde
return note?.[property];
}
export function useNoteRelation(note: FNote | undefined | null, relationName: string): [string | null | undefined, (newValue: string) => void] {
export function useNoteRelation(note: FNote | undefined | null, relationName: RelationNames): [string | null | undefined, (newValue: string) => void] {
const [ relationValue, setRelationValue ] = useState<string | null | undefined>(note?.getRelationValue(relationName));
useEffect(() => setRelationValue(note?.getRelationValue(relationName) ?? null), [ note ]);
@ -292,14 +291,18 @@ export function useNoteRelation(note: FNote | undefined | null, relationName: st
* @param labelName the name of the label to read/write.
* @returns an array where the first element is the getter and the second element is the setter. The setter has a special behaviour for convenience: if the value is undefined, the label is created without a value (e.g. a tag), if the value is null then the label is removed.
*/
export function useNoteLabel(note: FNote | undefined | null, labelName: string): [string | null | undefined, (newValue: string | null | undefined) => void] {
const [ labelValue, setLabelValue ] = useState<string | null | undefined>(note?.getLabelValue(labelName));
export function useNoteLabel(note: FNote | undefined | null, labelName: FilterLabelsByType<string>): [string | null | undefined, (newValue: string | null | undefined) => void] {
const [ , setLabelValue ] = useState<string | null | undefined>();
useEffect(() => setLabelValue(note?.getLabelValue(labelName) ?? null), [ note ]);
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
for (const attr of loadResults.getAttributeRows()) {
if (attr.type === "label" && attr.name === labelName && attributes.isAffecting(attr, note)) {
setLabelValue(attr.value ?? null);
if (!attr.isDeleted) {
setLabelValue(attr.value);
} else {
setLabelValue(null);
}
}
}
});
@ -317,12 +320,17 @@ export function useNoteLabel(note: FNote | undefined | null, labelName: string):
useDebugValue(labelName);
return [
labelValue,
note?.getLabelValue(labelName),
setter
] as const;
}
export function useNoteLabelBoolean(note: FNote | undefined | null, labelName: string): [ boolean, (newValue: boolean) => void] {
export function useNoteLabelWithDefault(note: FNote | undefined | null, labelName: FilterLabelsByType<string>, defaultValue: string): [string, (newValue: string | null | undefined) => void] {
const [ labelValue, setLabelValue ] = useNoteLabel(note, labelName);
return [ labelValue ?? defaultValue, setLabelValue];
}
export function useNoteLabelBoolean(note: FNote | undefined | null, labelName: FilterLabelsByType<boolean>): [ boolean, (newValue: boolean) => void] {
const [ labelValue, setLabelValue ] = useState<boolean>(!!note?.hasLabel(labelName));
useEffect(() => setLabelValue(!!note?.hasLabel(labelName)), [ note ]);
@ -350,7 +358,17 @@ export function useNoteLabelBoolean(note: FNote | undefined | null, labelName: s
return [ labelValue, setter ] as const;
}
export function useNoteBlob(note: FNote | null | undefined): [ FBlob | null | undefined ] {
export function useNoteLabelInt(note: FNote | undefined | null, labelName: FilterLabelsByType<number>): [ number | undefined, (newValue: number) => void] {
//@ts-expect-error `useNoteLabel` only accepts string properties but we need to be able to read number ones.
const [ value, setValue ] = useNoteLabel(note, labelName);
useDebugValue(labelName);
return [
(value ? parseInt(value, 10) : undefined),
(newValue) => setValue(String(newValue))
]
}
export function useNoteBlob(note: FNote | null | undefined): FBlob | null | undefined {
const [ blob, setBlob ] = useState<FBlob | null>();
function refresh() {
@ -359,14 +377,23 @@ export function useNoteBlob(note: FNote | null | undefined): [ FBlob | null | un
useEffect(refresh, [ note?.noteId ]);
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
if (note && loadResults.hasRevisionForNote(note.noteId)) {
if (!note) return;
// Check if the note was deleted.
if (loadResults.getEntityRow("notes", note.noteId)?.isDeleted) {
setBlob(null);
return;
}
// Check if a revision occurred.
if (loadResults.hasRevisionForNote(note.noteId)) {
refresh();
}
});
useDebugValue(note?.noteId);
return [ blob ] as const;
return blob;
}
export function useLegacyWidget<T extends BasicWidget>(widgetFactory: () => T, { noteContext, containerClassName, containerStyle }: {
@ -548,3 +575,121 @@ export function useSyncedRef<T>(externalRef?: RefObject<T>, initialValue: T | nu
return ref;
}
export function useImperativeSearchHighlighlighting(highlightedTokens: string[] | null | undefined) {
const mark = useRef<Mark>();
const highlightRegex = useMemo(() => {
if (!highlightedTokens?.length) return null;
const regex = highlightedTokens.map((token) => escapeRegExp(token)).join("|");
return new RegExp(regex, "gi")
}, [ highlightedTokens ]);
return (el: HTMLElement | null | undefined) => {
if (!el || !highlightRegex) return;
if (!mark.current) {
mark.current = new Mark(el);
}
mark.current.unmark();
mark.current.markRegExp(highlightRegex, {
element: "span",
className: "ck-find-result"
});
};
}
export function useNoteTreeDrag(containerRef: MutableRef<HTMLElement | null | undefined>, { dragEnabled, dragNotEnabledMessage, callback }: {
dragEnabled: boolean,
dragNotEnabledMessage: Omit<ToastOptions, "id" | "closeAfter">;
callback: (data: DragData[], e: DragEvent) => void
}) {
useEffect(() => {
const container = containerRef.current;
if (!container) return;
function onDragEnter(e: DragEvent) {
if (!dragEnabled) {
toast.showPersistent({
...dragNotEnabledMessage,
id: "drag-not-enabled",
closeAfter: 5000
});
}
}
function onDragOver(e: DragEvent) {
e.preventDefault();
}
function onDrop(e: DragEvent) {
toast.closePersistent("drag-not-enabled");
if (!dragEnabled) {
return;
}
const data = e.dataTransfer?.getData('text');
if (!data) {
return;
}
const parsedData = JSON.parse(data) as DragData[];
if (!parsedData.length) {
return;
}
callback(parsedData, e);
}
function onDragLeave() {
toast.closePersistent("drag-not-enabled");
}
container.addEventListener("dragenter", onDragEnter);
container.addEventListener("dragover", onDragOver);
container.addEventListener("drop", onDrop);
container.addEventListener("dragleave", onDragLeave)
return () => {
container.removeEventListener("dragenter", onDragEnter);
container.removeEventListener("dragover", onDragOver);
container.removeEventListener("drop", onDrop);
container.removeEventListener("dragleave", onDragLeave);
};
}, [ containerRef, callback ]);
}
export function useTouchBar(
factory: (context: CommandListenerData<"buildTouchBar"> & { parentComponent: Component | null }) => void,
inputs: Inputs
) {
const parentComponent = useContext(ParentComponent);
useLegacyImperativeHandlers({
buildTouchBarCommand(context: CommandListenerData<"buildTouchBar">) {
return factory({
...context,
parentComponent
});
}
});
useEffect(() => {
parentComponent?.triggerCommand("refreshTouchBar");
}, inputs);
}
export function useResizeObserver(ref: RefObject<HTMLElement>, callback: () => void) {
const resizeObserver = useRef<ResizeObserver>(null);
useEffect(() => {
resizeObserver.current?.disconnect();
const observer = new ResizeObserver(callback);
resizeObserver.current = observer;
if (ref.current) {
observer.observe(ref.current);
}
return () => observer.disconnect();
}, [ callback, ref ]);
}

View File

@ -24,11 +24,11 @@ export function refToJQuerySelector<T extends HTMLElement>(ref: RefObject<T> | n
* @param el the JSX element to render.
* @returns the rendered wrapped DOM element.
*/
export function renderReactWidget(parentComponent: Component, el: JSX.Element) {
export function renderReactWidget(parentComponent: Component | null, el: JSX.Element) {
return renderReactWidgetAtElement(parentComponent, el, new DocumentFragment()).children();
}
export function renderReactWidgetAtElement(parentComponent: Component, el: JSX.Element, container: Element | DocumentFragment) {
export function renderReactWidgetAtElement(parentComponent: Component | null, el: JSX.Element, container: Element | DocumentFragment) {
render((
<ParentComponent.Provider value={parentComponent}>
{el}

View File

@ -1,6 +1,5 @@
import { useContext, useMemo } from "preact/hooks";
import { t } from "../../services/i18n";
import { ViewTypeOptions } from "../../services/note_list_renderer";
import FormSelect, { FormSelectWithGroups } from "../react/FormSelect";
import { TabContext } from "./ribbon-interface";
import { mapToKeyValueArray } from "../../services/utils";
@ -12,6 +11,7 @@ import FNote from "../../entities/fnote";
import FormCheckbox from "../react/FormCheckbox";
import FormTextBox from "../react/FormTextBox";
import { ComponentChildren } from "preact";
import { ViewTypeOptions } from "../collections/interface";
const VIEW_TYPE_MAPPINGS: Record<ViewTypeOptions, string> = {
grid: t("book_properties.grid"),
@ -24,7 +24,7 @@ const VIEW_TYPE_MAPPINGS: Record<ViewTypeOptions, string> = {
export default function CollectionPropertiesTab({ note }: TabContext) {
const [ viewType, setViewType ] = useNoteLabel(note, "viewType");
const viewTypeWithDefault = viewType ?? "grid";
const viewTypeWithDefault = (viewType ?? "grid") as ViewTypeOptions;
const properties = bookPropertiesConfig[viewTypeWithDefault].properties;
return (
@ -32,7 +32,7 @@ export default function CollectionPropertiesTab({ note }: TabContext) {
{note && (
<>
<CollectionTypeSwitcher viewType={viewTypeWithDefault} setViewType={setViewType} />
<BookProperties note={note} properties={properties} />
<BookProperties viewType={viewTypeWithDefault} note={note} properties={properties} />
</>
)}
</div>
@ -54,15 +54,25 @@ function CollectionTypeSwitcher({ viewType, setViewType }: { viewType: string, s
)
}
function BookProperties({ note, properties }: { note: FNote, properties: BookProperty[] }) {
function BookProperties({ viewType, note, properties }: { viewType: ViewTypeOptions, note: FNote, properties: BookProperty[] }) {
return (
<div className="book-properties-container">
<>
{properties.map(property => (
<div className={`type-${property}`}>
{mapPropertyView({ note, property })}
</div>
))}
</div>
{viewType !== "list" && viewType !== "grid" && (
<CheckboxPropertyView
note={note} property={{
bindToLabel: "includeArchived",
label: t("book_properties.include_archived_notes"),
type: "checkbox"
}}
/>
)}
</>
)
}
@ -108,6 +118,7 @@ function CheckboxPropertyView({ note, property }: { note: FNote, property: Check
}
function NumberPropertyView({ note, property }: { note: FNote, property: NumberProperty }) {
//@ts-expect-error Interop with text box which takes in string values even for numbers.
const [ value, setValue ] = useNoteLabel(note, property.bindToLabel);
return (
@ -130,7 +141,7 @@ function ComboBoxPropertyView({ note, property }: { note: FNote, property: Combo
<FormSelectWithGroups
values={property.options}
keyProperty="value" titleProperty="label"
currentValue={value ?? ""} onChange={setValue}
currentValue={value ?? property.defaultValue} onChange={setValue}
/>
</LabelledEntry>
)

View File

@ -12,7 +12,7 @@ import FNote from "../../entities/fnote";
export default function FilePropertiesTab({ note }: { note?: FNote | null }) {
const [ originalFileName ] = useNoteLabel(note, "originalFileName");
const canAccessProtectedNote = !note?.isProtected || protected_session_holder.isProtectedSessionAvailable();
const [ blob ] = useNoteBlob(note);
const blob = useNoteBlob(note);
return (
<div className="file-properties-widget">

View File

@ -12,7 +12,7 @@ import toast from "../../services/toast";
export default function ImagePropertiesTab({ note, ntxId }: TabContext) {
const [ originalFileName ] = useNoteLabel(note, "originalFileName");
const [ blob ] = useNoteBlob(note);
const blob = useNoteBlob(note);
const parentComponent = useContext(ParentComponent);

View File

@ -1,9 +1,10 @@
import { t } from "i18next";
import FNote from "../../entities/fnote";
import attributes from "../../services/attributes";
import { ViewTypeOptions } from "../../services/note_list_renderer"
import NoteContextAwareWidget from "../note_context_aware_widget";
import { DEFAULT_MAP_LAYER_NAME, MAP_LAYERS, type MapLayer } from "../view_widgets/geo_view/map_layer";
import { DEFAULT_MAP_LAYER_NAME, MAP_LAYERS, type MapLayer } from "../collections/geomap/map_layer";
import { ViewTypeOptions } from "../collections/interface";
import { FilterLabelsByType } from "@triliumnext/commons";
interface BookConfig {
properties: BookProperty[];
@ -12,7 +13,7 @@ interface BookConfig {
export interface CheckBoxProperty {
type: "checkbox",
label: string;
bindToLabel: string
bindToLabel: FilterLabelsByType<boolean>
}
export interface ButtonProperty {
@ -26,7 +27,7 @@ export interface ButtonProperty {
export interface NumberProperty {
type: "number",
label: string;
bindToLabel: string;
bindToLabel: FilterLabelsByType<number>;
width?: number;
min?: number;
}
@ -44,7 +45,7 @@ interface ComboBoxGroup {
export interface ComboBoxProperty {
type: "combobox",
label: string;
bindToLabel: string;
bindToLabel: FilterLabelsByType<string>;
/**
* The default value is used when the label is not set.
*/

View File

@ -233,7 +233,6 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI
useEffect(() => refresh(), [ note ]);
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
if (loadResults.getAttributeRows(componentId).find((attr) => attributes.isAffecting(attr, note))) {
console.log("Trigger due to entities reloaded");
refresh();
}
});

View File

@ -179,6 +179,13 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.note-info-id {
font-variant: none;
font-family: var(--monospace-font-family);
font-size: 0.8em;
vertical-align: middle !important;
}
/* #endregion */
/* #region Similar Notes */
@ -336,31 +343,26 @@
.book-properties-widget {
padding: 12px 12px 6px 12px;
display: flex;
}
.book-properties-widget > * {
margin-right: 15px;
}
.book-properties-container {
display: flex;
flex-wrap: wrap;
gap: 15px;
overflow: hidden;
align-items: center;
}
.book-properties-container > div {
margin-right: 15px;
.book-properties-widget > * {
flex-shrink: 0;
}
.book-properties-container > .type-number > label {
.book-properties-widget > .type-number > label {
display: flex;
align-items: baseline;
}
.book-properties-container input[type="checkbox"] {
.book-properties-widget input[type="checkbox"] {
margin-right: 5px;
}
.book-properties-container label {
.book-properties-widget label {
display: flex;
justify-content: center;
align-items: center;

View File

@ -1,9 +1,10 @@
import { useEffect, useRef, useState } from "preact/hooks";
import { useEffect, useState } from "preact/hooks";
import { t } from "../services/i18n";
import Alert from "./react/Alert";
import { useNoteContext, useNoteProperty, useTriliumEvent } from "./react/hooks";
import { useNoteContext, useTriliumEvent } from "./react/hooks";
import "./search_result.css";
import NoteListRenderer from "../services/note_list_renderer";
import NoteList from "./collections/NoteList";
// import NoteListRenderer from "../services/note_list_renderer";
enum SearchResultState {
NO_RESULTS,
@ -14,26 +15,18 @@ enum SearchResultState {
export default function SearchResult() {
const { note, ntxId } = useNoteContext();
const [ state, setState ] = useState<SearchResultState>();
const searchContainerRef = useRef<HTMLDivElement>(null);
const [ highlightedTokens, setHighlightedTokens ] = useState<string[]>();
function refresh() {
searchContainerRef.current?.replaceChildren();
if (note?.type !== "search") {
setState(undefined);
} else if (!note?.searchResultsLoaded) {
setState(SearchResultState.NOT_EXECUTED);
} else if (note.getChildNoteIds().length === 0) {
setState(SearchResultState.NO_RESULTS);
} else if (searchContainerRef.current) {
} else {
setState(SearchResultState.GOT_RESULTS);
const noteListRenderer = new NoteListRenderer({
$parent: $(searchContainerRef.current),
parentNote: note,
showNotePath: true
});
noteListRenderer.renderList();
setHighlightedTokens(note.highlightedTokens);
}
}
@ -59,7 +52,9 @@ export default function SearchResult() {
<Alert type="info" className="search-no-results">{t("search_result.no_notes_found")}</Alert>
)}
<div ref={searchContainerRef} className="search-result-widget-content" />
{state === SearchResultState.GOT_RESULTS && (
<NoteList note={note} highlightedTokens={highlightedTokens} />
)}
</div>
);
}

View File

@ -14,9 +14,10 @@ export default function SqlResults() {
setResults(results);
})
const isEnabled = note?.mime === "text/x-sqlite;schema=trilium";
return (
<div className="sql-result-widget">
{note?.mime === "text/x-sqlite;schema=trilium" && (
<div className={`sql-result-widget ${!isEnabled ? "hidden-ext" : ""}`}>
{isEnabled && (
results?.length === 1 && Array.isArray(results[0]) && results[0].length === 0 ? (
<Alert type="info">
{t("sql_result.no_rows")}

View File

@ -5,6 +5,7 @@ import options from "../services/options.js";
import syncService from "../services/sync.js";
import { escapeQuotes } from "../services/utils.js";
import { Tooltip } from "bootstrap";
import { WebSocketMessage } from "@triliumnext/commons";
const TPL = /*html*/`
<div class="sync-status-widget launcher-button">
@ -117,8 +118,7 @@ export default class SyncStatusWidget extends BasicWidget {
this.$widget.find(`.sync-status-${className}`).show();
}
// TriliumNextTODO: Use Type Message from "services/ws.ts"
processMessage(message: { type: string; lastSyncedPush: number; data: { lastSyncedPush: number } }) {
processMessage(message: WebSocketMessage) {
if (message.type === "sync-pull-in-progress") {
this.syncState = "in-progress";
this.lastSyncedPush = message.lastSyncedPush;

Some files were not shown because too many files have changed in this diff Show More