From ffb90c2b4b7cf8f6bba711cf0bb2c5b474a442f6 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 30 Aug 2025 14:29:54 +0300 Subject: [PATCH 001/233] chore(react/collections): move files around for ease of development --- apps/client/src/layouts/desktop_layout.tsx | 4 ++-- apps/client/src/layouts/layout_commons.tsx | 4 ++-- apps/client/src/layouts/mobile_layout.tsx | 4 ++-- apps/client/src/widgets/collections/NoteList.tsx | 7 +++++++ .../{note_list.ts => collections/note_list.bak} | 10 +++++----- .../collections/note_list_renderer.ts.bak} | 16 ++++++++-------- apps/client/src/widgets/search_result.tsx | 15 ++++++++------- 7 files changed, 34 insertions(+), 26 deletions(-) create mode 100644 apps/client/src/widgets/collections/NoteList.tsx rename apps/client/src/widgets/{note_list.ts => collections/note_list.bak} (94%) rename apps/client/src/{services/note_list_renderer.ts => widgets/collections/note_list_renderer.ts.bak} (77%) diff --git a/apps/client/src/layouts/desktop_layout.tsx b/apps/client/src/layouts/desktop_layout.tsx index 5301702828..0e9a39caa3 100644 --- a/apps/client/src/layouts/desktop_layout.tsx +++ b/apps/client/src/layouts/desktop_layout.tsx @@ -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() .child(new NoteDetailWidget()) - .child(new NoteListWidget(false)) + .child() .child() .child() .child() diff --git a/apps/client/src/layouts/layout_commons.tsx b/apps/client/src/layouts/layout_commons.tsx index 02171db608..292006011a 100644 --- a/apps/client/src/layouts/layout_commons.tsx +++ b/apps/client/src/layouts/layout_commons.tsx @@ -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() .child(new PromotedAttributesWidget()) .child(new NoteDetailWidget()) - .child(new NoteListWidget(true))) + .child()) .child(); } diff --git a/apps/client/src/layouts/mobile_layout.tsx b/apps/client/src/layouts/mobile_layout.tsx index b7eceffa22..f7d3a13953 100644 --- a/apps/client/src/layouts/mobile_layout.tsx +++ b/apps/client/src/layouts/mobile_layout.tsx @@ -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"; @@ -23,6 +22,7 @@ import { MOBILE_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.j import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button.jsx"; import CloseZenModeButton from "../widgets/close_zen_button.js"; import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js"; +import NoteList from "../widgets/collections/NoteList.jsx"; const MOBILE_CSS = ` - -
-
-`; - export default class NoteListWidget extends NoteContextAwareWidget { private $content!: JQuery; @@ -49,10 +23,6 @@ export default class NoteListWidget extends NoteContextAwareWidget { } 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)) { diff --git a/apps/client/src/widgets/collections/note_list_renderer.ts.bak b/apps/client/src/widgets/collections/note_list_renderer.ts.bak index 9660883ea5..ce7bbc4269 100644 --- a/apps/client/src/widgets/collections/note_list_renderer.ts.bak +++ b/apps/client/src/widgets/collections/note_list_renderer.ts.bak @@ -17,17 +17,6 @@ export default class NoteListRenderer { 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": diff --git a/apps/client/src/widgets/view_widgets/list_or_grid_view.ts b/apps/client/src/widgets/view_widgets/list_or_grid_view.ts index 9ab58bbbf6..f68521d5cc 100644 --- a/apps/client/src/widgets/view_widgets/list_or_grid_view.ts +++ b/apps/client/src/widgets/view_widgets/list_or_grid_view.ts @@ -8,156 +8,6 @@ import type FNote from "../../entities/fnote.js"; import ViewMode, { type ViewModeArgs } from "./view_mode.js"; import { ViewTypeOptions } from "../collections/interface.js"; -const TPL = /*html*/` -
- - -
-
- - - -
-
-
`; - class ListOrGridView extends ViewMode<{}> { private $noteList: JQuery; diff --git a/apps/client/src/widgets/view_widgets/view_mode.ts b/apps/client/src/widgets/view_widgets/view_mode.ts index 2b7f02c9a5..cb7d3a8a84 100644 --- a/apps/client/src/widgets/view_widgets/view_mode.ts +++ b/apps/client/src/widgets/view_widgets/view_mode.ts @@ -48,10 +48,6 @@ export default abstract class ViewMode extends Component { } async entitiesReloadedEvent(e: EventData<"entitiesReloaded">) { - if (e.loadResults.getBranchRows().some(branch => branch.parentNoteId === this.parentNote.noteId || this.noteIds.includes(branch.parentNoteId ?? ""))) { - this.#refreshNoteIds(); - } - if (await this.onEntitiesReloaded(e)) { appContext.triggerEvent("refreshNoteList", { noteId: this.parentNote.noteId }); } @@ -70,14 +66,4 @@ export default abstract class ViewMode extends Component { return this._viewStorage; } - async #refreshNoteIds() { - let noteIds: string[]; - if (this.viewType === "list" || this.viewType === "grid") { - noteIds = this.args.parentNote.getChildNoteIds(); - } else { - noteIds = await this.args.parentNote.getSubtreeNoteIds(); - } - this.noteIds = noteIds; - } - } From 09fd1c7628a904ad9302add69a6a71f318088c38 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 30 Aug 2025 15:44:49 +0300 Subject: [PATCH 004/233] chore(react/collections): get list view to show something --- .../src/widgets/collections/NoteList.tsx | 17 ++--- .../src/widgets/collections/interface.ts | 9 +++ .../widgets/collections/legacy/ListView.tsx | 72 ++++++++++++++++++- apps/client/src/widgets/react/Icon.tsx | 5 +- .../widgets/view_widgets/list_or_grid_view.ts | 56 +-------------- 5 files changed, 91 insertions(+), 68 deletions(-) diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index c4402e8c78..d57c66871a 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -1,4 +1,4 @@ -import { allViewTypes, ViewTypeOptions } from "./interface"; +import { allViewTypes, ViewModeProps, ViewTypeOptions } from "./interface"; import { useNoteContext, useNoteLabel, useTriliumEvent } from "../react/hooks"; import FNote from "../../entities/fnote"; import "./NoteList.css"; @@ -13,27 +13,27 @@ export default function NoteList({ }: NoteListProps) { const { note } = useNoteContext(); const viewType = useNoteViewType(note); const noteIds = useNoteIds(note, viewType); - const isEnabled = (!!viewType); + const isEnabled = (note && !!viewType); // Refresh note Ids - console.log("Got note ids", noteIds); return (
{isEnabled && (
- {getComponentByViewType(viewType)} + {getComponentByViewType(note, noteIds, viewType)}
)}
); } -function getComponentByViewType(viewType: ViewTypeOptions) { - console.log("Got ", viewType); +function getComponentByViewType(note: FNote, noteIds: string[], viewType: ViewTypeOptions) { + const props: ViewModeProps = { note, noteIds }; + switch (viewType) { case "list": - return ; + return ; } } @@ -54,12 +54,13 @@ function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOptions | const [ noteIds, setNoteIds ] = useState([]); async function refreshNoteIds() { - console.log("Refreshed note IDs"); if (!note) { setNoteIds([]); } else if (viewType === "list" || viewType === "grid") { + console.log("Refreshed note IDs"); setNoteIds(note.getChildNoteIds()); } else { + console.log("Refreshed note IDs"); setNoteIds(await note.getSubtreeNoteIds()); } } diff --git a/apps/client/src/widgets/collections/interface.ts b/apps/client/src/widgets/collections/interface.ts index 6d64c2b45a..4c3c71e76a 100644 --- a/apps/client/src/widgets/collections/interface.ts +++ b/apps/client/src/widgets/collections/interface.ts @@ -1,5 +1,14 @@ +import FNote from "../../entities/fnote"; import type { ViewModeArgs } from "../view_widgets/view_mode"; export const allViewTypes = ["list", "grid", "calendar", "table", "geoMap", "board"] as const; export type ArgsWithoutNoteId = Omit; export type ViewTypeOptions = typeof allViewTypes[number]; + +export interface ViewModeProps { + note: FNote; + /** + * We're using noteIds so that it's not necessary to load all notes at once when paging. + */ + noteIds: string[]; +} diff --git a/apps/client/src/widgets/collections/legacy/ListView.tsx b/apps/client/src/widgets/collections/legacy/ListView.tsx index 25817b8061..550edff606 100644 --- a/apps/client/src/widgets/collections/legacy/ListView.tsx +++ b/apps/client/src/widgets/collections/legacy/ListView.tsx @@ -1,14 +1,80 @@ -export default function ListView() { +import { useEffect, useMemo, useState } from "preact/hooks"; +import FNote from "../../../entities/fnote"; +import Icon from "../../react/Icon"; +import { ViewModeProps } from "../interface"; +import { useNoteLabel, useNoteLabelBoolean } from "../../react/hooks"; +import froca from "../../../services/froca"; +import NoteLink from "../../react/NoteLink"; + +export default function ListView({ note, noteIds }: ViewModeProps) { + const [ isExpanded ] = useNoteLabelBoolean(note, "expanded"); + const filteredNoteIds = useMemo(() => { + // 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. + 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); + const { pageNotes } = usePagination(note, filteredNoteIds); + return (
- List view goes here.
- +
); +} + +function NoteCard({ note, expand }: { note: FNote, expand?: boolean }) { + const isSearch = note.type === "search"; + const notePath = isSearch + ? note.noteId // for search note parent, we want to display a non-search path + : `${note.noteId}/${note.noteId}`; + + return ( +
+
+ + +
+
+ ) +} + +function usePagination(note: FNote, noteIds: string[]) { + const [ page, setPage ] = useState(1); + const [ pageNotes, setPageNotes ] = useState(); + + // Parse page size. + const [ pageSize ] = useNoteLabel(note, "pageSize"); + const pageSizeNum = parseInt(pageSize ?? "", 10); + const normalizedPageSize = (pageSizeNum && pageSizeNum > 0 ? pageSizeNum : 20); + + // Calculate start/end index. + const startIdx = (page - 1) * normalizedPageSize; + const endIdx = startIdx + 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 + } } \ No newline at end of file diff --git a/apps/client/src/widgets/react/Icon.tsx b/apps/client/src/widgets/react/Icon.tsx index cc7afe8122..e047a1762b 100644 --- a/apps/client/src/widgets/react/Icon.tsx +++ b/apps/client/src/widgets/react/Icon.tsx @@ -1,7 +1,8 @@ interface IconProps { icon?: string; + className?: string; } -export default function Icon({ icon }: IconProps) { - return +export default function Icon({ icon, className }: IconProps) { + return } \ No newline at end of file diff --git a/apps/client/src/widgets/view_widgets/list_or_grid_view.ts b/apps/client/src/widgets/view_widgets/list_or_grid_view.ts index f68521d5cc..e533b3562b 100644 --- a/apps/client/src/widgets/view_widgets/list_or_grid_view.ts +++ b/apps/client/src/widgets/view_widgets/list_or_grid_view.ts @@ -17,40 +17,10 @@ class ListOrGridView extends ViewMode<{}> { private showNotePath?: boolean; private highlightRegex?: RegExp | null; - /* - * We're using noteIds so that it's not necessary to load all notes at once when paging - */ constructor(viewType: ViewTypeOptions, args: ViewModeArgs) { super(args, viewType); this.$noteList = $(TPL); - - - args.$parent.append(this.$noteList); - - this.page = 1; - this.pageSize = parseInt(args.parentNote.getLabelValue("pageSize") || ""); - - if (!this.pageSize || this.pageSize < 1) { - this.pageSize = 20; - } - this.$noteList.addClass(`${this.viewType}-view`); - - this.showNotePath = args.showNotePath; - } - - /** @returns {Set} list of noteIds included (images, included notes) in the parent note and which - * don't have to be shown in the note list. */ - getIncludedNoteIds() { - const includedLinks = this.parentNote ? this.parentNote.getRelations().filter((rel) => rel.name === "imageLink" || rel.name === "includeNoteLink") : []; - - return new Set(includedLinks.map((rel) => rel.value)); - } - - async beforeRender() { - super.beforeRender(); - const includedNoteIds = this.getIncludedNoteIds(); - this.filteredNoteIds = this.noteIds.filter((noteId) => !includedNoteIds.has(noteId) && noteId !== "_hidden"); } async renderList() { @@ -70,20 +40,6 @@ class ListOrGridView extends ViewMode<{}> { this.$noteList.show(); - const $container = this.$noteList.find(".note-list-container").empty(); - - const startIdx = (this.page - 1) * this.pageSize; - const endIdx = startIdx + this.pageSize; - - const pageNoteIds = this.filteredNoteIds.slice(startIdx, Math.min(endIdx, this.filteredNoteIds.length)); - const pageNotes = await froca.getNotes(pageNoteIds); - - for (const note of pageNotes) { - const $card = await this.renderNote(note, this.parentNote.isLabelTruthy("expanded")); - - $container.append($card); - } - this.renderPager(); return this.$noteList; @@ -132,25 +88,15 @@ class ListOrGridView extends ViewMode<{}> { } async renderNote(note: FNote, expand: boolean = false) { - const $expander = $(''); - const { $renderedAttributes } = await attributeRenderer.renderNormalAttributes(note); - const notePath = - this.parentNote.type === "search" - ? note.noteId // for search note parent, we want to display a non-search path - : `${this.parentNote.noteId}/${note.noteId}`; const $card = $('
') - .attr("data-note-id", note.noteId) - .addClass("no-tooltip-preview") .append( $('
') - .append($expander) - .append($('').addClass(note.getIcon())) .append( this.viewType === "grid" ? $('').text(await treeService.getNoteTitle(note.noteId, this.parentNote.noteId)) - : (await linkService.createLink(notePath, { showTooltip: false, showNotePath: this.showNotePath })).addClass("note-book-title") + : (await linkService.createLink(notePath, { showNotePath: this.showNotePath })).addClass("note-book-title") ) .append($renderedAttributes) ); From 1c986e2bf6385b45cec2340e01cc35e009a52657 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 30 Aug 2025 16:01:02 +0300 Subject: [PATCH 005/233] chore(react/collections/list): display note content --- .../collections/legacy/ListOrGridView.css | 4 --- .../widgets/collections/legacy/ListView.tsx | 36 ++++++++++++++++--- .../widgets/view_widgets/list_or_grid_view.ts | 21 +---------- 3 files changed, 33 insertions(+), 28 deletions(-) diff --git a/apps/client/src/widgets/collections/legacy/ListOrGridView.css b/apps/client/src/widgets/collections/legacy/ListOrGridView.css index 73c98a5f2a..21e60c981d 100644 --- a/apps/client/src/widgets/collections/legacy/ListOrGridView.css +++ b/apps/client/src/widgets/collections/legacy/ListOrGridView.css @@ -117,10 +117,6 @@ border: 1px solid transparent; } -.note-list.grid-view .note-expander { - display: none; -} - .note-list.grid-view .note-book-card { max-height: 300px; } diff --git a/apps/client/src/widgets/collections/legacy/ListView.tsx b/apps/client/src/widgets/collections/legacy/ListView.tsx index 550edff606..f6873aabfe 100644 --- a/apps/client/src/widgets/collections/legacy/ListView.tsx +++ b/apps/client/src/widgets/collections/legacy/ListView.tsx @@ -1,10 +1,12 @@ -import { useEffect, useMemo, useState } from "preact/hooks"; +import { useEffect, useMemo, useRef, useState } from "preact/hooks"; import FNote from "../../../entities/fnote"; import Icon from "../../react/Icon"; import { ViewModeProps } from "../interface"; -import { useNoteLabel, useNoteLabelBoolean } from "../../react/hooks"; +import { useNoteLabel, useNoteLabelBoolean, useNoteProperty } from "../../react/hooks"; import froca from "../../../services/froca"; import NoteLink from "../../react/NoteLink"; +import "./ListOrGridView.css"; +import content_renderer from "../../../services/content_renderer"; export default function ListView({ note, noteIds }: ViewModeProps) { const [ isExpanded ] = useNoteLabelBoolean(note, "expanded"); @@ -34,6 +36,7 @@ export default function ListView({ note, noteIds }: ViewModeProps) { } function NoteCard({ note, expand }: { note: FNote, expand?: boolean }) { + const [ isExpanded, setExpanded ] = useState(expand); const isSearch = note.type === "search"; const notePath = isSearch ? note.noteId // for search note parent, we want to display a non-search path @@ -41,17 +44,42 @@ function NoteCard({ note, expand }: { note: FNote, expand?: boolean }) { return (
+ setExpanded(!isExpanded)} + /> + +
) } +function NoteContent({ note, trim }: { note: FNote, trim?: boolean }) { + const contentRef = useRef(null); + + useEffect(() => { + content_renderer.getRenderedContent(note, { trim }) + .then(({ $renderedContent, type }) => { + contentRef.current?.replaceChildren(...$renderedContent); + contentRef.current?.classList.add(`type-${type}`); + }) + .catch(e => { + console.warn(`Caught error while rendering note '${note.noteId}' of type '${note.type}'`); + console.error(e); + contentRef.current?.replaceChildren("rendering error"); + }) + }, [ note ]); + + return
; +} + function usePagination(note: FNote, noteIds: string[]) { const [ page, setPage ] = useState(1); const [ pageNotes, setPageNotes ] = useState(); diff --git a/apps/client/src/widgets/view_widgets/list_or_grid_view.ts b/apps/client/src/widgets/view_widgets/list_or_grid_view.ts index e533b3562b..c61859f096 100644 --- a/apps/client/src/widgets/view_widgets/list_or_grid_view.ts +++ b/apps/client/src/widgets/view_widgets/list_or_grid_view.ts @@ -1,6 +1,5 @@ import linkService from "../../services/link.js"; import contentRenderer from "../../services/content_renderer.js"; -import froca from "../../services/froca.js"; import attributeRenderer from "../../services/attribute_renderer.js"; import treeService from "../../services/tree.js"; import utils from "../../services/utils.js"; @@ -130,22 +129,12 @@ class ListOrGridView extends ViewMode<{}> { const $expander = $card.find("> .note-book-header .note-expander"); - if (expand || this.viewType === "grid") { - $card.addClass("expanded"); - $expander.addClass("bx-chevron-down").removeClass("bx-chevron-right"); - } else { - $card.removeClass("expanded"); - $expander.addClass("bx-chevron-right").removeClass("bx-chevron-down"); - } - - if ((expand || this.viewType === "grid") && $card.find(".note-book-content").length === 0) { + if ((this.viewType === "grid")) { $card.append(await this.renderNoteContent(note)); } } async renderNoteContent(note: FNote) { - const $content = $('
'); - try { const { $renderedContent, type } = await contentRenderer.getRenderedContent(note, { trim: this.viewType === "grid" // for grid only short content is needed @@ -158,14 +147,6 @@ class ListOrGridView extends ViewMode<{}> { className: "ck-find-result" }); } - - $content.append($renderedContent); - $content.addClass(`type-${type}`); - } catch (e) { - console.warn(`Caught error while rendering note '${note.noteId}' of type '${note.type}'`); - console.error(e); - - $content.append("rendering error"); } if (this.viewType === "list") { From c2a5f437fd8d4c41897ed06c07ecc6d29f6a9b81 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 30 Aug 2025 16:19:21 +0300 Subject: [PATCH 006/233] chore(react/collections/list): display children recursively --- .../widgets/collections/legacy/ListView.tsx | 19 ++++++++++++++++++- .../widgets/view_widgets/list_or_grid_view.ts | 14 -------------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/apps/client/src/widgets/collections/legacy/ListView.tsx b/apps/client/src/widgets/collections/legacy/ListView.tsx index f6873aabfe..f28f8c2bbe 100644 --- a/apps/client/src/widgets/collections/legacy/ListView.tsx +++ b/apps/client/src/widgets/collections/legacy/ListView.tsx @@ -55,7 +55,10 @@ function NoteCard({ note, expand }: { note: FNote, expand?: boolean }) { - + {isExpanded && <> + + + }
) @@ -80,6 +83,20 @@ function NoteContent({ note, trim }: { note: FNote, trim?: boolean }) { return
; } +function NoteChildren({ note }: { note: FNote}) { + const imageLinks = note.getRelations("imageLink"); + const [ childNotes, setChildNotes ] = useState(); + + useEffect(() => { + note.getChildNotes().then(childNotes => { + const filteredChildNotes = childNotes.filter((childNote) => !imageLinks.find((rel) => rel.value === childNote.noteId)); + setChildNotes(filteredChildNotes); + }); + }, [ note ]); + + return childNotes?.map(childNote => ) +} + function usePagination(note: FNote, noteIds: string[]) { const [ page, setPage ] = useState(1); const [ pageNotes, setPageNotes ] = useState(); diff --git a/apps/client/src/widgets/view_widgets/list_or_grid_view.ts b/apps/client/src/widgets/view_widgets/list_or_grid_view.ts index c61859f096..b028fbe42c 100644 --- a/apps/client/src/widgets/view_widgets/list_or_grid_view.ts +++ b/apps/client/src/widgets/view_widgets/list_or_grid_view.ts @@ -127,8 +127,6 @@ class ListOrGridView extends ViewMode<{}> { return; } - const $expander = $card.find("> .note-book-header .note-expander"); - if ((this.viewType === "grid")) { $card.append(await this.renderNoteContent(note)); } @@ -148,18 +146,6 @@ class ListOrGridView extends ViewMode<{}> { }); } } - - if (this.viewType === "list") { - const imageLinks = note.getRelations("imageLink"); - - const childNotes = (await note.getChildNotes()).filter((childNote) => !imageLinks.find((rel) => rel.value === childNote.noteId)); - - for (const childNote of childNotes) { - $content.append(await this.renderNote(childNote)); - } - } - - return $content; } } From 12f805c02032af1f4f6aff1be5653dfd0eb35741 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 30 Aug 2025 16:40:25 +0300 Subject: [PATCH 007/233] chore(react/collections/list): display pagination --- .../src/widgets/collections/NoteList.css | 9 ++- .../widgets/collections/legacy/ListView.tsx | 74 ++++++++++++++++--- .../widgets/view_widgets/list_or_grid_view.ts | 34 --------- 3 files changed, 71 insertions(+), 46 deletions(-) diff --git a/apps/client/src/widgets/collections/NoteList.css b/apps/client/src/widgets/collections/NoteList.css index 612d088f48..08fab236c6 100644 --- a/apps/client/src/widgets/collections/NoteList.css +++ b/apps/client/src/widgets/collections/NoteList.css @@ -15,4 +15,11 @@ .note-list-widget video { height: 100%; -} \ No newline at end of file +} + +/* #region Pagination */ +.note-list-pager span.current-page { + text-decoration: underline; + font-weight: bold; +} +/* #endregion */ \ No newline at end of file diff --git a/apps/client/src/widgets/collections/legacy/ListView.tsx b/apps/client/src/widgets/collections/legacy/ListView.tsx index f28f8c2bbe..7c061921ba 100644 --- a/apps/client/src/widgets/collections/legacy/ListView.tsx +++ b/apps/client/src/widgets/collections/legacy/ListView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "preact/hooks"; +import { Dispatch, StateUpdater, useEffect, useMemo, useRef, useState } from "preact/hooks"; import FNote from "../../../entities/fnote"; import Icon from "../../react/Icon"; import { ViewModeProps } from "../interface"; @@ -7,6 +7,7 @@ import froca from "../../../services/froca"; import NoteLink from "../../react/NoteLink"; import "./ListOrGridView.css"; import content_renderer from "../../../services/content_renderer"; +import { ComponentChildren, VNode } from "preact"; export default function ListView({ note, noteIds }: ViewModeProps) { const [ isExpanded ] = useNoteLabelBoolean(note, "expanded"); @@ -16,12 +17,12 @@ export default function ListView({ note, noteIds }: ViewModeProps) { const includedNoteIds = new Set(includedLinks.map((rel) => rel.value)); return noteIds.filter((noteId) => !includedNoteIds.has(noteId) && noteId !== "_hidden"); }, noteIds); - const { pageNotes } = usePagination(note, filteredNoteIds); + const { pageNotes, ...pagination } = usePagination(note, filteredNoteIds); return (
-
+ -
+
); @@ -97,9 +98,59 @@ function NoteChildren({ note }: { note: FNote}) { return childNotes?.map(childNote => ) } -function usePagination(note: FNote, noteIds: string[]) { +function Pager({ page, pageSize, setPage, pageCount, totalNotes }: Omit) { + if (pageCount < 1) 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(( + setPage(i)} + > + {i} + + )) + } else { + // Current page + children.push({i}) + } + + children.push(<>{" "} {" "}); + } else if (lastPrinted) { + children.push(<>{"... "} {" "}); + lastPrinted = false; + } + } + + return ( +
+ {children} +
+ ) +} + +interface PaginationContext { + page: number; + setPage: Dispatch>; + pageNotes?: FNote[]; + pageCount: number; + pageSize: number; + totalNotes: number; +} + +function usePagination(note: FNote, noteIds: string[]): PaginationContext { const [ page, setPage ] = useState(1); - const [ pageNotes, setPageNotes ] = useState(); + const [ pageNotes, setPageNotes ] = useState(); // Parse page size. const [ pageSize ] = useNoteLabel(note, "pageSize"); @@ -109,17 +160,18 @@ function usePagination(note: FNote, noteIds: string[]) { // 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)); + const pageNoteIds = noteIds.slice(startIdx, Math.min(endIdx, noteIds.length)); useEffect(() => { froca.getNotes(pageNoteIds).then(setPageNotes); }, [ note, noteIds, page, pageSize ]); return { - page, - setPage, - pageNotes - } + page, setPage, pageNotes, pageCount, + pageSize: normalizedPageSize, + totalNotes: noteIds.length + }; } \ No newline at end of file diff --git a/apps/client/src/widgets/view_widgets/list_or_grid_view.ts b/apps/client/src/widgets/view_widgets/list_or_grid_view.ts index b028fbe42c..857f1c6f0c 100644 --- a/apps/client/src/widgets/view_widgets/list_or_grid_view.ts +++ b/apps/client/src/widgets/view_widgets/list_or_grid_view.ts @@ -45,42 +45,8 @@ class ListOrGridView extends ViewMode<{}> { } renderPager() { - const $pager = this.$noteList.find(".note-list-pager").empty(); - if (!this.page || !this.pageSize) { - return; - } - const pageCount = Math.ceil(this.filteredNoteIds.length / this.pageSize); - $pager.toggle(pageCount > 1); - - let lastPrinted; - - for (let i = 1; i <= pageCount; i++) { - if (pageCount < 20 || i <= 5 || pageCount - i <= 5 || Math.abs(this.page - i) <= 2) { - lastPrinted = true; - - const startIndex = (i - 1) * this.pageSize + 1; - const endIndex = Math.min(this.filteredNoteIds.length, i * this.pageSize); - - $pager.append( - i === this.page - ? $("").text(i).css("text-decoration", "underline").css("font-weight", "bold") - : $('') - .text(i) - .attr("title", `Page of ${startIndex} - ${endIndex}`) - .on("click", () => { - this.page = i; - this.renderList(); - }), - "   " - ); - } else if (lastPrinted) { - $pager.append("...   "); - - lastPrinted = false; - } - } // no need to distinguish "note" vs "notes" since in case of one result, there's no paging at all $pager.append(`(${this.filteredNoteIds.length} notes)`); From c13f5a9b04aaa1ba0c2138946785b667317b45c8 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 30 Aug 2025 16:58:23 +0300 Subject: [PATCH 008/233] refactor(react/collections/list): split pagination into separate file --- .../src/widgets/collections/Pagination.tsx | 83 ++++++++++++++++++ .../widgets/collections/legacy/ListView.tsx | 85 +------------------ 2 files changed, 86 insertions(+), 82 deletions(-) create mode 100644 apps/client/src/widgets/collections/Pagination.tsx diff --git a/apps/client/src/widgets/collections/Pagination.tsx b/apps/client/src/widgets/collections/Pagination.tsx new file mode 100644 index 0000000000..b37824e00e --- /dev/null +++ b/apps/client/src/widgets/collections/Pagination.tsx @@ -0,0 +1,83 @@ +import { ComponentChildren } from "preact"; +import { Dispatch, StateUpdater, useEffect, useState } from "preact/hooks"; +import FNote from "../../entities/fnote"; +import froca from "../../services/froca"; +import { useNoteLabel } from "../react/hooks"; + +interface PaginationContext { + page: number; + setPage: Dispatch>; + pageNotes?: FNote[]; + pageCount: number; + pageSize: number; + totalNotes: number; +} + +export function Pager({ page, pageSize, setPage, pageCount, totalNotes }: Omit) { + if (pageCount < 1) 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(( + setPage(i)} + > + {i} + + )) + } else { + // Current page + children.push({i}) + } + + children.push(<>{" "} {" "}); + } else if (lastPrinted) { + children.push(<>{"... "} {" "}); + lastPrinted = false; + } + } + + return ( +
+ {children} +
+ ) +} + +export function usePagination(note: FNote, noteIds: string[]): PaginationContext { + const [ page, setPage ] = useState(1); + const [ pageNotes, setPageNotes ] = useState(); + + // Parse page size. + const [ pageSize ] = useNoteLabel(note, "pageSize"); + const pageSizeNum = parseInt(pageSize ?? "", 10); + const normalizedPageSize = (pageSizeNum && pageSizeNum > 0 ? pageSizeNum : 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 + }; +} \ No newline at end of file diff --git a/apps/client/src/widgets/collections/legacy/ListView.tsx b/apps/client/src/widgets/collections/legacy/ListView.tsx index 7c061921ba..c9fc455b1d 100644 --- a/apps/client/src/widgets/collections/legacy/ListView.tsx +++ b/apps/client/src/widgets/collections/legacy/ListView.tsx @@ -1,13 +1,12 @@ -import { Dispatch, StateUpdater, useEffect, useMemo, useRef, useState } from "preact/hooks"; +import { useEffect, useMemo, useRef, useState } from "preact/hooks"; import FNote from "../../../entities/fnote"; import Icon from "../../react/Icon"; import { ViewModeProps } from "../interface"; -import { useNoteLabel, useNoteLabelBoolean, useNoteProperty } from "../../react/hooks"; -import froca from "../../../services/froca"; +import { useNoteLabelBoolean, useNoteProperty } from "../../react/hooks"; import NoteLink from "../../react/NoteLink"; import "./ListOrGridView.css"; import content_renderer from "../../../services/content_renderer"; -import { ComponentChildren, VNode } from "preact"; +import { Pager, usePagination } from "../Pagination"; export default function ListView({ note, noteIds }: ViewModeProps) { const [ isExpanded ] = useNoteLabelBoolean(note, "expanded"); @@ -97,81 +96,3 @@ function NoteChildren({ note }: { note: FNote}) { return childNotes?.map(childNote => ) } - -function Pager({ page, pageSize, setPage, pageCount, totalNotes }: Omit) { - if (pageCount < 1) 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(( - setPage(i)} - > - {i} - - )) - } else { - // Current page - children.push({i}) - } - - children.push(<>{" "} {" "}); - } else if (lastPrinted) { - children.push(<>{"... "} {" "}); - lastPrinted = false; - } - } - - return ( -
- {children} -
- ) -} - -interface PaginationContext { - page: number; - setPage: Dispatch>; - pageNotes?: FNote[]; - pageCount: number; - pageSize: number; - totalNotes: number; -} - -function usePagination(note: FNote, noteIds: string[]): PaginationContext { - const [ page, setPage ] = useState(1); - const [ pageNotes, setPageNotes ] = useState(); - - // Parse page size. - const [ pageSize ] = useNoteLabel(note, "pageSize"); - const pageSizeNum = parseInt(pageSize ?? "", 10); - const normalizedPageSize = (pageSizeNum && pageSizeNum > 0 ? pageSizeNum : 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 - }; -} \ No newline at end of file From a9c5a3105fa3a9338b62cb2850d3ad7fa0acfa2c Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 30 Aug 2025 17:00:24 +0300 Subject: [PATCH 009/233] chore(react/collections/list): add class to title --- apps/client/src/widgets/collections/legacy/ListView.tsx | 2 +- apps/client/src/widgets/react/NoteLink.tsx | 7 ++++++- apps/client/src/widgets/view_widgets/list_or_grid_view.ts | 1 - 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/client/src/widgets/collections/legacy/ListView.tsx b/apps/client/src/widgets/collections/legacy/ListView.tsx index c9fc455b1d..bb61ef04cd 100644 --- a/apps/client/src/widgets/collections/legacy/ListView.tsx +++ b/apps/client/src/widgets/collections/legacy/ListView.tsx @@ -54,7 +54,7 @@ function NoteCard({ note, expand }: { note: FNote, expand?: boolean }) { /> - + {isExpanded && <> diff --git a/apps/client/src/widgets/react/NoteLink.tsx b/apps/client/src/widgets/react/NoteLink.tsx index 221b902af2..09ed254d8a 100644 --- a/apps/client/src/widgets/react/NoteLink.tsx +++ b/apps/client/src/widgets/react/NoteLink.tsx @@ -3,6 +3,7 @@ import link from "../../services/link"; import RawHtml from "./RawHtml"; interface NoteLinkOpts { + className?: string; notePath: string | string[]; showNotePath?: boolean; showNoteIcon?: boolean; @@ -11,7 +12,7 @@ interface NoteLinkOpts { noTnLink?: boolean; } -export default function NoteLink({ notePath, showNotePath, showNoteIcon, style, noPreview, noTnLink }: NoteLinkOpts) { +export default function NoteLink({ className, notePath, showNotePath, showNoteIcon, style, noPreview, noTnLink }: NoteLinkOpts) { const stringifiedNotePath = Array.isArray(notePath) ? notePath.join("/") : notePath; const [ jqueryEl, setJqueryEl ] = useState>(); @@ -33,6 +34,10 @@ export default function NoteLink({ notePath, showNotePath, showNoteIcon, style, $linkEl?.addClass("tn-link"); } + if (className) { + $linkEl?.addClass(className); + } + return } \ No newline at end of file diff --git a/apps/client/src/widgets/view_widgets/list_or_grid_view.ts b/apps/client/src/widgets/view_widgets/list_or_grid_view.ts index 857f1c6f0c..28f4fd53e9 100644 --- a/apps/client/src/widgets/view_widgets/list_or_grid_view.ts +++ b/apps/client/src/widgets/view_widgets/list_or_grid_view.ts @@ -61,7 +61,6 @@ class ListOrGridView extends ViewMode<{}> { .append( this.viewType === "grid" ? $('').text(await treeService.getNoteTitle(note.noteId, this.parentNote.noteId)) - : (await linkService.createLink(notePath, { showNotePath: this.showNotePath })).addClass("note-book-title") ) .append($renderedAttributes) ); From 49b189e7a999e66d3d158f09276dedb6afd17d42 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 30 Aug 2025 17:03:21 +0300 Subject: [PATCH 010/233] chore(react/collections/list): add note count to pagination --- apps/client/src/translations/en/translation.json | 4 ++++ apps/client/src/widgets/collections/Pagination.tsx | 5 ++++- apps/client/src/widgets/view_widgets/list_or_grid_view.ts | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index d76843a270..d265e02838 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -2024,5 +2024,9 @@ }, "units": { "percentage": "%" + }, + "pagination": { + "page_title": "Page of {{startIndex}} - {{endIndex}}", + "total_notes": "{{count}} notes" } } diff --git a/apps/client/src/widgets/collections/Pagination.tsx b/apps/client/src/widgets/collections/Pagination.tsx index b37824e00e..9d4782da4d 100644 --- a/apps/client/src/widgets/collections/Pagination.tsx +++ b/apps/client/src/widgets/collections/Pagination.tsx @@ -3,6 +3,7 @@ import { Dispatch, StateUpdater, useEffect, useState } from "preact/hooks"; import FNote from "../../entities/fnote"; import froca from "../../services/froca"; import { useNoteLabel } from "../react/hooks"; +import { t } from "../../services/i18n"; interface PaginationContext { page: number; @@ -29,7 +30,7 @@ export function Pager({ page, pageSize, setPage, pageCount, totalNotes }: Omit

setPage(i)} > {i} @@ -50,6 +51,8 @@ export function Pager({ page, pageSize, setPage, pageCount, totalNotes }: Omit

{children} + + ({t("pagination.total_notes", { count: totalNotes })})

) } diff --git a/apps/client/src/widgets/view_widgets/list_or_grid_view.ts b/apps/client/src/widgets/view_widgets/list_or_grid_view.ts index 28f4fd53e9..8e88ebdcf9 100644 --- a/apps/client/src/widgets/view_widgets/list_or_grid_view.ts +++ b/apps/client/src/widgets/view_widgets/list_or_grid_view.ts @@ -49,7 +49,7 @@ class ListOrGridView extends ViewMode<{}> { // no need to distinguish "note" vs "notes" since in case of one result, there's no paging at all - $pager.append(`(${this.filteredNoteIds.length} notes)`); + $pager.append(``); } async renderNote(note: FNote, expand: boolean = false) { From 4891721cc061aae2e84f58395f4cb232f0f14486 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 30 Aug 2025 17:06:21 +0300 Subject: [PATCH 011/233] chore(react): fix editorconfig --- .editorconfig | 2 +- apps/client/src/widgets/collections/Pagination.tsx | 11 ++++++----- .../src/widgets/view_widgets/list_or_grid_view.ts | 10 ---------- 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/.editorconfig b/.editorconfig index cd301498ec..cebb2ba580 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,6 +1,6 @@ root = true -[*.{js,ts,.tsx}] +[*.{js,ts,tsx}] charset = utf-8 end_of_line = lf indent_size = 4 diff --git a/apps/client/src/widgets/collections/Pagination.tsx b/apps/client/src/widgets/collections/Pagination.tsx index 9d4782da4d..435f9049bf 100644 --- a/apps/client/src/widgets/collections/Pagination.tsx +++ b/apps/client/src/widgets/collections/Pagination.tsx @@ -33,10 +33,10 @@ export function Pager({ page, pageSize, setPage, pageCount, totalNotes }: Omit

setPage(i)} > - {i} + {i} )) - } else { + } else { // Current page children.push({i}) } @@ -52,6 +52,7 @@ export function Pager({ page, pageSize, setPage, pageCount, totalNotes }: Omit

{children} + // no need to distinguish "note" vs "notes" since in case of one result, there's no paging at all ({t("pagination.total_notes", { count: totalNotes })}) ) @@ -59,7 +60,7 @@ export function Pager({ page, pageSize, setPage, pageCount, totalNotes }: Omit

(); + const [ pageNotes, setPageNotes ] = useState(); // Parse page size. const [ pageSize ] = useNoteLabel(note, "pageSize"); @@ -72,7 +73,7 @@ export function usePagination(note: FNote, noteIds: string[]): PaginationContext const pageCount = Math.ceil(noteIds.length / normalizedPageSize); // Obtain notes within the range. - const pageNoteIds = noteIds.slice(startIdx, Math.min(endIdx, noteIds.length)); + const pageNoteIds = noteIds.slice(startIdx, Math.min(endIdx, noteIds.length)); useEffect(() => { froca.getNotes(pageNoteIds).then(setPageNotes); @@ -83,4 +84,4 @@ export function usePagination(note: FNote, noteIds: string[]): PaginationContext pageSize: normalizedPageSize, totalNotes: noteIds.length }; -} \ No newline at end of file +} diff --git a/apps/client/src/widgets/view_widgets/list_or_grid_view.ts b/apps/client/src/widgets/view_widgets/list_or_grid_view.ts index 8e88ebdcf9..0b1d6b5798 100644 --- a/apps/client/src/widgets/view_widgets/list_or_grid_view.ts +++ b/apps/client/src/widgets/view_widgets/list_or_grid_view.ts @@ -39,19 +39,9 @@ class ListOrGridView extends ViewMode<{}> { this.$noteList.show(); - this.renderPager(); - return this.$noteList; } - renderPager() { - - - - // no need to distinguish "note" vs "notes" since in case of one result, there's no paging at all - $pager.append(``); - } - async renderNote(note: FNote, expand: boolean = false) { const { $renderedAttributes } = await attributeRenderer.renderNormalAttributes(note); From 5cf18ae17c475fe2b6601170f7349f6901b4fcf3 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 30 Aug 2025 17:21:22 +0300 Subject: [PATCH 012/233] chore(react/collections/view): first implementation --- .../src/widgets/collections/NoteList.tsx | 14 ++- .../src/widgets/collections/Pagination.tsx | 1 - .../collections/legacy/ListOrGridView.css | 1 - .../widgets/collections/legacy/ListView.tsx | 105 ++++++++++++++---- .../widgets/view_widgets/list_or_grid_view.ts | 36 +----- 5 files changed, 91 insertions(+), 66 deletions(-) diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index d57c66871a..d749e28440 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -2,7 +2,7 @@ import { allViewTypes, ViewModeProps, ViewTypeOptions } from "./interface"; import { useNoteContext, useNoteLabel, useTriliumEvent } from "../react/hooks"; import FNote from "../../entities/fnote"; import "./NoteList.css"; -import ListView from "./legacy/ListView"; +import { ListView, GridView } from "./legacy/ListView"; import { useEffect, useState } from "preact/hooks"; interface NoteListProps { @@ -30,16 +30,18 @@ export default function NoteList({ }: NoteListProps) { function getComponentByViewType(note: FNote, noteIds: string[], viewType: ViewTypeOptions) { const props: ViewModeProps = { note, noteIds }; - + switch (viewType) { case "list": return ; + case "grid": + return ; } } function useNoteViewType(note?: FNote | null): ViewTypeOptions | undefined { const [ viewType ] = useNoteLabel(note, "viewType"); - + if (!note) { return undefined; } else if (!(allViewTypes as readonly string[]).includes(viewType || "")) { @@ -52,7 +54,7 @@ function useNoteViewType(note?: FNote | null): ViewTypeOptions | undefined { function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOptions | undefined) { const [ noteIds, setNoteIds ] = useState([]); - + async function refreshNoteIds() { if (!note) { setNoteIds([]); @@ -73,9 +75,9 @@ function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOptions | if (note && loadResults.getBranchRows().some(branch => branch.parentNoteId === note.noteId || noteIds.includes(branch.parentNoteId ?? ""))) { - refreshNoteIds(); + refreshNoteIds(); } }) return noteIds; -} \ No newline at end of file +} diff --git a/apps/client/src/widgets/collections/Pagination.tsx b/apps/client/src/widgets/collections/Pagination.tsx index 435f9049bf..969a173cc3 100644 --- a/apps/client/src/widgets/collections/Pagination.tsx +++ b/apps/client/src/widgets/collections/Pagination.tsx @@ -52,7 +52,6 @@ export function Pager({ page, pageSize, setPage, pageCount, totalNotes }: Omit

{children} - // no need to distinguish "note" vs "notes" since in case of one result, there's no paging at all ({t("pagination.total_notes", { count: totalNotes })}) ) diff --git a/apps/client/src/widgets/collections/legacy/ListOrGridView.css b/apps/client/src/widgets/collections/legacy/ListOrGridView.css index 21e60c981d..f981144b4c 100644 --- a/apps/client/src/widgets/collections/legacy/ListOrGridView.css +++ b/apps/client/src/widgets/collections/legacy/ListOrGridView.css @@ -17,7 +17,6 @@ } .note-book-card:not(.expanded) .note-book-content { - display: none !important; padding: 10px } diff --git a/apps/client/src/widgets/collections/legacy/ListView.tsx b/apps/client/src/widgets/collections/legacy/ListView.tsx index bb61ef04cd..46450707ea 100644 --- a/apps/client/src/widgets/collections/legacy/ListView.tsx +++ b/apps/client/src/widgets/collections/legacy/ListView.tsx @@ -7,45 +7,60 @@ 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"; -export default function ListView({ note, noteIds }: ViewModeProps) { +export function ListView({ note, noteIds: unfilteredNoteIds }: ViewModeProps) { const [ isExpanded ] = useNoteLabelBoolean(note, "expanded"); - const filteredNoteIds = useMemo(() => { - // 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. - 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); - const { pageNotes, ...pagination } = usePagination(note, filteredNoteIds); + const noteIds = useFilteredNoteIds(note, unfilteredNoteIds); + const { pageNotes, ...pagination } = usePagination(note, noteIds); return ( -

+
- + - +
); } -function NoteCard({ note, expand }: { note: FNote, expand?: boolean }) { +export function GridView({ note, noteIds: unfilteredNoteIds }: ViewModeProps) { + const noteIds = useFilteredNoteIds(note, unfilteredNoteIds); + const { pageNotes, ...pagination } = usePagination(note, noteIds); + + return ( +
+
+ + + + + +
+
+ ); +} + +function ListNoteCard({ note, parentNote, expand }: { note: FNote, parentNote: FNote, expand?: boolean }) { const [ isExpanded, setExpanded ] = useState(expand); - const isSearch = note.type === "search"; - const notePath = isSearch - ? note.noteId // for search note parent, we want to display a non-search path - : `${note.noteId}/${note.noteId}`; + const notePath = getNotePath(parentNote, note); return (
- + {isExpanded && <> - + }
) } +function GridNoteCard({ note, parentNote }: { note: FNote, parentNote: FNote }) { + const [ noteTitle, setNoteTitle ] = useState(); + const notePath = getNotePath(parentNote, note); + + useEffect(() => { + tree.getNoteTitle(note.noteId, parentNote.noteId).then(setNoteTitle); + }, [ note ]); + + return ( +
link.goToLink(e)} + > +
+ + {noteTitle} +
+ +
+ ) +} + function NoteContent({ note, trim }: { note: FNote, trim?: boolean }) { const contentRef = useRef(null); @@ -83,7 +122,7 @@ function NoteContent({ note, trim }: { note: FNote, trim?: boolean }) { return
; } -function NoteChildren({ note }: { note: FNote}) { +function NoteChildren({ note, parentNote }: { note: FNote, parentNote: FNote }) { const imageLinks = note.getRelations("imageLink"); const [ childNotes, setChildNotes ] = useState(); @@ -94,5 +133,25 @@ function NoteChildren({ note }: { note: FNote}) { }); }, [ note ]); - return childNotes?.map(childNote => ) + return childNotes?.map(childNote => ) +} + +/** + * 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}` + } } diff --git a/apps/client/src/widgets/view_widgets/list_or_grid_view.ts b/apps/client/src/widgets/view_widgets/list_or_grid_view.ts index 0b1d6b5798..28f2cb866d 100644 --- a/apps/client/src/widgets/view_widgets/list_or_grid_view.ts +++ b/apps/client/src/widgets/view_widgets/list_or_grid_view.ts @@ -13,7 +13,6 @@ class ListOrGridView extends ViewMode<{}> { private filteredNoteIds!: string[]; private page?: number; private pageSize?: number; - private showNotePath?: boolean; private highlightRegex?: RegExp | null; constructor(viewType: ViewTypeOptions, args: ViewModeArgs) { @@ -45,25 +44,6 @@ class ListOrGridView extends ViewMode<{}> { async renderNote(note: FNote, expand: boolean = false) { const { $renderedAttributes } = await attributeRenderer.renderNormalAttributes(note); - const $card = $('
') - .append( - $('
') - .append( - this.viewType === "grid" - ? $('').text(await treeService.getNoteTitle(note.noteId, this.parentNote.noteId)) - ) - .append($renderedAttributes) - ); - - if (this.viewType === "grid") { - $card - .addClass("block-link") - .attr("data-href", `#${notePath}`) - .on("click", (e) => linkService.goToLink(e)); - } - - $expander.on("click", () => this.toggleContent($card, note, !$card.hasClass("expanded"))); - if (this.highlightRegex) { const Mark = new (await import("mark.js")).default($card.find(".note-book-title")[0]); Mark.markRegExp(this.highlightRegex, { @@ -71,26 +51,12 @@ class ListOrGridView extends ViewMode<{}> { className: "ck-find-result" }); } - - await this.toggleContent($card, note, expand); - - return $card; - } - - async toggleContent($card: JQuery, note: FNote, expand: boolean) { - if (this.viewType === "list" && ((expand && $card.hasClass("expanded")) || (!expand && !$card.hasClass("expanded")))) { - return; - } - - if ((this.viewType === "grid")) { - $card.append(await this.renderNoteContent(note)); - } } async renderNoteContent(note: FNote) { try { const { $renderedContent, type } = await contentRenderer.getRenderedContent(note, { - trim: this.viewType === "grid" // for grid only short content is needed + trim: this.viewType === "grid" }); if (this.highlightRegex) { From 566ffbdde21afb7bca860fd3f334e4ee8229e23f Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 30 Aug 2025 17:26:43 +0300 Subject: [PATCH 013/233] fix(react/collections): pagination displayed when not needed --- apps/client/src/widgets/collections/Pagination.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/widgets/collections/Pagination.tsx b/apps/client/src/widgets/collections/Pagination.tsx index 969a173cc3..36a751cda6 100644 --- a/apps/client/src/widgets/collections/Pagination.tsx +++ b/apps/client/src/widgets/collections/Pagination.tsx @@ -15,7 +15,7 @@ interface PaginationContext { } export function Pager({ page, pageSize, setPage, pageCount, totalNotes }: Omit) { - if (pageCount < 1) return; + if (pageCount < 2) return; let lastPrinted = false; let children: ComponentChildren[] = []; From c4d771f2c6ba9de363275514e3883e6c2ec94024 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 30 Aug 2025 17:30:35 +0300 Subject: [PATCH 014/233] chore(react/collections): use translation --- apps/client/src/translations/en/translation.json | 3 +++ apps/client/src/widgets/collections/legacy/ListView.tsx | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index d265e02838..81c0cacc74 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -2028,5 +2028,8 @@ "pagination": { "page_title": "Page of {{startIndex}} - {{endIndex}}", "total_notes": "{{count}} notes" + }, + "collections": { + "rendering_error": "Unable to show content due to an error." } } diff --git a/apps/client/src/widgets/collections/legacy/ListView.tsx b/apps/client/src/widgets/collections/legacy/ListView.tsx index 46450707ea..cc08685cc2 100644 --- a/apps/client/src/widgets/collections/legacy/ListView.tsx +++ b/apps/client/src/widgets/collections/legacy/ListView.tsx @@ -9,6 +9,7 @@ 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"; export function ListView({ note, noteIds: unfilteredNoteIds }: ViewModeProps) { const [ isExpanded ] = useNoteLabelBoolean(note, "expanded"); @@ -115,7 +116,7 @@ function NoteContent({ note, trim }: { note: FNote, trim?: boolean }) { .catch(e => { console.warn(`Caught error while rendering note '${note.noteId}' of type '${note.type}'`); console.error(e); - contentRef.current?.replaceChildren("rendering error"); + contentRef.current?.replaceChildren(t("collections.rendering_error")); }) }, [ note ]); From f92948d65c2d24cf80d765823b73432209afc5d1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 30 Aug 2025 17:39:09 +0300 Subject: [PATCH 015/233] chore(react/collections): bring back attribute rendering --- .../collections/legacy/ListOrGridView.css | 2 +- .../widgets/collections/legacy/ListView.tsx | 19 +++++++++++++++++-- .../widgets/view_widgets/list_or_grid_view.ts | 12 ------------ 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/apps/client/src/widgets/collections/legacy/ListOrGridView.css b/apps/client/src/widgets/collections/legacy/ListOrGridView.css index f981144b4c..1e7fa1a0d0 100644 --- a/apps/client/src/widgets/collections/legacy/ListOrGridView.css +++ b/apps/client/src/widgets/collections/legacy/ListOrGridView.css @@ -50,7 +50,7 @@ } .note-book-header .rendered-note-attributes:before { - content: "\\00a0\\00a0"; + content: "\00a0\00a0"; } .note-book-header .note-icon { diff --git a/apps/client/src/widgets/collections/legacy/ListView.tsx b/apps/client/src/widgets/collections/legacy/ListView.tsx index cc08685cc2..1bf98c3deb 100644 --- a/apps/client/src/widgets/collections/legacy/ListView.tsx +++ b/apps/client/src/widgets/collections/legacy/ListView.tsx @@ -10,6 +10,7 @@ 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 }: ViewModeProps) { const [ isExpanded ] = useNoteLabelBoolean(note, "expanded"); @@ -18,7 +19,7 @@ export function ListView({ note, noteIds: unfilteredNoteIds }: ViewModeProps) { return (
-
+ { noteIds.length > 0 &&
-
+
}
); } @@ -71,6 +72,8 @@ function ListNoteCard({ note, parentNote, expand }: { note: FNote, parentNote: F + + {isExpanded && <> @@ -98,12 +101,24 @@ function GridNoteCard({ note, parentNote }: { note: FNote, parentNote: FNote })
{noteTitle} +
) } +function NoteAttributes({ note }: { note: FNote }) { + const ref = useRef(null); + useEffect(() => { + attribute_renderer.renderNormalAttributes(note).then(({$renderedAttributes}) => { + ref.current?.replaceChildren(...$renderedAttributes); + }); + }, [ note ]); + + return +} + function NoteContent({ note, trim }: { note: FNote, trim?: boolean }) { const contentRef = useRef(null); diff --git a/apps/client/src/widgets/view_widgets/list_or_grid_view.ts b/apps/client/src/widgets/view_widgets/list_or_grid_view.ts index 28f2cb866d..2ca6b246ae 100644 --- a/apps/client/src/widgets/view_widgets/list_or_grid_view.ts +++ b/apps/client/src/widgets/view_widgets/list_or_grid_view.ts @@ -15,12 +15,6 @@ class ListOrGridView extends ViewMode<{}> { private pageSize?: number; private highlightRegex?: RegExp | null; - constructor(viewType: ViewTypeOptions, args: ViewModeArgs) { - super(args, viewType); - this.$noteList = $(TPL); - this.$noteList.addClass(`${this.viewType}-view`); - } - async renderList() { if (this.filteredNoteIds.length === 0 || !this.page || !this.pageSize) { this.$noteList.hide(); @@ -42,8 +36,6 @@ class ListOrGridView extends ViewMode<{}> { } async renderNote(note: FNote, expand: boolean = false) { - const { $renderedAttributes } = await attributeRenderer.renderNormalAttributes(note); - if (this.highlightRegex) { const Mark = new (await import("mark.js")).default($card.find(".note-book-title")[0]); Mark.markRegExp(this.highlightRegex, { @@ -55,10 +47,6 @@ class ListOrGridView extends ViewMode<{}> { async renderNoteContent(note: FNote) { try { - const { $renderedContent, type } = await contentRenderer.getRenderedContent(note, { - trim: this.viewType === "grid" - }); - if (this.highlightRegex) { const Mark = new (await import("mark.js")).default($renderedContent[0]); Mark.markRegExp(this.highlightRegex, { From 68dff71512ab0fd36294910409eca06b9c845c15 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 30 Aug 2025 18:48:34 +0300 Subject: [PATCH 016/233] chore(react/collections): title highlighting in list title --- apps/client/src/services/utils.ts | 2 +- .../src/widgets/collections/NoteList.tsx | 15 ++-- .../src/widgets/collections/interface.ts | 1 + .../widgets/collections/legacy/ListView.tsx | 19 ++--- apps/client/src/widgets/react/NoteLink.tsx | 14 ++-- apps/client/src/widgets/react/RawHtml.tsx | 12 +-- apps/client/src/widgets/react/hooks.tsx | 75 ++++++++++++------- apps/client/src/widgets/search_result.tsx | 28 +++---- 8 files changed, 96 insertions(+), 70 deletions(-) diff --git a/apps/client/src/services/utils.ts b/apps/client/src/services/utils.ts index 77fec13668..4b425f8320 100644 --- a/apps/client/src/services/utils.ts +++ b/apps/client/src/services/utils.ts @@ -448,7 +448,7 @@ function sleep(time_ms: number) { }); } -function escapeRegExp(str: string) { +export function escapeRegExp(str: string) { return str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1"); } diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index d749e28440..173ec06bee 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -6,30 +6,31 @@ import { ListView, GridView } from "./legacy/ListView"; import { useEffect, useState } from "preact/hooks"; interface NoteListProps { + note?: FNote | null; displayOnlyCollections?: boolean; + highlightedTokens?: string[] | null; } -export default function NoteList({ }: NoteListProps) { - const { note } = useNoteContext(); +export default function NoteList({ note: providedNote, highlightedTokens }: NoteListProps) { + const { note: contextNote } = useNoteContext(); + const note = providedNote ?? contextNote; const viewType = useNoteViewType(note); const noteIds = useNoteIds(note, viewType); const isEnabled = (note && !!viewType); - // Refresh note Ids - return (
{isEnabled && (
- {getComponentByViewType(note, noteIds, viewType)} + {getComponentByViewType(note, noteIds, viewType, highlightedTokens)}
)}
); } -function getComponentByViewType(note: FNote, noteIds: string[], viewType: ViewTypeOptions) { - const props: ViewModeProps = { note, noteIds }; +function getComponentByViewType(note: FNote, noteIds: string[], viewType: ViewTypeOptions, highlightedTokens: string[] | null | undefined) { + const props: ViewModeProps = { note, noteIds, highlightedTokens }; switch (viewType) { case "list": diff --git a/apps/client/src/widgets/collections/interface.ts b/apps/client/src/widgets/collections/interface.ts index 4c3c71e76a..80c194f316 100644 --- a/apps/client/src/widgets/collections/interface.ts +++ b/apps/client/src/widgets/collections/interface.ts @@ -11,4 +11,5 @@ export interface ViewModeProps { * We're using noteIds so that it's not necessary to load all notes at once when paging. */ noteIds: string[]; + highlightedTokens: string[] | null | undefined; } diff --git a/apps/client/src/widgets/collections/legacy/ListView.tsx b/apps/client/src/widgets/collections/legacy/ListView.tsx index 1bf98c3deb..62f6505fd1 100644 --- a/apps/client/src/widgets/collections/legacy/ListView.tsx +++ b/apps/client/src/widgets/collections/legacy/ListView.tsx @@ -12,7 +12,7 @@ import link from "../../../services/link"; import { t } from "../../../services/i18n"; import attribute_renderer from "../../../services/attribute_renderer"; -export function ListView({ note, noteIds: unfilteredNoteIds }: ViewModeProps) { +export function ListView({ note, noteIds: unfilteredNoteIds, highlightedTokens }: ViewModeProps) { const [ isExpanded ] = useNoteLabelBoolean(note, "expanded"); const noteIds = useFilteredNoteIds(note, unfilteredNoteIds); const { pageNotes, ...pagination } = usePagination(note, noteIds); @@ -24,7 +24,7 @@ export function ListView({ note, noteIds: unfilteredNoteIds }: ViewModeProps) { @@ -34,7 +34,7 @@ export function ListView({ note, noteIds: unfilteredNoteIds }: ViewModeProps) { ); } -export function GridView({ note, noteIds: unfilteredNoteIds }: ViewModeProps) { +export function GridView({ note, noteIds: unfilteredNoteIds, highlightedTokens }: ViewModeProps) { const noteIds = useFilteredNoteIds(note, unfilteredNoteIds); const { pageNotes, ...pagination } = usePagination(note, noteIds); @@ -45,7 +45,7 @@ export function GridView({ note, noteIds: unfilteredNoteIds }: ViewModeProps) { @@ -55,7 +55,7 @@ export function GridView({ note, noteIds: unfilteredNoteIds }: ViewModeProps) { ); } -function ListNoteCard({ note, parentNote, expand }: { note: FNote, parentNote: FNote, expand?: boolean }) { +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); @@ -71,7 +71,7 @@ function ListNoteCard({ note, parentNote, expand }: { note: FNote, parentNote: F /> - + {isExpanded && <> @@ -83,7 +83,8 @@ function ListNoteCard({ note, parentNote, expand }: { note: FNote, parentNote: F ) } -function GridNoteCard({ note, parentNote }: { note: FNote, parentNote: FNote }) { +function GridNoteCard({ note, parentNote, highlightedTokens }: { note: FNote, parentNote: FNote, highlightedTokens: string[] | null | undefined }) { + const titleRef = useRef(null); const [ noteTitle, setNoteTitle ] = useState(); const notePath = getNotePath(parentNote, note); @@ -100,7 +101,7 @@ function GridNoteCard({ note, parentNote }: { note: FNote, parentNote: FNote }) >
- {noteTitle} + {noteTitle}
@@ -149,7 +150,7 @@ function NoteChildren({ note, parentNote }: { note: FNote, parentNote: FNote }) }); }, [ note ]); - return childNotes?.map(childNote => ) + return childNotes?.map(childNote => ) } /** diff --git a/apps/client/src/widgets/react/NoteLink.tsx b/apps/client/src/widgets/react/NoteLink.tsx index 09ed254d8a..4d7925bf73 100644 --- a/apps/client/src/widgets/react/NoteLink.tsx +++ b/apps/client/src/widgets/react/NoteLink.tsx @@ -1,6 +1,7 @@ -import { useEffect, useState } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; import link from "../../services/link"; import RawHtml from "./RawHtml"; +import { useSearchHighlighlighting } from "./hooks"; interface NoteLinkOpts { className?: string; @@ -10,11 +11,14 @@ interface NoteLinkOpts { style?: Record; noPreview?: boolean; noTnLink?: boolean; + highlightedTokens?: string[] | null | undefined; } -export default function NoteLink({ className, 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 [ jqueryEl, setJqueryEl ] = useState>(); + const containerRef = useRef(null); + useSearchHighlighlighting(containerRef, highlightedTokens); useEffect(() => { link.createLink(stringifiedNotePath, { showNotePath, showNoteIcon }) @@ -38,6 +42,6 @@ export default function NoteLink({ className, notePath, showNotePath, showNoteIc $linkEl?.addClass(className); } - return - -} \ No newline at end of file + return + +} diff --git a/apps/client/src/widgets/react/RawHtml.tsx b/apps/client/src/widgets/react/RawHtml.tsx index a8b3b2820b..e022b5480a 100644 --- a/apps/client/src/widgets/react/RawHtml.tsx +++ b/apps/client/src/widgets/react/RawHtml.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties } from "preact/compat"; +import type { CSSProperties, RefObject } from "preact/compat"; type HTMLElementLike = string | HTMLElement | JQuery; @@ -9,12 +9,12 @@ interface RawHtmlProps { onClick?: (e: MouseEvent) => void; } -export default function RawHtml(props: RawHtmlProps) { - return ; +export default function RawHtml({containerRef, ...props}: RawHtmlProps & { containerRef?: RefObject}) { + return ; } -export function RawHtmlBlock(props: RawHtmlProps) { - return
+export function RawHtmlBlock({containerRef, ...props}: RawHtmlProps & { containerRef?: RefObject}) { + return
} function getProps({ className, html, style, onClick }: RawHtmlProps) { @@ -38,4 +38,4 @@ export function getHtml(html: string | HTMLElement | JQuery) { return { __html: html as string }; -} \ No newline at end of file +} diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 523f3ef776..fa6c901bc4 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -4,7 +4,7 @@ import { ParentComponent } from "./react_utils"; import SpacedUpdate from "../../services/spaced_update"; import { KeyboardActionNames, OptionNames } 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,7 @@ 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"; export function useTriliumEvent(eventName: T, handler: (data: EventData) => void) { const parentComponent = useContext(ParentComponent); @@ -27,7 +28,7 @@ export function useTriliumEvent(eventName: T, handler: (da export function useTriliumEvents(eventNames: T[], handler: (data: EventData, eventName: T) => void) { const parentComponent = useContext(ParentComponent); - + useLayoutEffect(() => { const handlers: ({ eventName: T, callback: (data: EventData) => void })[] = []; for (const eventName of eventNames) { @@ -35,11 +36,11 @@ export function useTriliumEvents(eventNames: T[], handler: handler(data, eventName); }}) } - + for (const { eventName, callback } of handlers) { parentComponent?.registerHandler(eventName, callback); } - + return (() => { for (const { eventName, callback } of handlers) { parentComponent?.removeHandler(eventName, callback); @@ -76,10 +77,10 @@ export function useSpacedUpdate(callback: () => void | Promise, interval = /** * Allows a React component to read and write a Trilium option, while also watching for external changes. - * + * * Conceptually, `useTriliumOption` works just like `useState`, but the value is also automatically updated if * the option is changed somewhere else in the client. - * + * * @param name the name of the option to listen for. * @param needsRefresh whether to reload the frontend whenever the value is changed. * @returns an array where the first value is the current option value and the second value is the setter. @@ -115,7 +116,7 @@ export function useTriliumOption(name: OptionNames, needsRefresh?: boolean): [st /** * Similar to {@link useTriliumOption}, but the value is converted to and from a boolean instead of a string. - * + * * @param name the name of the option to listen for. * @param needsRefresh whether to reload the frontend whenever the value is changed. * @returns an array where the first value is the current option value and the second value is the setter. @@ -131,7 +132,7 @@ export function useTriliumOptionBool(name: OptionNames, needsRefresh?: boolean): /** * Similar to {@link useTriliumOption}, but the value is converted to and from a int instead of a string. - * + * * @param name the name of the option to listen for. * @param needsRefresh whether to reload the frontend whenever the value is changed. * @returns an array where the first value is the current option value and the second value is the setter. @@ -147,7 +148,7 @@ export function useTriliumOptionInt(name: OptionNames): [number, (newValue: numb /** * Similar to {@link useTriliumOption}, but the object value is parsed to and from a JSON instead of a string. - * + * * @param name the name of the option to listen for. * @returns an array where the first value is the current option value and the second value is the setter. */ @@ -161,8 +162,8 @@ export function useTriliumOptionJson(name: OptionNames): [ T, (newValue: T) = } /** - * Similar to {@link useTriliumOption}, but operates with multiple options at once. - * + * Similar to {@link useTriliumOption}, but operates with multiple options at once. + * * @param names the name of the option to listen for. * @returns an array where the first value is a map where the keys are the option names and the values, and the second value is the setter which takes in the same type of map and saves them all at once. */ @@ -182,10 +183,10 @@ export function useTriliumOptions(...names: T[]) { /** * Generates a unique name via a random alphanumeric string of a fixed length. - * + * *

* Generally used to assign names to inputs that are unique, especially useful for widgets inside tabs. - * + * * @param prefix a prefix to add to the unique name. * @returns a name with the given prefix and a random alpanumeric string appended to it. */ @@ -196,7 +197,7 @@ export function useUniqueName(prefix?: string) { export function useNoteContext() { const [ noteContext, setNoteContext ] = useState(); const [ notePath, setNotePath ] = useState(); - const [ note, setNote ] = useState(); + const [ note, setNote ] = useState(); const [ refreshCounter, setRefreshCounter ] = useState(0); useEffect(() => { @@ -205,7 +206,7 @@ export function useNoteContext() { useTriliumEvents([ "setNoteContext", "activeContextChanged", "noteSwitchedAndActivated", "noteSwitched" ], ({ noteContext }) => { setNoteContext(noteContext); - setNotePath(noteContext.notePath); + setNotePath(noteContext.notePath); }); useTriliumEvent("frocaReloaded", () => { setNote(noteContext?.note); @@ -235,7 +236,7 @@ export function useNoteContext() { /** * Allows a React component to listen to obtain a property of a {@link FNote} while also automatically watching for changes, either via the user changing to a different note or the property being changed externally. - * + * * @param note the {@link FNote} whose property to obtain. * @param property a property of a {@link FNote} to obtain the value from (e.g. `title`, `isProtected`). * @param componentId optionally, constricts the refresh of the value if an update occurs externally via the component ID of a legacy widget. This can be used to avoid external data replacing fresher, user-inputted data. @@ -287,7 +288,7 @@ export function useNoteRelation(note: FNote | undefined | null, relationName: st /** * Allows a React component to read or write a note's label while also reacting to changes in value. - * + * * @param note the note whose label to read/write. * @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. @@ -352,9 +353,9 @@ export function useNoteLabelBoolean(note: FNote | undefined | null, labelName: s export function useNoteBlob(note: FNote | null | undefined): [ FBlob | null | undefined ] { const [ blob, setBlob ] = useState(); - + function refresh() { - note?.getBlob().then(setBlob); + note?.getBlob().then(setBlob); } useEffect(refresh, [ note?.noteId ]); @@ -388,7 +389,7 @@ export function useLegacyWidget(widgetFactory: () => T, { if (noteContext && widget instanceof NoteContextAwareWidget) { widget.setNoteContextEvent({ noteContext }); } - + const renderedWidget = widget.render(); return [ widget, renderedWidget ]; }, []); @@ -415,7 +416,7 @@ export function useLegacyWidget(widgetFactory: () => T, { /** * Attaches a {@link ResizeObserver} to the given ref and reads the bounding client rect whenever it changes. - * + * * @param ref a ref to a {@link HTMLElement} to determine the size and observe the changes in size. * @returns the size of the element, reacting to changes. */ @@ -445,7 +446,7 @@ export function useElementSize(ref: RefObject) { /** * Obtains the inner width and height of the window, as well as reacts to changes in size. - * + * * @returns the width and height of the window. */ export function useWindowSize() { @@ -453,7 +454,7 @@ export function useWindowSize() { windowWidth: window.innerWidth, windowHeight: window.innerHeight }); - + useEffect(() => { function onResize() { setSize({ @@ -499,7 +500,7 @@ export function useTooltip(elRef: RefObject, config: Partial(externalRef?: RefObject, initialValue: T | nu }, [ ref, externalRef ]); return ref; -} \ No newline at end of file +} + +export function useSearchHighlighlighting(ref: RefObject, highlightedTokens: string[] | null | undefined) { + const mark = useRef(); + const highlightRegex = useMemo(() => { + if (!highlightedTokens?.length) return null; + const regex = highlightedTokens.map((token) => escapeRegExp(token)).join("|"); + return new RegExp(regex, "gi") + }, [ highlightedTokens ]); + + useEffect(() => { + if (!ref.current || !highlightRegex) return; + + if (!mark.current) { + mark.current = new Mark(ref.current); + } + + mark.current.markRegExp(highlightRegex, { + element: "span", + className: "ck-find-result" + }); + + return () => mark.current?.unmark(); + }); +} diff --git a/apps/client/src/widgets/search_result.tsx b/apps/client/src/widgets/search_result.tsx index adfb1b0a67..abe9d4174f 100644 --- a/apps/client/src/widgets/search_result.tsx +++ b/apps/client/src/widgets/search_result.tsx @@ -1,11 +1,12 @@ -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 NoteList from "./collections/NoteList"; // import NoteListRenderer from "../services/note_list_renderer"; -enum SearchResultState { +enum SearchResultState { NO_RESULTS, NOT_EXECUTED, GOT_RESULTS @@ -14,27 +15,18 @@ enum SearchResultState { export default function SearchResult() { const { note, ntxId } = useNoteContext(); const [ state, setState ] = useState(); - const searchContainerRef = useRef(null); + const [ highlightedTokens, setHighlightedTokens ] = useState(); 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); - - // TODO: Fix me. - // const noteListRenderer = new NoteListRenderer({ - // $parent: $(searchContainerRef.current), - // parentNote: note, - // showNotePath: true - // }); - // noteListRenderer.renderList(); + setHighlightedTokens(note.highlightedTokens); } } @@ -60,7 +52,9 @@ export default function SearchResult() { {t("search_result.no_notes_found")} )} -

+ {state === SearchResultState.GOT_RESULTS && ( + + )}
); -} \ No newline at end of file +} From 1cee01a22a18336d86a3f297f6b97db06e5339ea Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 30 Aug 2025 19:03:18 +0300 Subject: [PATCH 017/233] chore(react/collections): content highlighting in list --- .../widgets/collections/legacy/ListView.tsx | 23 +++++++++++-------- apps/client/src/widgets/react/NoteLink.tsx | 15 ++++++++---- apps/client/src/widgets/react/hooks.tsx | 13 +++++------ 3 files changed, 29 insertions(+), 22 deletions(-) diff --git a/apps/client/src/widgets/collections/legacy/ListView.tsx b/apps/client/src/widgets/collections/legacy/ListView.tsx index 62f6505fd1..c25f4ac554 100644 --- a/apps/client/src/widgets/collections/legacy/ListView.tsx +++ b/apps/client/src/widgets/collections/legacy/ListView.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "preact/hooks"; import FNote from "../../../entities/fnote"; import Icon from "../../react/Icon"; import { ViewModeProps } from "../interface"; -import { useNoteLabelBoolean, useNoteProperty } from "../../react/hooks"; +import { useNoteLabelBoolean, useImperativeSearchHighlighlighting } from "../../react/hooks"; import NoteLink from "../../react/NoteLink"; import "./ListOrGridView.css"; import content_renderer from "../../../services/content_renderer"; @@ -75,8 +75,8 @@ function ListNoteCard({ note, parentNote, expand, highlightedTokens }: { note: F {isExpanded && <> - - + + }
@@ -104,7 +104,7 @@ function GridNoteCard({ note, parentNote, highlightedTokens }: { note: FNote, pa {noteTitle} - +
) } @@ -120,26 +120,29 @@ function NoteAttributes({ note }: { note: FNote }) { return } -function NoteContent({ note, trim }: { note: FNote, trim?: boolean }) { +function NoteContent({ note, trim, highlightedTokens }: { note: FNote, trim?: boolean, highlightedTokens }) { const contentRef = useRef(null); + const highlightSearch = useImperativeSearchHighlighlighting(highlightedTokens); useEffect(() => { content_renderer.getRenderedContent(note, { trim }) .then(({ $renderedContent, type }) => { - contentRef.current?.replaceChildren(...$renderedContent); - contentRef.current?.classList.add(`type-${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 ]); + }, [ note, highlightedTokens ]); return
; } -function NoteChildren({ note, parentNote }: { note: FNote, parentNote: FNote }) { +function NoteChildren({ note, parentNote, highlightedTokens }: { note: FNote, parentNote: FNote, highlightedTokens: string[] | null | undefined }) { const imageLinks = note.getRelations("imageLink"); const [ childNotes, setChildNotes ] = useState(); @@ -150,7 +153,7 @@ function NoteChildren({ note, parentNote }: { note: FNote, parentNote: FNote }) }); }, [ note ]); - return childNotes?.map(childNote => ) + return childNotes?.map(childNote => ) } /** diff --git a/apps/client/src/widgets/react/NoteLink.tsx b/apps/client/src/widgets/react/NoteLink.tsx index 4d7925bf73..2a9ec199d2 100644 --- a/apps/client/src/widgets/react/NoteLink.tsx +++ b/apps/client/src/widgets/react/NoteLink.tsx @@ -1,7 +1,6 @@ import { useEffect, useRef, useState } from "preact/hooks"; import link from "../../services/link"; -import RawHtml from "./RawHtml"; -import { useSearchHighlighlighting } from "./hooks"; +import { useImperativeSearchHighlighlighting } from "./hooks"; interface NoteLinkOpts { className?: string; @@ -16,15 +15,21 @@ interface 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(null); const [ jqueryEl, setJqueryEl ] = useState>(); - const containerRef = useRef(null); - useSearchHighlighlighting(containerRef, highlightedTokens); + 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); } @@ -42,6 +47,6 @@ export default function NoteLink({ className, notePath, showNotePath, showNoteIc $linkEl?.addClass(className); } - return + return } diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index fa6c901bc4..b4148197d7 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -550,7 +550,7 @@ export function useSyncedRef(externalRef?: RefObject, initialValue: T | nu return ref; } -export function useSearchHighlighlighting(ref: RefObject, highlightedTokens: string[] | null | undefined) { +export function useImperativeSearchHighlighlighting(highlightedTokens: string[] | null | undefined) { const mark = useRef(); const highlightRegex = useMemo(() => { if (!highlightedTokens?.length) return null; @@ -558,18 +558,17 @@ export function useSearchHighlighlighting(ref: RefObject, highlight return new RegExp(regex, "gi") }, [ highlightedTokens ]); - useEffect(() => { - if (!ref.current || !highlightRegex) return; + return (el: HTMLElement) => { + if (!el || !highlightRegex) return; if (!mark.current) { - mark.current = new Mark(ref.current); + mark.current = new Mark(el); } + mark.current.unmark(); mark.current.markRegExp(highlightRegex, { element: "span", className: "ck-find-result" }); - - return () => mark.current?.unmark(); - }); + }; } From d52f9f2a92795d619d0f2d3d16c7d3aaafe8c4ac Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 30 Aug 2025 19:07:06 +0300 Subject: [PATCH 018/233] chore(react/collections): highlighting in grid title --- apps/client/src/widgets/collections/legacy/ListView.tsx | 3 +++ apps/client/src/widgets/react/hooks.tsx | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/client/src/widgets/collections/legacy/ListView.tsx b/apps/client/src/widgets/collections/legacy/ListView.tsx index c25f4ac554..eb3ea1de0d 100644 --- a/apps/client/src/widgets/collections/legacy/ListView.tsx +++ b/apps/client/src/widgets/collections/legacy/ListView.tsx @@ -87,11 +87,14 @@ function GridNoteCard({ note, parentNote, highlightedTokens }: { note: FNote, pa const titleRef = useRef(null); const [ noteTitle, setNoteTitle ] = useState(); 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 (
{ + return (el: HTMLElement | null | undefined) => { if (!el || !highlightRegex) return; if (!mark.current) { From 5f73532d62d66deb589addadb7a468226fa15348 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 30 Aug 2025 19:11:12 +0300 Subject: [PATCH 019/233] chore(react/collections): fix expand state when switching notes --- .../widgets/collections/legacy/ListView.tsx | 3 + .../widgets/view_widgets/list_or_grid_view.ts | 61 ------------------- 2 files changed, 3 insertions(+), 61 deletions(-) delete mode 100644 apps/client/src/widgets/view_widgets/list_or_grid_view.ts diff --git a/apps/client/src/widgets/collections/legacy/ListView.tsx b/apps/client/src/widgets/collections/legacy/ListView.tsx index eb3ea1de0d..9893bdd51a 100644 --- a/apps/client/src/widgets/collections/legacy/ListView.tsx +++ b/apps/client/src/widgets/collections/legacy/ListView.tsx @@ -59,6 +59,9 @@ function ListNoteCard({ note, parentNote, expand, highlightedTokens }: { note: F const [ isExpanded, setExpanded ] = useState(expand); const notePath = getNotePath(parentNote, note); + // Reset expand state if switching to another note. + useEffect(() => setExpanded(expand), [ note ]); + return (
{ - private $noteList: JQuery; - - private filteredNoteIds!: string[]; - private page?: number; - private pageSize?: number; - private highlightRegex?: RegExp | null; - - async renderList() { - if (this.filteredNoteIds.length === 0 || !this.page || !this.pageSize) { - this.$noteList.hide(); - return; - } - - const highlightedTokens = this.parentNote.highlightedTokens || []; - if (highlightedTokens.length > 0) { - const regex = highlightedTokens.map((token) => utils.escapeRegExp(token)).join("|"); - - this.highlightRegex = new RegExp(regex, "gi"); - } else { - this.highlightRegex = null; - } - - this.$noteList.show(); - - return this.$noteList; - } - - async renderNote(note: FNote, expand: boolean = false) { - if (this.highlightRegex) { - const Mark = new (await import("mark.js")).default($card.find(".note-book-title")[0]); - Mark.markRegExp(this.highlightRegex, { - element: "span", - className: "ck-find-result" - }); - } - } - - async renderNoteContent(note: FNote) { - try { - if (this.highlightRegex) { - const Mark = new (await import("mark.js")).default($renderedContent[0]); - Mark.markRegExp(this.highlightRegex, { - element: "span", - className: "ck-find-result" - }); - } - } - } -} - -export default ListOrGridView; From 98a4a8d8c602a63fbebd748797bebec3288af177 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 30 Aug 2025 19:13:08 +0300 Subject: [PATCH 020/233] chore(react/collections): fix list body --- .../client/src/widgets/collections/legacy/ListView.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/client/src/widgets/collections/legacy/ListView.tsx b/apps/client/src/widgets/collections/legacy/ListView.tsx index 9893bdd51a..4d3f0f7952 100644 --- a/apps/client/src/widgets/collections/legacy/ListView.tsx +++ b/apps/client/src/widgets/collections/legacy/ListView.tsx @@ -76,12 +76,12 @@ function ListNoteCard({ note, parentNote, expand, highlightedTokens }: { note: F - - {isExpanded && <> - - - } + + {isExpanded && <> + + + }
) } From c49e84efc6612ddc758818c2d24f627de850cb7a Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 30 Aug 2025 19:21:26 +0300 Subject: [PATCH 021/233] refactor(react/collections): rename --- apps/client/src/widgets/collections/NoteList.tsx | 2 +- .../collections/legacy/{ListView.tsx => ListOrGridView.tsx} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename apps/client/src/widgets/collections/legacy/{ListView.tsx => ListOrGridView.tsx} (100%) diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index 173ec06bee..9d9f772c4b 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -2,7 +2,7 @@ import { allViewTypes, ViewModeProps, ViewTypeOptions } from "./interface"; import { useNoteContext, useNoteLabel, useTriliumEvent } from "../react/hooks"; import FNote from "../../entities/fnote"; import "./NoteList.css"; -import { ListView, GridView } from "./legacy/ListView"; +import { ListView, GridView } from "./legacy/ListOrGridView"; import { useEffect, useState } from "preact/hooks"; interface NoteListProps { diff --git a/apps/client/src/widgets/collections/legacy/ListView.tsx b/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx similarity index 100% rename from apps/client/src/widgets/collections/legacy/ListView.tsx rename to apps/client/src/widgets/collections/legacy/ListOrGridView.tsx From cc7edbe3a7a53be357936a6243f9ff7fcc7f3ae7 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 30 Aug 2025 19:24:32 +0300 Subject: [PATCH 022/233] chore(react/collections): full-height rendering for non-legacy --- apps/client/src/widgets/collections/NoteList.tsx | 3 ++- .../src/widgets/collections/note_list_renderer.ts.bak | 10 ---------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index 9d9f772c4b..3aec82d990 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -17,9 +17,10 @@ export default function NoteList({ note: providedNote, highlightedTokens }: Note const viewType = useNoteViewType(note); const noteIds = useNoteIds(note, viewType); const isEnabled = (note && !!viewType); + const isFullHeight = (viewType !== "list" && viewType !== "grid"); return ( -
+
{isEnabled && (
{getComponentByViewType(note, noteIds, viewType, highlightedTokens)} diff --git a/apps/client/src/widgets/collections/note_list_renderer.ts.bak b/apps/client/src/widgets/collections/note_list_renderer.ts.bak index ce7bbc4269..0b0e489625 100644 --- a/apps/client/src/widgets/collections/note_list_renderer.ts.bak +++ b/apps/client/src/widgets/collections/note_list_renderer.ts.bak @@ -17,16 +17,6 @@ export default class NoteListRenderer { this.viewType = this.#getViewType(args.parentNote); } - 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); From 5570f3bdcfc7ace47b36c1e299f58cfb406772a0 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 30 Aug 2025 19:27:41 +0300 Subject: [PATCH 023/233] chore(react/collections): title stretched thin --- apps/client/src/widgets/collections/legacy/ListOrGridView.css | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/client/src/widgets/collections/legacy/ListOrGridView.css b/apps/client/src/widgets/collections/legacy/ListOrGridView.css index 1e7fa1a0d0..784dcfa49c 100644 --- a/apps/client/src/widgets/collections/legacy/ListOrGridView.css +++ b/apps/client/src/widgets/collections/legacy/ListOrGridView.css @@ -36,6 +36,7 @@ margin-bottom: 0; padding-bottom: .5rem; word-break: break-all; + flex-shrink: 0; } /* not-expanded title is limited to one line only */ From 2689b22674b1c5d17b09c97461228f5572180242 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 30 Aug 2025 19:33:32 +0300 Subject: [PATCH 024/233] chore(react): not reacting to deleted note labels --- apps/client/src/widgets/react/hooks.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 4a9f24cebd..6b1d7af68a 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -300,7 +300,11 @@ export function useNoteLabel(note: FNote | undefined | null, labelName: string): 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); + } } } }); From 6e575df40bc7f69975e6dc6eba1ef5eea0f7b322 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 30 Aug 2025 19:42:16 +0300 Subject: [PATCH 025/233] chore(react/collections): add intersection observer --- .../src/widgets/collections/NoteList.tsx | 29 +++++++++-- .../src/widgets/collections/note_list.bak | 51 ------------------- 2 files changed, 26 insertions(+), 54 deletions(-) diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index 3aec82d990..05ddc34fe1 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -3,7 +3,7 @@ import { useNoteContext, useNoteLabel, useTriliumEvent } from "../react/hooks"; import FNote from "../../entities/fnote"; import "./NoteList.css"; import { ListView, GridView } from "./legacy/ListOrGridView"; -import { useEffect, useState } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; interface NoteListProps { note?: FNote | null; @@ -12,15 +12,38 @@ interface NoteListProps { } export default function NoteList({ note: providedNote, highlightedTokens }: NoteListProps) { + const widgetRef = useRef(null); const { note: contextNote } = useNoteContext(); const note = providedNote ?? contextNote; const viewType = useNoteViewType(note); const noteIds = useNoteIds(note, viewType); - const isEnabled = (note && !!viewType); const isFullHeight = (viewType !== "list" && viewType !== "grid"); + const [ isIntersecting, setIsIntersecting ] = useState(false); + const shouldRender = (isFullHeight || isIntersecting); + const isEnabled = (note && !!viewType && shouldRender); + + useEffect(() => { + 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(); + }, []); return ( -
+
{isEnabled && (
{getComponentByViewType(note, noteIds, viewType, highlightedTokens)} diff --git a/apps/client/src/widgets/collections/note_list.bak b/apps/client/src/widgets/collections/note_list.bak index 61fdca46bf..e730eee232 100644 --- a/apps/client/src/widgets/collections/note_list.bak +++ b/apps/client/src/widgets/collections/note_list.bak @@ -7,7 +7,6 @@ import type ViewMode from "../view_widgets/view_mode.js"; export default class NoteListWidget extends NoteContextAwareWidget { private $content!: JQuery; - private isIntersecting?: boolean; private noteIdRefreshed?: string; private shownNoteId?: string | null; private viewMode?: ViewMode | null; @@ -33,56 +32,6 @@ export default class NoteListWidget extends NoteContextAwareWidget { 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); From 34fc30b8dba5d3f95e7b17da744362cecd9af07a Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 30 Aug 2025 19:48:05 +0300 Subject: [PATCH 026/233] chore(react/collections): avoid intersection observer when not needed --- apps/client/src/widgets/collections/NoteList.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index 05ddc34fe1..d8bfabf85b 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -11,18 +11,23 @@ interface NoteListProps { highlightedTokens?: string[] | null; } -export default function NoteList({ note: providedNote, highlightedTokens }: NoteListProps) { +export default function NoteList({ note: providedNote, highlightedTokens, displayOnlyCollections }: NoteListProps) { const widgetRef = useRef(null); - const { note: contextNote } = useNoteContext(); + const { note: contextNote, noteContext } = useNoteContext(); const note = providedNote ?? contextNote; const viewType = useNoteViewType(note); const noteIds = useNoteIds(note, viewType); const isFullHeight = (viewType !== "list" && viewType !== "grid"); const [ isIntersecting, setIsIntersecting ] = useState(false); const shouldRender = (isFullHeight || isIntersecting); - const isEnabled = (note && !!viewType && shouldRender); + const isEnabled = (note && noteContext?.hasNoteList() && !!viewType && shouldRender); useEffect(() => { + if (isFullHeight || displayOnlyCollections) { + // Double role: no need to check if the note list is visible if the view is full-height, but also prevent legacy views if `displayOnlyCollections` is true. + return; + } + const observer = new IntersectionObserver( (entries) => { if (!isIntersecting) { From 5b8394d68547f4cbdef9d2680a8f67350903e0c5 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 30 Aug 2025 19:50:20 +0300 Subject: [PATCH 027/233] chore(react/collections): display books even if collections only --- .../src/widgets/collections/NoteList.tsx | 7 ++++--- .../src/widgets/collections/note_list.bak | 21 ------------------- 2 files changed, 4 insertions(+), 24 deletions(-) diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index d8bfabf85b..3b10d831e1 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -7,6 +7,7 @@ import { useEffect, useRef, useState } from "preact/hooks"; interface NoteListProps { 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; } @@ -19,12 +20,12 @@ export default function NoteList({ note: providedNote, highlightedTokens, displa const noteIds = useNoteIds(note, viewType); const isFullHeight = (viewType !== "list" && viewType !== "grid"); const [ isIntersecting, setIsIntersecting ] = useState(false); - const shouldRender = (isFullHeight || isIntersecting); + const shouldRender = (isFullHeight || isIntersecting || note?.type === "book"); const isEnabled = (note && noteContext?.hasNoteList() && !!viewType && shouldRender); useEffect(() => { - if (isFullHeight || displayOnlyCollections) { - // Double role: no need to check if the note list is visible if the view is full-height, but also prevent legacy views if `displayOnlyCollections` is true. + 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; } diff --git a/apps/client/src/widgets/collections/note_list.bak b/apps/client/src/widgets/collections/note_list.bak index e730eee232..53b8893f91 100644 --- a/apps/client/src/widgets/collections/note_list.bak +++ b/apps/client/src/widgets/collections/note_list.bak @@ -10,27 +10,6 @@ export default class NoteListWidget extends NoteContextAwareWidget { private noteIdRefreshed?: string; private shownNoteId?: string | null; private viewMode?: ViewMode | 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 (this.displayOnlyCollections && this.note?.type !== "book") { - const viewType = this.note?.getLabelValue("viewType"); - if (!viewType || ["grid", "list"].includes(viewType)) { - return false; - } - } - - return this.noteContext?.hasNoteList(); - } async refreshNoteListEvent({ noteId }: EventData<"refreshNoteList">) { if (this.isNote(noteId) && this.note) { From 1969ce562a8ba779468417f289c41f2b4a2b174d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 3 Sep 2025 23:17:35 +0300 Subject: [PATCH 028/233] chore(react/collections): start porting geomap --- .../src/widgets/collections/NoteList.tsx | 3 + .../src/widgets/collections/geomap/index.css | 74 ++++++++++++ .../src/widgets/collections/geomap/index.tsx | 16 +++ .../src/widgets/collections/geomap/map.tsx | 70 ++++++++++++ .../geomap}/map_layer.ts | 0 .../geomap}/styles/colorful/de.json | 0 .../geomap}/styles/colorful/en.json | 0 .../geomap}/styles/colorful/nolabel.json | 0 .../geomap}/styles/colorful/style.json | 0 .../geomap}/styles/eclipse/de.json | 0 .../geomap}/styles/eclipse/en.json | 0 .../geomap}/styles/eclipse/nolabel.json | 0 .../geomap}/styles/eclipse/style.json | 0 .../geomap}/styles/graybeard/de.json | 0 .../geomap}/styles/graybeard/en.json | 0 .../geomap}/styles/graybeard/nolabel.json | 0 .../geomap}/styles/graybeard/style.json | 0 .../geomap}/styles/neutrino/de.json | 0 .../geomap}/styles/neutrino/en.json | 0 .../geomap}/styles/neutrino/nolabel.json | 0 .../geomap}/styles/neutrino/style.json | 0 .../geomap}/styles/shadow/de.json | 0 .../geomap}/styles/shadow/en.json | 0 .../geomap}/styles/shadow/nolabel.json | 0 .../geomap}/styles/shadow/style.json | 0 .../ribbon/collection-properties-config.ts | 2 +- .../widgets/view_widgets/geo_view/index.ts | 107 +----------------- 27 files changed, 165 insertions(+), 107 deletions(-) create mode 100644 apps/client/src/widgets/collections/geomap/index.css create mode 100644 apps/client/src/widgets/collections/geomap/index.tsx create mode 100644 apps/client/src/widgets/collections/geomap/map.tsx rename apps/client/src/widgets/{view_widgets/geo_view => collections/geomap}/map_layer.ts (100%) rename apps/client/src/widgets/{view_widgets/geo_view => collections/geomap}/styles/colorful/de.json (100%) rename apps/client/src/widgets/{view_widgets/geo_view => collections/geomap}/styles/colorful/en.json (100%) rename apps/client/src/widgets/{view_widgets/geo_view => collections/geomap}/styles/colorful/nolabel.json (100%) rename apps/client/src/widgets/{view_widgets/geo_view => collections/geomap}/styles/colorful/style.json (100%) rename apps/client/src/widgets/{view_widgets/geo_view => collections/geomap}/styles/eclipse/de.json (100%) rename apps/client/src/widgets/{view_widgets/geo_view => collections/geomap}/styles/eclipse/en.json (100%) rename apps/client/src/widgets/{view_widgets/geo_view => collections/geomap}/styles/eclipse/nolabel.json (100%) rename apps/client/src/widgets/{view_widgets/geo_view => collections/geomap}/styles/eclipse/style.json (100%) rename apps/client/src/widgets/{view_widgets/geo_view => collections/geomap}/styles/graybeard/de.json (100%) rename apps/client/src/widgets/{view_widgets/geo_view => collections/geomap}/styles/graybeard/en.json (100%) rename apps/client/src/widgets/{view_widgets/geo_view => collections/geomap}/styles/graybeard/nolabel.json (100%) rename apps/client/src/widgets/{view_widgets/geo_view => collections/geomap}/styles/graybeard/style.json (100%) rename apps/client/src/widgets/{view_widgets/geo_view => collections/geomap}/styles/neutrino/de.json (100%) rename apps/client/src/widgets/{view_widgets/geo_view => collections/geomap}/styles/neutrino/en.json (100%) rename apps/client/src/widgets/{view_widgets/geo_view => collections/geomap}/styles/neutrino/nolabel.json (100%) rename apps/client/src/widgets/{view_widgets/geo_view => collections/geomap}/styles/neutrino/style.json (100%) rename apps/client/src/widgets/{view_widgets/geo_view => collections/geomap}/styles/shadow/de.json (100%) rename apps/client/src/widgets/{view_widgets/geo_view => collections/geomap}/styles/shadow/en.json (100%) rename apps/client/src/widgets/{view_widgets/geo_view => collections/geomap}/styles/shadow/nolabel.json (100%) rename apps/client/src/widgets/{view_widgets/geo_view => collections/geomap}/styles/shadow/style.json (100%) diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index 3b10d831e1..501944158e 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -4,6 +4,7 @@ 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"; interface NoteListProps { note?: FNote | null; @@ -67,6 +68,8 @@ function getComponentByViewType(note: FNote, noteIds: string[], viewType: ViewTy return ; case "grid": return ; + case "geoMap": + return ; } } diff --git a/apps/client/src/widgets/collections/geomap/index.css b/apps/client/src/widgets/collections/geomap/index.css new file mode 100644 index 0000000000..668962ff14 --- /dev/null +++ b/apps/client/src/widgets/collections/geomap/index.css @@ -0,0 +1,74 @@ +.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-map-container.placing-note { + 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.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; +} \ No newline at end of file diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx new file mode 100644 index 0000000000..c116ebd7a8 --- /dev/null +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -0,0 +1,16 @@ +import Map from "./map"; +import "./index.css"; + +const DEFAULT_COORDINATES: [number, number] = [3.878638227135724, 446.6630455551659]; +const DEFAULT_ZOOM = 2; + +export default function GeoView() { + return ( +
+ +
+ ); +} diff --git a/apps/client/src/widgets/collections/geomap/map.tsx b/apps/client/src/widgets/collections/geomap/map.tsx new file mode 100644 index 0000000000..8a3dd97b15 --- /dev/null +++ b/apps/client/src/widgets/collections/geomap/map.tsx @@ -0,0 +1,70 @@ +import { useEffect, useRef, useState } from "preact/hooks"; +import L, { LatLng, Layer } from "leaflet"; +import "leaflet/dist/leaflet.css"; +import { useNoteContext, useNoteLabel } from "../../react/hooks"; +import { DEFAULT_MAP_LAYER_NAME, MAP_LAYERS } from "./map_layer"; + +interface MapProps { + coordinates: LatLng | [number, number]; + zoom: number; +} + +export default function Map({ coordinates, zoom }: MapProps) { + const mapRef = useRef(null); + const containerRef = useRef(null); + const { note } = useNoteContext(); + const [ layerName ] = useNoteLabel(note, "map:style"); + + useEffect(() => { + if (!containerRef.current) return; + mapRef.current = L.map(containerRef.current, { + worldCopyJump: true + }); + }, []); + + // Load the layer asynchronously. + const [ layer, setLayer ] = useState(); + useEffect(() => { + async function load() { + const layerData = MAP_LAYERS[layerName ?? DEFAULT_MAP_LAYER_NAME]; + + 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; + console.log("Add layer ", map, layerToAdd); + 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 ]); + + return ( +
+ +
+ ); +} diff --git a/apps/client/src/widgets/view_widgets/geo_view/map_layer.ts b/apps/client/src/widgets/collections/geomap/map_layer.ts similarity index 100% rename from apps/client/src/widgets/view_widgets/geo_view/map_layer.ts rename to apps/client/src/widgets/collections/geomap/map_layer.ts diff --git a/apps/client/src/widgets/view_widgets/geo_view/styles/colorful/de.json b/apps/client/src/widgets/collections/geomap/styles/colorful/de.json similarity index 100% rename from apps/client/src/widgets/view_widgets/geo_view/styles/colorful/de.json rename to apps/client/src/widgets/collections/geomap/styles/colorful/de.json diff --git a/apps/client/src/widgets/view_widgets/geo_view/styles/colorful/en.json b/apps/client/src/widgets/collections/geomap/styles/colorful/en.json similarity index 100% rename from apps/client/src/widgets/view_widgets/geo_view/styles/colorful/en.json rename to apps/client/src/widgets/collections/geomap/styles/colorful/en.json diff --git a/apps/client/src/widgets/view_widgets/geo_view/styles/colorful/nolabel.json b/apps/client/src/widgets/collections/geomap/styles/colorful/nolabel.json similarity index 100% rename from apps/client/src/widgets/view_widgets/geo_view/styles/colorful/nolabel.json rename to apps/client/src/widgets/collections/geomap/styles/colorful/nolabel.json diff --git a/apps/client/src/widgets/view_widgets/geo_view/styles/colorful/style.json b/apps/client/src/widgets/collections/geomap/styles/colorful/style.json similarity index 100% rename from apps/client/src/widgets/view_widgets/geo_view/styles/colorful/style.json rename to apps/client/src/widgets/collections/geomap/styles/colorful/style.json diff --git a/apps/client/src/widgets/view_widgets/geo_view/styles/eclipse/de.json b/apps/client/src/widgets/collections/geomap/styles/eclipse/de.json similarity index 100% rename from apps/client/src/widgets/view_widgets/geo_view/styles/eclipse/de.json rename to apps/client/src/widgets/collections/geomap/styles/eclipse/de.json diff --git a/apps/client/src/widgets/view_widgets/geo_view/styles/eclipse/en.json b/apps/client/src/widgets/collections/geomap/styles/eclipse/en.json similarity index 100% rename from apps/client/src/widgets/view_widgets/geo_view/styles/eclipse/en.json rename to apps/client/src/widgets/collections/geomap/styles/eclipse/en.json diff --git a/apps/client/src/widgets/view_widgets/geo_view/styles/eclipse/nolabel.json b/apps/client/src/widgets/collections/geomap/styles/eclipse/nolabel.json similarity index 100% rename from apps/client/src/widgets/view_widgets/geo_view/styles/eclipse/nolabel.json rename to apps/client/src/widgets/collections/geomap/styles/eclipse/nolabel.json diff --git a/apps/client/src/widgets/view_widgets/geo_view/styles/eclipse/style.json b/apps/client/src/widgets/collections/geomap/styles/eclipse/style.json similarity index 100% rename from apps/client/src/widgets/view_widgets/geo_view/styles/eclipse/style.json rename to apps/client/src/widgets/collections/geomap/styles/eclipse/style.json diff --git a/apps/client/src/widgets/view_widgets/geo_view/styles/graybeard/de.json b/apps/client/src/widgets/collections/geomap/styles/graybeard/de.json similarity index 100% rename from apps/client/src/widgets/view_widgets/geo_view/styles/graybeard/de.json rename to apps/client/src/widgets/collections/geomap/styles/graybeard/de.json diff --git a/apps/client/src/widgets/view_widgets/geo_view/styles/graybeard/en.json b/apps/client/src/widgets/collections/geomap/styles/graybeard/en.json similarity index 100% rename from apps/client/src/widgets/view_widgets/geo_view/styles/graybeard/en.json rename to apps/client/src/widgets/collections/geomap/styles/graybeard/en.json diff --git a/apps/client/src/widgets/view_widgets/geo_view/styles/graybeard/nolabel.json b/apps/client/src/widgets/collections/geomap/styles/graybeard/nolabel.json similarity index 100% rename from apps/client/src/widgets/view_widgets/geo_view/styles/graybeard/nolabel.json rename to apps/client/src/widgets/collections/geomap/styles/graybeard/nolabel.json diff --git a/apps/client/src/widgets/view_widgets/geo_view/styles/graybeard/style.json b/apps/client/src/widgets/collections/geomap/styles/graybeard/style.json similarity index 100% rename from apps/client/src/widgets/view_widgets/geo_view/styles/graybeard/style.json rename to apps/client/src/widgets/collections/geomap/styles/graybeard/style.json diff --git a/apps/client/src/widgets/view_widgets/geo_view/styles/neutrino/de.json b/apps/client/src/widgets/collections/geomap/styles/neutrino/de.json similarity index 100% rename from apps/client/src/widgets/view_widgets/geo_view/styles/neutrino/de.json rename to apps/client/src/widgets/collections/geomap/styles/neutrino/de.json diff --git a/apps/client/src/widgets/view_widgets/geo_view/styles/neutrino/en.json b/apps/client/src/widgets/collections/geomap/styles/neutrino/en.json similarity index 100% rename from apps/client/src/widgets/view_widgets/geo_view/styles/neutrino/en.json rename to apps/client/src/widgets/collections/geomap/styles/neutrino/en.json diff --git a/apps/client/src/widgets/view_widgets/geo_view/styles/neutrino/nolabel.json b/apps/client/src/widgets/collections/geomap/styles/neutrino/nolabel.json similarity index 100% rename from apps/client/src/widgets/view_widgets/geo_view/styles/neutrino/nolabel.json rename to apps/client/src/widgets/collections/geomap/styles/neutrino/nolabel.json diff --git a/apps/client/src/widgets/view_widgets/geo_view/styles/neutrino/style.json b/apps/client/src/widgets/collections/geomap/styles/neutrino/style.json similarity index 100% rename from apps/client/src/widgets/view_widgets/geo_view/styles/neutrino/style.json rename to apps/client/src/widgets/collections/geomap/styles/neutrino/style.json diff --git a/apps/client/src/widgets/view_widgets/geo_view/styles/shadow/de.json b/apps/client/src/widgets/collections/geomap/styles/shadow/de.json similarity index 100% rename from apps/client/src/widgets/view_widgets/geo_view/styles/shadow/de.json rename to apps/client/src/widgets/collections/geomap/styles/shadow/de.json diff --git a/apps/client/src/widgets/view_widgets/geo_view/styles/shadow/en.json b/apps/client/src/widgets/collections/geomap/styles/shadow/en.json similarity index 100% rename from apps/client/src/widgets/view_widgets/geo_view/styles/shadow/en.json rename to apps/client/src/widgets/collections/geomap/styles/shadow/en.json diff --git a/apps/client/src/widgets/view_widgets/geo_view/styles/shadow/nolabel.json b/apps/client/src/widgets/collections/geomap/styles/shadow/nolabel.json similarity index 100% rename from apps/client/src/widgets/view_widgets/geo_view/styles/shadow/nolabel.json rename to apps/client/src/widgets/collections/geomap/styles/shadow/nolabel.json diff --git a/apps/client/src/widgets/view_widgets/geo_view/styles/shadow/style.json b/apps/client/src/widgets/collections/geomap/styles/shadow/style.json similarity index 100% rename from apps/client/src/widgets/view_widgets/geo_view/styles/shadow/style.json rename to apps/client/src/widgets/collections/geomap/styles/shadow/style.json diff --git a/apps/client/src/widgets/ribbon/collection-properties-config.ts b/apps/client/src/widgets/ribbon/collection-properties-config.ts index 6a0c74d04e..d53513a439 100644 --- a/apps/client/src/widgets/ribbon/collection-properties-config.ts +++ b/apps/client/src/widgets/ribbon/collection-properties-config.ts @@ -2,7 +2,7 @@ import { t } from "i18next"; import FNote from "../../entities/fnote"; import attributes from "../../services/attributes"; 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"; interface BookConfig { diff --git a/apps/client/src/widgets/view_widgets/geo_view/index.ts b/apps/client/src/widgets/view_widgets/geo_view/index.ts index 9b194f9df4..845df3813e 100644 --- a/apps/client/src/widgets/view_widgets/geo_view/index.ts +++ b/apps/client/src/widgets/view_widgets/geo_view/index.ts @@ -1,7 +1,6 @@ import ViewMode, { ViewModeArgs } from "../view_mode.js"; import L from "leaflet"; import type { GPX, LatLng, Layer, LeafletMouseEvent, Map, Marker } from "leaflet"; -import "leaflet/dist/leaflet.css"; import SpacedUpdate from "../../../services/spaced_update.js"; import { t } from "../../../services/i18n.js"; import processNoteWithMarker, { processNoteWithGpxTrack } from "./markers.js"; @@ -13,88 +12,6 @@ import { openMapContextMenu } from "./context_menu.js"; import attributes from "../../../services/attributes.js"; import { DEFAULT_MAP_LAYER_NAME, MAP_LAYERS } from "./map_layer.js"; -const TPL = /*html*/` -
- - -
-
`; - interface MapData { view?: { center?: LatLng | [number, number]; @@ -102,8 +19,6 @@ interface MapData { }; } -const DEFAULT_COORDINATES: [number, number] = [3.878638227135724, 446.6630455551659]; -const DEFAULT_ZOOM = 2; export const LOCATION_ATTRIBUTE = "geolocation"; enum State { @@ -142,27 +57,8 @@ export default class GeoView extends ViewMode { } async renderMap() { - const map = L.map(this.$container[0], { - worldCopyJump: true - }); + const layerName = this.parentNote.getLabelValue("map:style") ?? ; - const layerName = this.parentNote.getLabelValue("map:style") ?? DEFAULT_MAP_LAYER_NAME; - let layer: Layer; - 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"); - - layer = L.maplibreGL({ - style: style as any - }); - } else { - layer = L.tileLayer(layerData.url, { - attribution: layerData.attribution, - detectRetina: true - }); - } if (this.parentNote.hasLabel("map:scale")) { L.control.scale().addTo(map); @@ -220,7 +116,6 @@ export default class GeoView extends ViewMode { // Restore viewport position & zoom const center = parsedContent?.view?.center ?? DEFAULT_COORDINATES; const zoom = parsedContent?.view?.zoom ?? DEFAULT_ZOOM; - map.setView(center, zoom); } private onSave() { From 330b17bff829c2df45caae8396a3eef918fd336c Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 3 Sep 2025 23:35:29 +0300 Subject: [PATCH 029/233] refactor(react/collections): move layer name to view --- .../src/widgets/collections/geomap/index.tsx | 8 +++++++- .../src/widgets/collections/geomap/map.tsx | 16 +++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index c116ebd7a8..0a490b1f3e 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -1,15 +1,21 @@ import Map from "./map"; import "./index.css"; +import { ViewModeProps } from "../interface"; +import { useNoteLabel } from "../../react/hooks"; +import { DEFAULT_MAP_LAYER_NAME } from "./map_layer"; const DEFAULT_COORDINATES: [number, number] = [3.878638227135724, 446.6630455551659]; const DEFAULT_ZOOM = 2; -export default function GeoView() { +export default function GeoView({ note }: ViewModeProps) { + const [ layerName ] = useNoteLabel(note, "map:style"); + return (
); diff --git a/apps/client/src/widgets/collections/geomap/map.tsx b/apps/client/src/widgets/collections/geomap/map.tsx index 8a3dd97b15..4452be824a 100644 --- a/apps/client/src/widgets/collections/geomap/map.tsx +++ b/apps/client/src/widgets/collections/geomap/map.tsx @@ -1,19 +1,17 @@ import { useEffect, useRef, useState } from "preact/hooks"; import L, { LatLng, Layer } from "leaflet"; import "leaflet/dist/leaflet.css"; -import { useNoteContext, useNoteLabel } from "../../react/hooks"; -import { DEFAULT_MAP_LAYER_NAME, MAP_LAYERS } from "./map_layer"; +import { MAP_LAYERS } from "./map_layer"; interface MapProps { coordinates: LatLng | [number, number]; zoom: number; + layerName: string; } -export default function Map({ coordinates, zoom }: MapProps) { +export default function Map({ coordinates, zoom, layerName }: MapProps) { const mapRef = useRef(null); const containerRef = useRef(null); - const { note } = useNoteContext(); - const [ layerName ] = useNoteLabel(note, "map:style"); useEffect(() => { if (!containerRef.current) return; @@ -26,7 +24,7 @@ export default function Map({ coordinates, zoom }: MapProps) { const [ layer, setLayer ] = useState(); useEffect(() => { async function load() { - const layerData = MAP_LAYERS[layerName ?? DEFAULT_MAP_LAYER_NAME]; + const layerData = MAP_LAYERS[layerName]; if (layerData.type === "vector") { const style = (typeof layerData.style === "string" ? layerData.style : await layerData.style()); @@ -62,9 +60,5 @@ export default function Map({ coordinates, zoom }: MapProps) { mapRef.current.setView(coordinates, zoom); }, [ mapRef, coordinates, zoom ]); - return ( -
- -
- ); + return
; } From 620e6012da9527f1126b12f2912bf9ac52384cb2 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 3 Sep 2025 23:57:38 +0300 Subject: [PATCH 030/233] refactor(react/collections): reintroduce view mode --- .../src/widgets/collections/NoteList.tsx | 21 ++++++++++++------- .../src/widgets/collections/geomap/index.tsx | 10 ++++++++- .../src/widgets/collections/interface.ts | 4 +++- .../widgets/view_widgets/geo_view/index.ts | 7 ------- .../src/widgets/view_widgets/view_mode.ts | 9 -------- 5 files changed, 26 insertions(+), 25 deletions(-) diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index 501944158e..5781daf604 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -3,17 +3,19 @@ import { useNoteContext, useNoteLabel, 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 { useEffect, useMemo, useRef, useState } from "preact/hooks"; import GeoView from "./geomap"; +import ViewModeStorage from "../view_widgets/view_mode_storage"; -interface NoteListProps { +interface NoteListProps { 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; } -export default function NoteList({ note: providedNote, highlightedTokens, displayOnlyCollections }: NoteListProps) { +export default function NoteList({ note: providedNote, highlightedTokens, displayOnlyCollections }: NoteListProps) { const widgetRef = useRef(null); const { note: contextNote, noteContext } = useNoteContext(); const note = providedNote ?? contextNote; @@ -49,19 +51,24 @@ export default function NoteList({ note: providedNote, highlightedTokens, displa return () => observer.disconnect(); }, []); + const viewStorage = useMemo(() => { + if (!note || !viewType) return; + return new ViewModeStorage(note, viewType); + }, [ note, viewType ]); + return (
- {isEnabled && ( + {viewStorage && isEnabled && (
- {getComponentByViewType(note, noteIds, viewType, highlightedTokens)} + {getComponentByViewType(note, noteIds, viewType, highlightedTokens, viewStorage)}
)}
); } -function getComponentByViewType(note: FNote, noteIds: string[], viewType: ViewTypeOptions, highlightedTokens: string[] | null | undefined) { - const props: ViewModeProps = { note, noteIds, highlightedTokens }; +function getComponentByViewType(note: FNote, noteIds: string[], viewType: ViewTypeOptions, highlightedTokens: string[] | null | undefined, viewStorage: ViewModeStorage) { + const props: ViewModeProps = { note, noteIds, highlightedTokens, viewStorage }; switch (viewType) { case "list": diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index 0a490b1f3e..39081c5edc 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -3,11 +3,19 @@ import "./index.css"; import { ViewModeProps } from "../interface"; import { useNoteLabel } from "../../react/hooks"; import { DEFAULT_MAP_LAYER_NAME } from "./map_layer"; +import { LatLng } from "leaflet"; const DEFAULT_COORDINATES: [number, number] = [3.878638227135724, 446.6630455551659]; const DEFAULT_ZOOM = 2; -export default function GeoView({ note }: ViewModeProps) { +interface MapData { + view?: { + center?: LatLng | [number, number]; + zoom?: number; + }; +} + +export default function GeoView({ note, viewStorage }: ViewModeProps) { const [ layerName ] = useNoteLabel(note, "map:style"); return ( diff --git a/apps/client/src/widgets/collections/interface.ts b/apps/client/src/widgets/collections/interface.ts index 80c194f316..e528db165a 100644 --- a/apps/client/src/widgets/collections/interface.ts +++ b/apps/client/src/widgets/collections/interface.ts @@ -1,15 +1,17 @@ import FNote from "../../entities/fnote"; import type { ViewModeArgs } from "../view_widgets/view_mode"; +import ViewModeStorage from "../view_widgets/view_mode_storage"; export const allViewTypes = ["list", "grid", "calendar", "table", "geoMap", "board"] as const; export type ArgsWithoutNoteId = Omit; export type ViewTypeOptions = typeof allViewTypes[number]; -export interface ViewModeProps { +export interface ViewModeProps { note: FNote; /** * We're using noteIds so that it's not necessary to load all notes at once when paging. */ noteIds: string[]; highlightedTokens: string[] | null | undefined; + viewStorage: ViewModeStorage; } diff --git a/apps/client/src/widgets/view_widgets/geo_view/index.ts b/apps/client/src/widgets/view_widgets/geo_view/index.ts index 845df3813e..fa23b6e0e1 100644 --- a/apps/client/src/widgets/view_widgets/geo_view/index.ts +++ b/apps/client/src/widgets/view_widgets/geo_view/index.ts @@ -12,13 +12,6 @@ import { openMapContextMenu } from "./context_menu.js"; import attributes from "../../../services/attributes.js"; import { DEFAULT_MAP_LAYER_NAME, MAP_LAYERS } from "./map_layer.js"; -interface MapData { - view?: { - center?: LatLng | [number, number]; - zoom?: number; - }; -} - export const LOCATION_ATTRIBUTE = "geolocation"; enum State { diff --git a/apps/client/src/widgets/view_widgets/view_mode.ts b/apps/client/src/widgets/view_widgets/view_mode.ts index cb7d3a8a84..303eac9858 100644 --- a/apps/client/src/widgets/view_widgets/view_mode.ts +++ b/apps/client/src/widgets/view_widgets/view_mode.ts @@ -57,13 +57,4 @@ export default abstract class ViewMode extends Component { return this.parentNote.hasLabel("readOnly"); } - get viewStorage() { - if (this._viewStorage) { - return this._viewStorage; - } - - this._viewStorage = new ViewModeStorage(this.parentNote, this.viewType); - return this._viewStorage; - } - } From 2346230d36a9a8eab7c40a7ec6a508bffd42a61b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 4 Sep 2025 14:26:29 +0300 Subject: [PATCH 031/233] chore(react/collections/geomap): save state --- .../src/widgets/collections/geomap/index.tsx | 22 ++++++++++++++++++- .../src/widgets/collections/geomap/map.tsx | 18 ++++++++++++++- .../widgets/view_widgets/geo_view/index.ts | 9 +++----- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index 39081c5edc..8bdc8b7a8e 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -1,9 +1,10 @@ import Map from "./map"; import "./index.css"; import { ViewModeProps } from "../interface"; -import { useNoteLabel } from "../../react/hooks"; +import { useNoteLabel, useSpacedUpdate } from "../../react/hooks"; import { DEFAULT_MAP_LAYER_NAME } from "./map_layer"; import { LatLng } from "leaflet"; +import { useEffect, useRef } from "preact/hooks"; const DEFAULT_COORDINATES: [number, number] = [3.878638227135724, 446.6630455551659]; const DEFAULT_ZOOM = 2; @@ -17,6 +18,19 @@ interface MapData { export default function GeoView({ note, viewStorage }: ViewModeProps) { const [ layerName ] = useNoteLabel(note, "map:style"); + const viewOptions = useRef(); + const spacedUpdate = useSpacedUpdate(() => { + viewStorage.store({ + view: viewOptions.current + }); + }, 5000); + + // Clean up on note change. + useEffect(() => { + viewStorage.restore().then(data => { + viewOptions.current = data?.view; + }); + }, [ note ]); return (
@@ -24,6 +38,12 @@ export default function GeoView({ note, viewStorage }: ViewModeProps) { coordinates={DEFAULT_COORDINATES} zoom={DEFAULT_ZOOM} layerName={layerName ?? DEFAULT_MAP_LAYER_NAME} + viewportChanged={(coordinates, zoom) => { + if (!viewOptions.current) return; + viewOptions.current.center = coordinates; + viewOptions.current.zoom = zoom; + spacedUpdate.scheduleUpdate(); + }} />
); diff --git a/apps/client/src/widgets/collections/geomap/map.tsx b/apps/client/src/widgets/collections/geomap/map.tsx index 4452be824a..8dd6d1fc04 100644 --- a/apps/client/src/widgets/collections/geomap/map.tsx +++ b/apps/client/src/widgets/collections/geomap/map.tsx @@ -7,9 +7,10 @@ interface MapProps { coordinates: LatLng | [number, number]; zoom: number; layerName: string; + viewportChanged: (coordinates: LatLng, zoom: number) => void; } -export default function Map({ coordinates, zoom, layerName }: MapProps) { +export default function Map({ coordinates, zoom, layerName, viewportChanged }: MapProps) { const mapRef = useRef(null); const containerRef = useRef(null); @@ -60,5 +61,20 @@ export default function Map({ coordinates, zoom, layerName }: MapProps) { 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 ]); + return
; } diff --git a/apps/client/src/widgets/view_widgets/geo_view/index.ts b/apps/client/src/widgets/view_widgets/geo_view/index.ts index fa23b6e0e1..60e9facd01 100644 --- a/apps/client/src/widgets/view_widgets/geo_view/index.ts +++ b/apps/client/src/widgets/view_widgets/geo_view/index.ts @@ -75,9 +75,6 @@ export default class GeoView extends ViewMode { this.#restoreViewportAndZoom(); const isEditable = !this.isReadOnly; - const updateFn = () => this.spacedUpdate.scheduleUpdate(); - map.on("moveend", updateFn); - map.on("zoomend", updateFn); map.on("click", (e) => this.#onMapClicked(e)) map.on("contextmenu", (e) => openMapContextMenu(this.parentNote.noteId, e, isEditable)); @@ -117,13 +114,13 @@ export default class GeoView extends ViewMode { if (map) { data = { view: { - center: map.getBounds().getCenter(), - zoom: map.getZoom() + center: , + zoom: } }; } - this.viewStorage.store(data); + } async #reloadMarkers() { From 63dd79e23c633a47f27b72af696719f310fedf42 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 4 Sep 2025 15:13:48 +0300 Subject: [PATCH 032/233] chore(react/collections/geomap): restore state --- .../src/widgets/collections/NoteList.tsx | 38 ++++++++++++++----- .../src/widgets/collections/geomap/index.tsx | 25 ++++-------- .../src/widgets/collections/interface.ts | 4 +- .../widgets/view_widgets/geo_view/index.ts | 30 --------------- 4 files changed, 39 insertions(+), 58 deletions(-) diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index 5781daf604..f19d983c8e 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -51,25 +51,30 @@ export default function NoteList({ note: providedNote, highlig return () => observer.disconnect(); }, []); - const viewStorage = useMemo(() => { - if (!note || !viewType) return; - return new ViewModeStorage(note, viewType); - }, [ note, viewType ]); + // Preload the configuration. + let props: ViewModeProps | undefined | null = null; + const viewModeConfig = useViewModeConfig(note, viewType); + if (note && viewModeConfig) { + props = { + note, noteIds, + highlightedTokens, + viewConfig: viewModeConfig[0], + saveConfig: viewModeConfig[1] + } + } return (
- {viewStorage && isEnabled && ( + {props && isEnabled && (
- {getComponentByViewType(note, noteIds, viewType, highlightedTokens, viewStorage)} + {getComponentByViewType(viewType, props)}
)}
); } -function getComponentByViewType(note: FNote, noteIds: string[], viewType: ViewTypeOptions, highlightedTokens: string[] | null | undefined, viewStorage: ViewModeStorage) { - const props: ViewModeProps = { note, noteIds, highlightedTokens, viewStorage }; - +function getComponentByViewType(viewType: ViewTypeOptions, props: ViewModeProps) { switch (viewType) { case "list": return ; @@ -122,3 +127,18 @@ function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOptions | return noteIds; } + +function useViewModeConfig(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(note, viewType); + viewStorage.restore().then(config => { + const storeFn = (config: T) => viewStorage.store(config); + setViewConfig([ config, storeFn ]); + }); + }, [ note, viewType ]); + + return viewConfig; +} diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index 8bdc8b7a8e..293a1f4a67 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -16,32 +16,23 @@ interface MapData { }; } -export default function GeoView({ note, viewStorage }: ViewModeProps) { +export default function GeoView({ note, viewConfig, saveConfig }: ViewModeProps) { const [ layerName ] = useNoteLabel(note, "map:style"); - const viewOptions = useRef(); const spacedUpdate = useSpacedUpdate(() => { - viewStorage.store({ - view: viewOptions.current - }); + if (viewConfig) { + saveConfig(viewConfig); + } }, 5000); - // Clean up on note change. - useEffect(() => { - viewStorage.restore().then(data => { - viewOptions.current = data?.view; - }); - }, [ note ]); - return (
{ - if (!viewOptions.current) return; - viewOptions.current.center = coordinates; - viewOptions.current.zoom = zoom; + if (!viewConfig) viewConfig = {}; + viewConfig.view = { center: coordinates, zoom }; spacedUpdate.scheduleUpdate(); }} /> diff --git a/apps/client/src/widgets/collections/interface.ts b/apps/client/src/widgets/collections/interface.ts index e528db165a..4f89a871d5 100644 --- a/apps/client/src/widgets/collections/interface.ts +++ b/apps/client/src/widgets/collections/interface.ts @@ -1,6 +1,5 @@ import FNote from "../../entities/fnote"; import type { ViewModeArgs } from "../view_widgets/view_mode"; -import ViewModeStorage from "../view_widgets/view_mode_storage"; export const allViewTypes = ["list", "grid", "calendar", "table", "geoMap", "board"] as const; export type ArgsWithoutNoteId = Omit; @@ -13,5 +12,6 @@ export interface ViewModeProps { */ noteIds: string[]; highlightedTokens: string[] | null | undefined; - viewStorage: ViewModeStorage; + viewConfig: T | undefined; + saveConfig(newConfig: T): void; } diff --git a/apps/client/src/widgets/view_widgets/geo_view/index.ts b/apps/client/src/widgets/view_widgets/geo_view/index.ts index 60e9facd01..880ef7230c 100644 --- a/apps/client/src/widgets/view_widgets/geo_view/index.ts +++ b/apps/client/src/widgets/view_widgets/geo_view/index.ts @@ -72,8 +72,6 @@ export default class GeoView extends ViewMode { throw new Error(t("geo-map.unable-to-load-map")); } - this.#restoreViewportAndZoom(); - const isEditable = !this.isReadOnly; map.on("click", (e) => this.#onMapClicked(e)) map.on("contextmenu", (e) => openMapContextMenu(this.parentNote.noteId, e, isEditable)); @@ -95,34 +93,6 @@ export default class GeoView extends ViewMode { } } - async #restoreViewportAndZoom() { - const map = this.map; - if (!map) { - return; - } - - const parsedContent = await this.viewStorage.restore(); - - // Restore viewport position & zoom - const center = parsedContent?.view?.center ?? DEFAULT_COORDINATES; - const zoom = parsedContent?.view?.zoom ?? DEFAULT_ZOOM; - } - - private onSave() { - const map = this.map; - let data: MapData = {}; - if (map) { - data = { - view: { - center: , - zoom: - } - }; - } - - - } - async #reloadMarkers() { if (!this.map) { return; From 581303c9231e31006a4cfb3e18ad336bf5ab79d4 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 4 Sep 2025 15:47:56 +0300 Subject: [PATCH 033/233] chore(react/collections/geomap): get markers to show up --- .../src/widgets/collections/geomap/index.tsx | 33 +++++++++++++++++-- .../src/widgets/collections/geomap/map.tsx | 12 +++++-- .../src/widgets/collections/geomap/marker.tsx | 24 ++++++++++++++ .../widgets/view_widgets/geo_view/index.ts | 2 -- .../widgets/view_widgets/geo_view/markers.ts | 1 - 5 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 apps/client/src/widgets/collections/geomap/marker.tsx diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index 293a1f4a67..a966cb6d5f 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -4,10 +4,13 @@ import { ViewModeProps } from "../interface"; import { useNoteLabel, useSpacedUpdate } from "../../react/hooks"; import { DEFAULT_MAP_LAYER_NAME } from "./map_layer"; import { LatLng } from "leaflet"; -import { useEffect, useRef } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; +import Marker, { MarkerProps } from "./marker"; +import froca from "../../../services/froca"; const DEFAULT_COORDINATES: [number, number] = [3.878638227135724, 446.6630455551659]; const DEFAULT_ZOOM = 2; +export const LOCATION_ATTRIBUTE = "geolocation"; interface MapData { view?: { @@ -16,14 +19,36 @@ interface MapData { }; } -export default function GeoView({ note, viewConfig, saveConfig }: ViewModeProps) { +export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewModeProps) { const [ layerName ] = useNoteLabel(note, "map:style"); + const [ markers, setMarkers ] = useState([]); const spacedUpdate = useSpacedUpdate(() => { if (viewConfig) { saveConfig(viewConfig); } }, 5000); + async function refreshMarkers() { + const notes = await froca.getNotes(noteIds); + const markers: MarkerProps[] = []; + for (const childNote of notes) { + const latLng = childNote.getAttributeValue("label", LOCATION_ATTRIBUTE); + if (!latLng) continue; + + const [lat, lng] = latLng.split(",", 2).map((el) => parseFloat(el)); + markers.push({ + coordinates: [lat, lng] + }) + } + + console.log("Built ", markers); + setMarkers(markers); + } + + useEffect(() => { + refreshMarkers(); + }, [ note ]); + return (
+ > + {markers.map(marker => )} +
); } diff --git a/apps/client/src/widgets/collections/geomap/map.tsx b/apps/client/src/widgets/collections/geomap/map.tsx index 8dd6d1fc04..f959606c78 100644 --- a/apps/client/src/widgets/collections/geomap/map.tsx +++ b/apps/client/src/widgets/collections/geomap/map.tsx @@ -2,15 +2,19 @@ import { useEffect, useRef, useState } from "preact/hooks"; import L, { LatLng, Layer } from "leaflet"; import "leaflet/dist/leaflet.css"; import { MAP_LAYERS } from "./map_layer"; +import { ComponentChildren, createContext } from "preact"; + +export const ParentMap = createContext(null); interface MapProps { coordinates: LatLng | [number, number]; zoom: number; layerName: string; viewportChanged: (coordinates: LatLng, zoom: number) => void; + children: ComponentChildren; } -export default function Map({ coordinates, zoom, layerName, viewportChanged }: MapProps) { +export default function Map({ coordinates, zoom, layerName, viewportChanged, children }: MapProps) { const mapRef = useRef(null); const containerRef = useRef(null); @@ -76,5 +80,9 @@ export default function Map({ coordinates, zoom, layerName, viewportChanged }: M }; }, [ mapRef, viewportChanged ]); - return
; + return
+ + {children} + +
; } diff --git a/apps/client/src/widgets/collections/geomap/marker.tsx b/apps/client/src/widgets/collections/geomap/marker.tsx new file mode 100644 index 0000000000..5f5830c630 --- /dev/null +++ b/apps/client/src/widgets/collections/geomap/marker.tsx @@ -0,0 +1,24 @@ +import { useContext, useEffect } from "preact/hooks"; +import { ParentMap } from "./map"; +import { marker } from "leaflet"; + +export interface MarkerProps { + coordinates: [ number, number ]; +} + +export default function Marker({ coordinates }: MarkerProps) { + const parentMap = useContext(ParentMap); + + useEffect(() => { + if (!parentMap) return; + + const newMarker = marker(coordinates, { + + }); + newMarker.addTo(parentMap); + + return () => newMarker.removeFrom(parentMap); + }, [ parentMap, coordinates ]); + + return (
) +} diff --git a/apps/client/src/widgets/view_widgets/geo_view/index.ts b/apps/client/src/widgets/view_widgets/geo_view/index.ts index 880ef7230c..3a3a286bed 100644 --- a/apps/client/src/widgets/view_widgets/geo_view/index.ts +++ b/apps/client/src/widgets/view_widgets/geo_view/index.ts @@ -12,7 +12,6 @@ import { openMapContextMenu } from "./context_menu.js"; import attributes from "../../../services/attributes.js"; import { DEFAULT_MAP_LAYER_NAME, MAP_LAYERS } from "./map_layer.js"; -export const LOCATION_ATTRIBUTE = "geolocation"; enum State { Normal, @@ -119,7 +118,6 @@ export default class GeoView extends ViewMode { continue; } - const latLng = childNote.getAttributeValue("label", LOCATION_ATTRIBUTE); if (latLng) { const marker = processNoteWithMarker(this.map, childNote, latLng, draggable); this.currentMarkerData[childNote.noteId] = marker; diff --git a/apps/client/src/widgets/view_widgets/geo_view/markers.ts b/apps/client/src/widgets/view_widgets/geo_view/markers.ts index af836c252e..11aa45190c 100644 --- a/apps/client/src/widgets/view_widgets/geo_view/markers.ts +++ b/apps/client/src/widgets/view_widgets/geo_view/markers.ts @@ -11,7 +11,6 @@ import L from "leaflet"; let gpxLoaded = false; export default function processNoteWithMarker(map: Map, note: FNote, location: string, isEditable: boolean) { - const [lat, lng] = location.split(",", 2).map((el) => parseFloat(el)); const icon = buildIcon(note.getIcon(), note.getColorClass(), note.title, note.noteId); const newMarker = marker(latLng(lat, lng), { From 3382ccc7bfe25fab3f716e776068ad2d84812ec6 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 4 Sep 2025 15:58:50 +0300 Subject: [PATCH 034/233] refactor(react/collections/geomap): use different mechanism for markers --- .../src/widgets/collections/geomap/index.tsx | 37 +++++++------------ 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index a966cb6d5f..09d4b7760e 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -4,9 +4,10 @@ import { ViewModeProps } from "../interface"; import { useNoteLabel, useSpacedUpdate } from "../../react/hooks"; import { DEFAULT_MAP_LAYER_NAME } from "./map_layer"; import { LatLng } from "leaflet"; -import { useEffect, useRef, useState } from "preact/hooks"; -import Marker, { MarkerProps } from "./marker"; +import { useEffect, useState } from "preact/hooks"; +import Marker from "./marker"; import froca from "../../../services/froca"; +import FNote from "../../../entities/fnote"; const DEFAULT_COORDINATES: [number, number] = [3.878638227135724, 446.6630455551659]; const DEFAULT_ZOOM = 2; @@ -21,33 +22,14 @@ interface MapData { export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewModeProps) { const [ layerName ] = useNoteLabel(note, "map:style"); - const [ markers, setMarkers ] = useState([]); + const [ notes, setNotes ] = useState([]); const spacedUpdate = useSpacedUpdate(() => { if (viewConfig) { saveConfig(viewConfig); } }, 5000); - async function refreshMarkers() { - const notes = await froca.getNotes(noteIds); - const markers: MarkerProps[] = []; - for (const childNote of notes) { - const latLng = childNote.getAttributeValue("label", LOCATION_ATTRIBUTE); - if (!latLng) continue; - - const [lat, lng] = latLng.split(",", 2).map((el) => parseFloat(el)); - markers.push({ - coordinates: [lat, lng] - }) - } - - console.log("Built ", markers); - setMarkers(markers); - } - - useEffect(() => { - refreshMarkers(); - }, [ note ]); + useEffect(() => { froca.getNotes(noteIds).then(setNotes) }, [ noteIds ]); return (
@@ -61,8 +43,15 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM spacedUpdate.scheduleUpdate(); }} > - {markers.map(marker => )} + {notes.map(note => )}
); } + +function NoteMarker({ note }: { note: FNote }) { + const [ location ] = useNoteLabel(note, LOCATION_ATTRIBUTE); + const latLng = location?.split(",", 2).map((el) => parseFloat(el)) as [ number, number ] | undefined; + + return latLng && +} From 4a02981c093e31b067f4e4f786d81cfa7c0c4561 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 4 Sep 2025 16:17:27 +0300 Subject: [PATCH 035/233] refactor(react/collections/geomap): display reactive icon, text --- .../src/widgets/collections/geomap/index.tsx | 36 ++++++++++++++++--- .../src/widgets/collections/geomap/marker.tsx | 7 ++-- .../widgets/view_widgets/geo_view/markers.ts | 22 ------------ 3 files changed, 36 insertions(+), 29 deletions(-) diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index 09d4b7760e..8dc272604b 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -1,13 +1,15 @@ import Map from "./map"; import "./index.css"; import { ViewModeProps } from "../interface"; -import { useNoteLabel, useSpacedUpdate } from "../../react/hooks"; +import { useNoteLabel, useNoteProperty, useSpacedUpdate } from "../../react/hooks"; import { DEFAULT_MAP_LAYER_NAME } from "./map_layer"; -import { LatLng } from "leaflet"; -import { useEffect, useState } from "preact/hooks"; +import { divIcon, LatLng } from "leaflet"; +import { useEffect, useMemo, useState } from "preact/hooks"; import Marker 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"; const DEFAULT_COORDINATES: [number, number] = [3.878638227135724, 446.6630455551659]; const DEFAULT_ZOOM = 2; @@ -51,7 +53,33 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM function NoteMarker({ note }: { note: FNote }) { const [ location ] = useNoteLabel(note, LOCATION_ATTRIBUTE); + const [ colorClass ] = useNoteLabel(note, "colorClass"); + useNoteLabel(note, "iconClass"); // React to icon changes. + const title = useNoteProperty(note, "title"); + const iconClass = note.getIcon(); const latLng = location?.split(",", 2).map((el) => parseFloat(el)) as [ number, number ] | undefined; + const icon = useMemo(() => buildIcon(iconClass, colorClass ?? undefined, title, note.noteId), [ iconClass, colorClass, title, note.noteId]); - return latLng && + return latLng && +} + +function buildIcon(bxIconClass: string, colorClass?: string, title?: string, noteIdLink?: string) { + let html = /*html*/`\ + + + + ${title ?? ""}`; + + if (noteIdLink) { + html = `
${html}
`; + } + + return divIcon({ + html, + iconSize: [25, 41], + iconAnchor: [12, 41] + }); } diff --git a/apps/client/src/widgets/collections/geomap/marker.tsx b/apps/client/src/widgets/collections/geomap/marker.tsx index 5f5830c630..559723b83e 100644 --- a/apps/client/src/widgets/collections/geomap/marker.tsx +++ b/apps/client/src/widgets/collections/geomap/marker.tsx @@ -1,19 +1,20 @@ import { useContext, useEffect } from "preact/hooks"; import { ParentMap } from "./map"; -import { marker } from "leaflet"; +import { DivIcon, Icon, marker } from "leaflet"; export interface MarkerProps { coordinates: [ number, number ]; + icon?: Icon | DivIcon; } -export default function Marker({ coordinates }: MarkerProps) { +export default function Marker({ coordinates, icon }: MarkerProps) { const parentMap = useContext(ParentMap); useEffect(() => { if (!parentMap) return; const newMarker = marker(coordinates, { - + icon }); newMarker.addTo(parentMap); diff --git a/apps/client/src/widgets/view_widgets/geo_view/markers.ts b/apps/client/src/widgets/view_widgets/geo_view/markers.ts index 11aa45190c..f5649551f1 100644 --- a/apps/client/src/widgets/view_widgets/geo_view/markers.ts +++ b/apps/client/src/widgets/view_widgets/geo_view/markers.ts @@ -1,5 +1,3 @@ -import markerIcon from "leaflet/dist/images/marker-icon.png"; -import markerIconShadow from "leaflet/dist/images/marker-shadow.png"; import { marker, latLng, divIcon, Map, type Marker } from "leaflet"; import type FNote from "../../../entities/fnote.js"; import openContextMenu from "./context_menu.js"; @@ -11,8 +9,6 @@ import L from "leaflet"; let gpxLoaded = false; export default function processNoteWithMarker(map: Map, note: FNote, location: string, isEditable: boolean) { - const icon = buildIcon(note.getIcon(), note.getColorClass(), note.title, note.noteId); - const newMarker = marker(latLng(lat, lng), { icon, draggable: isEditable, @@ -78,21 +74,3 @@ export async function processNoteWithGpxTrack(map: Map, note: FNote) { track.addTo(map); return track; } - -function buildIcon(bxIconClass: string, colorClass?: string, title?: string, noteIdLink?: string) { - let html = /*html*/`\ - - - - ${title ?? ""}`; - - if (noteIdLink) { - html = `
${html}
`; - } - - return divIcon({ - html, - iconSize: [25, 41], - iconAnchor: [12, 41] - }); -} From 3e2b777c304653179435e196d6cb974de3c0573f Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 4 Sep 2025 16:19:56 +0300 Subject: [PATCH 036/233] chore(react/collections/geomap): fix color class --- apps/client/src/widgets/collections/geomap/index.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index 8dc272604b..1426906910 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -53,9 +53,13 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM function NoteMarker({ note }: { note: FNote }) { const [ location ] = useNoteLabel(note, LOCATION_ATTRIBUTE); - const [ colorClass ] = useNoteLabel(note, "colorClass"); - useNoteLabel(note, "iconClass"); // React to icon changes. + + // React to changes + useNoteLabel(note, "color"); + useNoteLabel(note, "iconClass"); + const title = useNoteProperty(note, "title"); + const colorClass = note.getColorClass(); const iconClass = note.getIcon(); const latLng = location?.split(",", 2).map((el) => parseFloat(el)) as [ number, number ] | undefined; const icon = useMemo(() => buildIcon(iconClass, colorClass ?? undefined, title, note.noteId), [ iconClass, colorClass, title, note.noteId]); From ec40d20e6a9852b0f2b1170cc85ba88b2900fec4 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 4 Sep 2025 16:24:01 +0300 Subject: [PATCH 037/233] chore(react/collections/geomap): middle click --- apps/client/src/widgets/collections/geomap/index.tsx | 11 ++++++++++- apps/client/src/widgets/collections/geomap/marker.tsx | 10 ++++++++-- .../src/widgets/view_widgets/geo_view/markers.ts | 10 ---------- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index 1426906910..63142b365a 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -4,12 +4,13 @@ import { ViewModeProps } from "../interface"; import { useNoteLabel, useNoteProperty, useSpacedUpdate } from "../../react/hooks"; import { DEFAULT_MAP_LAYER_NAME } from "./map_layer"; import { divIcon, LatLng } from "leaflet"; -import { useEffect, useMemo, useState } from "preact/hooks"; +import { useCallback, useEffect, useMemo, useState } from "preact/hooks"; import Marker 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"; const DEFAULT_COORDINATES: [number, number] = [3.878638227135724, 446.6630455551659]; const DEFAULT_ZOOM = 2; @@ -67,6 +68,14 @@ function NoteMarker({ note }: { note: FNote }) { return latLng && { + // Middle click to open in new tab + if (e.button === 1) { + const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId; + appContext.tabManager.openInNewTab(note.noteId, hoistedNoteId); + return true; + } + }, [ note.noteId ])} /> } diff --git a/apps/client/src/widgets/collections/geomap/marker.tsx b/apps/client/src/widgets/collections/geomap/marker.tsx index 559723b83e..900ef88520 100644 --- a/apps/client/src/widgets/collections/geomap/marker.tsx +++ b/apps/client/src/widgets/collections/geomap/marker.tsx @@ -5,9 +5,10 @@ import { DivIcon, Icon, marker } from "leaflet"; export interface MarkerProps { coordinates: [ number, number ]; icon?: Icon | DivIcon; + mouseDown?: (e: MouseEvent) => void; } -export default function Marker({ coordinates, icon }: MarkerProps) { +export default function Marker({ coordinates, icon, mouseDown }: MarkerProps) { const parentMap = useContext(ParentMap); useEffect(() => { @@ -16,10 +17,15 @@ export default function Marker({ coordinates, icon }: MarkerProps) { const newMarker = marker(coordinates, { icon }); + + if (mouseDown) { + newMarker.on("mousedown", e => mouseDown(e.originalEvent)); + } + newMarker.addTo(parentMap); return () => newMarker.removeFrom(parentMap); - }, [ parentMap, coordinates ]); + }, [ parentMap, coordinates, mouseDown ]); return (
) } diff --git a/apps/client/src/widgets/view_widgets/geo_view/markers.ts b/apps/client/src/widgets/view_widgets/geo_view/markers.ts index f5649551f1..1a89d3c2f5 100644 --- a/apps/client/src/widgets/view_widgets/geo_view/markers.ts +++ b/apps/client/src/widgets/view_widgets/geo_view/markers.ts @@ -22,16 +22,6 @@ export default function processNoteWithMarker(map: Map, note: FNote, location: s }); } - newMarker.on("mousedown", ({ originalEvent }) => { - // Middle click to open in new tab - if (originalEvent.button === 1) { - const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId; - //@ts-ignore, fix once tab manager is ported. - appContext.tabManager.openInNewTab(note.noteId, hoistedNoteId); - return true; - } - }); - newMarker.on("contextmenu", (e) => { openContextMenu(note.noteId, e, isEditable); }); From 5854adb8067f397af6beacc9b72571b8c8bea5c9 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 4 Sep 2025 16:44:35 +0300 Subject: [PATCH 038/233] chore(react/collections/geomap): bring back dragging --- .../src/widgets/collections/geomap/api.ts | 8 +++++ .../src/widgets/collections/geomap/index.tsx | 30 ++++++++++++------- .../src/widgets/collections/geomap/marker.tsx | 24 +++++++++++---- .../widgets/view_widgets/geo_view/editing.ts | 5 ---- .../widgets/view_widgets/geo_view/markers.ts | 13 -------- .../src/widgets/view_widgets/view_mode.ts | 4 --- 6 files changed, 47 insertions(+), 37 deletions(-) create mode 100644 apps/client/src/widgets/collections/geomap/api.ts diff --git a/apps/client/src/widgets/collections/geomap/api.ts b/apps/client/src/widgets/collections/geomap/api.ts new file mode 100644 index 0000000000..7c3b1dbbd4 --- /dev/null +++ b/apps/client/src/widgets/collections/geomap/api.ts @@ -0,0 +1,8 @@ +import { LatLng } from "leaflet"; +import { LOCATION_ATTRIBUTE } from "."; +import attributes from "../../../services/attributes"; + +export async function moveMarker(noteId: string, latLng: LatLng | null) { + const value = latLng ? [latLng.lat, latLng.lng].join(",") : ""; + await attributes.setLabel(noteId, LOCATION_ATTRIBUTE, value); +} diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index 63142b365a..dae07753d5 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -11,6 +11,7 @@ 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 { moveMarker } from "./api"; const DEFAULT_COORDINATES: [number, number] = [3.878638227135724, 446.6630455551659]; const DEFAULT_ZOOM = 2; @@ -25,6 +26,7 @@ interface MapData { export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewModeProps) { const [ layerName ] = useNoteLabel(note, "map:style"); + const [ isReadOnly ] = useNoteLabel(note, "readOnly"); const [ notes, setNotes ] = useState([]); const spacedUpdate = useSpacedUpdate(() => { if (viewConfig) { @@ -46,13 +48,13 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM spacedUpdate.scheduleUpdate(); }} > - {notes.map(note => )} + {notes.map(note => )}
); } -function NoteMarker({ note }: { note: FNote }) { +function NoteMarker({ note, editable }: { note: FNote, editable: boolean }) { const [ location ] = useNoteLabel(note, LOCATION_ATTRIBUTE); // React to changes @@ -65,17 +67,25 @@ function NoteMarker({ note }: { note: FNote }) { const latLng = location?.split(",", 2).map((el) => parseFloat(el)) as [ number, number ] | undefined; const icon = useMemo(() => buildIcon(iconClass, colorClass ?? undefined, title, note.noteId), [ iconClass, colorClass, title, 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 ]); + return latLng && { - // Middle click to open in new tab - if (e.button === 1) { - const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId; - appContext.tabManager.openInNewTab(note.noteId, hoistedNoteId); - return true; - } - }, [ note.noteId ])} + mouseDown={onMouseDown} + draggable={editable} + dragged={onDragged} /> } diff --git a/apps/client/src/widgets/collections/geomap/marker.tsx b/apps/client/src/widgets/collections/geomap/marker.tsx index 900ef88520..53ec84ab89 100644 --- a/apps/client/src/widgets/collections/geomap/marker.tsx +++ b/apps/client/src/widgets/collections/geomap/marker.tsx @@ -1,27 +1,41 @@ import { useContext, useEffect } from "preact/hooks"; import { ParentMap } from "./map"; -import { DivIcon, Icon, marker } from "leaflet"; +import { DivIcon, Icon, LatLng, Marker as LeafletMarker, marker, MarkerOptions } from "leaflet"; export interface MarkerProps { coordinates: [ number, number ]; icon?: Icon | DivIcon; mouseDown?: (e: MouseEvent) => void; + dragged: ((newCoordinates: LatLng) => void) + draggable?: boolean; } -export default function Marker({ coordinates, icon, mouseDown }: MarkerProps) { +export default function Marker({ coordinates, icon, draggable, dragged, mouseDown }: MarkerProps) { const parentMap = useContext(ParentMap); useEffect(() => { if (!parentMap) return; - const newMarker = marker(coordinates, { - icon - }); + const options: MarkerOptions = { icon }; + if (draggable) { + options.draggable = true; + options.autoPan = true; + options.autoPanSpeed = 5; + } + + const newMarker = marker(coordinates, options); if (mouseDown) { newMarker.on("mousedown", e => mouseDown(e.originalEvent)); } + if (dragged) { + newMarker.on("moveend", e => { + const coordinates = (e.target as LeafletMarker).getLatLng(); + dragged(coordinates); + }); + } + newMarker.addTo(parentMap); return () => newMarker.removeFrom(parentMap); diff --git a/apps/client/src/widgets/view_widgets/geo_view/editing.ts b/apps/client/src/widgets/view_widgets/geo_view/editing.ts index c9dd7368cf..85753f38d3 100644 --- a/apps/client/src/widgets/view_widgets/geo_view/editing.ts +++ b/apps/client/src/widgets/view_widgets/geo_view/editing.ts @@ -18,11 +18,6 @@ interface CreateChildResponse { }; } -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 dialog.prompt({ message: t("relation_map.enter_title_of_new_note"), defaultValue: t("relation_map.default_new_note_title") }); diff --git a/apps/client/src/widgets/view_widgets/geo_view/markers.ts b/apps/client/src/widgets/view_widgets/geo_view/markers.ts index 1a89d3c2f5..8cfad222d3 100644 --- a/apps/client/src/widgets/view_widgets/geo_view/markers.ts +++ b/apps/client/src/widgets/view_widgets/geo_view/markers.ts @@ -9,19 +9,6 @@ import L from "leaflet"; let gpxLoaded = false; export default function processNoteWithMarker(map: Map, note: FNote, location: string, isEditable: boolean) { - const newMarker = marker(latLng(lat, lng), { - icon, - draggable: isEditable, - autoPan: true, - autoPanSpeed: 5 - }).addTo(map); - - if (isEditable) { - newMarker.on("moveend", (e) => { - moveMarker(note.noteId, (e.target as Marker).getLatLng()); - }); - } - newMarker.on("contextmenu", (e) => { openContextMenu(note.noteId, e, isEditable); }); diff --git a/apps/client/src/widgets/view_widgets/view_mode.ts b/apps/client/src/widgets/view_widgets/view_mode.ts index 303eac9858..1bce104995 100644 --- a/apps/client/src/widgets/view_widgets/view_mode.ts +++ b/apps/client/src/widgets/view_widgets/view_mode.ts @@ -53,8 +53,4 @@ export default abstract class ViewMode extends Component { } } - get isReadOnly() { - return this.parentNote.hasLabel("readOnly"); - } - } From 0f9a5296479ea544e9f62a52d49619f90f2f7f80 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 4 Sep 2025 16:47:38 +0300 Subject: [PATCH 039/233] chore(react/collections/geomap): fix editability --- apps/client/src/widgets/collections/geomap/index.tsx | 6 +++--- apps/client/src/widgets/collections/geomap/marker.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index dae07753d5..7a09d4eb6b 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -1,7 +1,7 @@ import Map from "./map"; import "./index.css"; import { ViewModeProps } from "../interface"; -import { useNoteLabel, useNoteProperty, useSpacedUpdate } from "../../react/hooks"; +import { useNoteLabel, useNoteLabelBoolean, useNoteProperty, useSpacedUpdate } from "../../react/hooks"; import { DEFAULT_MAP_LAYER_NAME } from "./map_layer"; import { divIcon, LatLng } from "leaflet"; import { useCallback, useEffect, useMemo, useState } from "preact/hooks"; @@ -26,7 +26,7 @@ interface MapData { export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewModeProps) { const [ layerName ] = useNoteLabel(note, "map:style"); - const [ isReadOnly ] = useNoteLabel(note, "readOnly"); + const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly"); const [ notes, setNotes ] = useState([]); const spacedUpdate = useSpacedUpdate(() => { if (viewConfig) { @@ -85,7 +85,7 @@ function NoteMarker({ note, editable }: { note: FNote, editable: boolean }) { icon={icon} mouseDown={onMouseDown} draggable={editable} - dragged={onDragged} + dragged={editable ? onDragged : undefined} /> } diff --git a/apps/client/src/widgets/collections/geomap/marker.tsx b/apps/client/src/widgets/collections/geomap/marker.tsx index 53ec84ab89..5c250097bd 100644 --- a/apps/client/src/widgets/collections/geomap/marker.tsx +++ b/apps/client/src/widgets/collections/geomap/marker.tsx @@ -6,7 +6,7 @@ export interface MarkerProps { coordinates: [ number, number ]; icon?: Icon | DivIcon; mouseDown?: (e: MouseEvent) => void; - dragged: ((newCoordinates: LatLng) => void) + dragged?: ((newCoordinates: LatLng) => void); draggable?: boolean; } From dd654fcd8de6e109812c3ac5a2e5264ed92643fe Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 4 Sep 2025 16:51:03 +0300 Subject: [PATCH 040/233] chore(react/collections/geomap): bring back open on click --- .../src/widgets/collections/geomap/index.tsx | 9 ++++++-- .../src/widgets/collections/geomap/marker.tsx | 21 ++++++++++++------- .../widgets/view_widgets/geo_view/markers.ts | 7 ------- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index 7a09d4eb6b..4e6ced7a66 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -67,6 +67,10 @@ function NoteMarker({ note, editable }: { note: FNote, editable: boolean }) { const latLng = location?.split(",", 2).map((el) => parseFloat(el)) as [ number, number ] | undefined; const icon = useMemo(() => buildIcon(iconClass, colorClass ?? undefined, title, note.noteId), [ iconClass, colorClass, title, note.noteId]); + 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) { @@ -83,9 +87,10 @@ function NoteMarker({ note, editable }: { note: FNote, editable: boolean }) { return latLng && } diff --git a/apps/client/src/widgets/collections/geomap/marker.tsx b/apps/client/src/widgets/collections/geomap/marker.tsx index 5c250097bd..009d60034f 100644 --- a/apps/client/src/widgets/collections/geomap/marker.tsx +++ b/apps/client/src/widgets/collections/geomap/marker.tsx @@ -5,12 +5,13 @@ import { DivIcon, Icon, LatLng, Marker as LeafletMarker, marker, MarkerOptions } export interface MarkerProps { coordinates: [ number, number ]; icon?: Icon | DivIcon; - mouseDown?: (e: MouseEvent) => void; - dragged?: ((newCoordinates: LatLng) => void); + onClick?: () => void; + onMouseDown?: (e: MouseEvent) => void; + onDragged?: ((newCoordinates: LatLng) => void); draggable?: boolean; } -export default function Marker({ coordinates, icon, draggable, dragged, mouseDown }: MarkerProps) { +export default function Marker({ coordinates, icon, draggable, onClick, onDragged, onMouseDown }: MarkerProps) { const parentMap = useContext(ParentMap); useEffect(() => { @@ -25,21 +26,25 @@ export default function Marker({ coordinates, icon, draggable, dragged, mouseDow const newMarker = marker(coordinates, options); - if (mouseDown) { - newMarker.on("mousedown", e => mouseDown(e.originalEvent)); + if (onClick) { + newMarker.on("click", () => onClick()); } - if (dragged) { + if (onMouseDown) { + newMarker.on("mousedown", e => onMouseDown(e.originalEvent)); + } + + if (onDragged) { newMarker.on("moveend", e => { const coordinates = (e.target as LeafletMarker).getLatLng(); - dragged(coordinates); + onDragged(coordinates); }); } newMarker.addTo(parentMap); return () => newMarker.removeFrom(parentMap); - }, [ parentMap, coordinates, mouseDown ]); + }, [ parentMap, coordinates, onMouseDown, onDragged ]); return (
) } diff --git a/apps/client/src/widgets/view_widgets/geo_view/markers.ts b/apps/client/src/widgets/view_widgets/geo_view/markers.ts index 8cfad222d3..f328242835 100644 --- a/apps/client/src/widgets/view_widgets/geo_view/markers.ts +++ b/apps/client/src/widgets/view_widgets/geo_view/markers.ts @@ -3,7 +3,6 @@ import type FNote from "../../../entities/fnote.js"; import openContextMenu from "./context_menu.js"; import server from "../../../services/server.js"; import { moveMarker } from "./editing.js"; -import appContext from "../../../components/app_context.js"; import L from "leaflet"; let gpxLoaded = false; @@ -13,12 +12,6 @@ export default function processNoteWithMarker(map: Map, note: FNote, location: s openContextMenu(note.noteId, e, isEditable); }); - if (!isEditable) { - newMarker.on("click", (e) => { - appContext.triggerCommand("openInPopup", { noteIdOrPath: note.noteId }); - }); - } - return newMarker; } From 189b7e20dbaaf7a45b3da08e7b6f18467de7862e Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 4 Sep 2025 21:26:09 +0300 Subject: [PATCH 041/233] chore(react/collections/geomap): bring back context menu --- apps/client/src/services/dialog.ts | 2 +- .../src/widgets/collections/geomap/api.ts | 20 ++++++++++++++++ .../geomap}/context_menu.ts | 2 +- .../src/widgets/collections/geomap/index.tsx | 6 ++++- .../src/widgets/collections/geomap/marker.tsx | 9 ++++++-- .../widgets/view_widgets/geo_view/editing.ts | 23 ------------------- .../widgets/view_widgets/geo_view/markers.ts | 8 ------- 7 files changed, 34 insertions(+), 36 deletions(-) rename apps/client/src/widgets/{view_widgets/geo_view => collections/geomap}/context_menu.ts (98%) diff --git a/apps/client/src/services/dialog.ts b/apps/client/src/services/dialog.ts index a1e54f5e8c..22efee3702 100644 --- a/apps/client/src/services/dialog.ts +++ b/apps/client/src/services/dialog.ts @@ -60,7 +60,7 @@ async function confirmDeleteNoteBoxWithNote(title: string) { return new Promise((res) => appContext.triggerCommand("showConfirmDeleteNoteBoxWithNoteDialog", { title, callback: res })); } -async function prompt(props: PromptDialogOptions) { +export async function prompt(props: PromptDialogOptions) { return new Promise((res) => appContext.triggerCommand("showPromptDialog", { ...props, callback: res })); } diff --git a/apps/client/src/widgets/collections/geomap/api.ts b/apps/client/src/widgets/collections/geomap/api.ts index 7c3b1dbbd4..d86ec50b74 100644 --- a/apps/client/src/widgets/collections/geomap/api.ts +++ b/apps/client/src/widgets/collections/geomap/api.ts @@ -1,8 +1,28 @@ import { LatLng } 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(`notes/${noteId}/children?target=into`, { + title, + content: "", + type: "text" + }); + attributes.setLabel(note.noteId, "iconClass", CHILD_NOTE_ICON); + moveMarker(note.noteId, e.latlng); + } +} diff --git a/apps/client/src/widgets/view_widgets/geo_view/context_menu.ts b/apps/client/src/widgets/collections/geomap/context_menu.ts similarity index 98% rename from apps/client/src/widgets/view_widgets/geo_view/context_menu.ts rename to apps/client/src/widgets/collections/geomap/context_menu.ts index 26d91df27c..617c4637f4 100644 --- a/apps/client/src/widgets/view_widgets/geo_view/context_menu.ts +++ b/apps/client/src/widgets/collections/geomap/context_menu.ts @@ -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"; diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index 4e6ced7a66..1b8d35834f 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -3,7 +3,7 @@ import "./index.css"; import { ViewModeProps } from "../interface"; import { useNoteLabel, useNoteLabelBoolean, useNoteProperty, useSpacedUpdate } from "../../react/hooks"; import { DEFAULT_MAP_LAYER_NAME } from "./map_layer"; -import { divIcon, LatLng } from "leaflet"; +import { divIcon, LatLng, LeafletMouseEvent } from "leaflet"; import { useCallback, useEffect, useMemo, useState } from "preact/hooks"; import Marker from "./marker"; import froca from "../../../services/froca"; @@ -12,6 +12,7 @@ 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 { moveMarker } from "./api"; +import openContextMenu from "./context_menu"; const DEFAULT_COORDINATES: [number, number] = [3.878638227135724, 446.6630455551659]; const DEFAULT_ZOOM = 2; @@ -84,6 +85,8 @@ function NoteMarker({ note, editable }: { note: FNote, editable: boolean }) { moveMarker(note.noteId, newCoordinates); }, [ note.noteId ]); + const onContextMenu = useCallback((e: LeafletMouseEvent) => openContextMenu(note.noteId, e, editable), [ note.noteId, editable ]); + return latLng && } diff --git a/apps/client/src/widgets/collections/geomap/marker.tsx b/apps/client/src/widgets/collections/geomap/marker.tsx index 009d60034f..7f9a7cda91 100644 --- a/apps/client/src/widgets/collections/geomap/marker.tsx +++ b/apps/client/src/widgets/collections/geomap/marker.tsx @@ -1,6 +1,6 @@ import { useContext, useEffect } from "preact/hooks"; import { ParentMap } from "./map"; -import { DivIcon, Icon, LatLng, Marker as LeafletMarker, marker, MarkerOptions } from "leaflet"; +import { DivIcon, Icon, LatLng, Marker as LeafletMarker, LeafletMouseEvent, marker, MarkerOptions } from "leaflet"; export interface MarkerProps { coordinates: [ number, number ]; @@ -8,10 +8,11 @@ export interface MarkerProps { 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 }: MarkerProps) { +export default function Marker({ coordinates, icon, draggable, onClick, onDragged, onMouseDown, onContextMenu }: MarkerProps) { const parentMap = useContext(ParentMap); useEffect(() => { @@ -41,6 +42,10 @@ export default function Marker({ coordinates, icon, draggable, onClick, onDragge }); } + if (onContextMenu) { + newMarker.on("contextmenu", e => onContextMenu(e)) + } + newMarker.addTo(parentMap); return () => newMarker.removeFrom(parentMap); diff --git a/apps/client/src/widgets/view_widgets/geo_view/editing.ts b/apps/client/src/widgets/view_widgets/geo_view/editing.ts index 85753f38d3..71041b50a9 100644 --- a/apps/client/src/widgets/view_widgets/geo_view/editing.ts +++ b/apps/client/src/widgets/view_widgets/geo_view/editing.ts @@ -9,29 +9,6 @@ import type { DragData } from "../../note_tree.js"; import froca from "../../../services/froca.js"; import branches from "../../../services/branches.js"; -const CHILD_NOTE_ICON = "bx bx-pin"; - -// TODO: Deduplicate -interface CreateChildResponse { - note: { - noteId: string; - }; -} - -export async function createNewNote(noteId: string, e: LeafletMouseEvent) { - 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()) { - const { note } = await server.post(`notes/${noteId}/children?target=into`, { - title, - content: "", - type: "text" - }); - attributes.setLabel(note.noteId, "iconClass", CHILD_NOTE_ICON); - moveMarker(note.noteId, e.latlng); - } -} - export function setupDragging($container: JQuery, map: Map, mapNoteId: string) { $container.on("dragover", (e) => { // Allow drag. diff --git a/apps/client/src/widgets/view_widgets/geo_view/markers.ts b/apps/client/src/widgets/view_widgets/geo_view/markers.ts index f328242835..0e8e9f4f1c 100644 --- a/apps/client/src/widgets/view_widgets/geo_view/markers.ts +++ b/apps/client/src/widgets/view_widgets/geo_view/markers.ts @@ -7,14 +7,6 @@ import L from "leaflet"; let gpxLoaded = false; -export default function processNoteWithMarker(map: Map, note: FNote, location: string, isEditable: boolean) { - newMarker.on("contextmenu", (e) => { - openContextMenu(note.noteId, e, isEditable); - }); - - return newMarker; -} - export async function processNoteWithGpxTrack(map: Map, note: FNote) { if (!gpxLoaded) { const GPX = await import("leaflet-gpx"); From 50121153dd58c30140ebd9233b9704d3b79077b8 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 4 Sep 2025 21:37:08 +0300 Subject: [PATCH 042/233] chore(react/collections/geomap): bring back adding new items --- .../src/widgets/collections/geomap/index.css | 2 +- .../src/widgets/collections/geomap/index.tsx | 44 +++++++++++++++++-- .../src/widgets/collections/geomap/map.tsx | 8 +++- .../widgets/view_widgets/geo_view/index.ts | 40 +---------------- 4 files changed, 49 insertions(+), 45 deletions(-) diff --git a/apps/client/src/widgets/collections/geomap/index.css b/apps/client/src/widgets/collections/geomap/index.css index 668962ff14..45dbf33f6b 100644 --- a/apps/client/src/widgets/collections/geomap/index.css +++ b/apps/client/src/widgets/collections/geomap/index.css @@ -18,7 +18,7 @@ z-index: 997; } -.geo-map-container.placing-note { +.geo-view.placing-note .geo-map-container { cursor: crosshair; } diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index 1b8d35834f..fee1a36bfe 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -1,7 +1,7 @@ import Map from "./map"; import "./index.css"; import { ViewModeProps } from "../interface"; -import { useNoteLabel, useNoteLabelBoolean, useNoteProperty, useSpacedUpdate } from "../../react/hooks"; +import { useNoteLabel, useNoteLabelBoolean, useNoteProperty, useSpacedUpdate, useTriliumEvent } from "../../react/hooks"; import { DEFAULT_MAP_LAYER_NAME } from "./map_layer"; import { divIcon, LatLng, LeafletMouseEvent } from "leaflet"; import { useCallback, useEffect, useMemo, useState } from "preact/hooks"; @@ -11,8 +11,10 @@ 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 { moveMarker } from "./api"; +import { createNewNote, moveMarker } from "./api"; import openContextMenu from "./context_menu"; +import toast from "../../../services/toast"; +import { t } from "../../../services/i18n"; const DEFAULT_COORDINATES: [number, number] = [3.878638227135724, 446.6630455551659]; const DEFAULT_ZOOM = 2; @@ -25,7 +27,13 @@ interface MapData { }; } +enum State { + Normal, + NewNote +} + export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewModeProps) { + const [ state, setState ] = useState(State.Normal); const [ layerName ] = useNoteLabel(note, "map:style"); const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly"); const [ notes, setNotes ] = useState([]); @@ -37,8 +45,37 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM useEffect(() => { froca.getNotes(noteIds).then(setNotes) }, [ noteIds ]); + // 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); + }); + const onClick = useCallback(async (e: LeafletMouseEvent) => { + if (state === State.NewNote) { + toast.closePersistent("geo-new-note"); + await createNewNote(note.noteId, e); + setState(State.Normal); + } + }, [ state ]); + return ( -
+
{notes.map(note => )} diff --git a/apps/client/src/widgets/collections/geomap/map.tsx b/apps/client/src/widgets/collections/geomap/map.tsx index f959606c78..1a0f642cc9 100644 --- a/apps/client/src/widgets/collections/geomap/map.tsx +++ b/apps/client/src/widgets/collections/geomap/map.tsx @@ -1,8 +1,9 @@ import { useEffect, useRef, useState } from "preact/hooks"; -import L, { LatLng, Layer } from "leaflet"; +import L, { LatLng, Layer, LeafletMouseEvent } from "leaflet"; import "leaflet/dist/leaflet.css"; import { MAP_LAYERS } from "./map_layer"; import { ComponentChildren, createContext } from "preact"; +import { map } from "jquery"; export const ParentMap = createContext(null); @@ -12,9 +13,10 @@ interface MapProps { layerName: string; viewportChanged: (coordinates: LatLng, zoom: number) => void; children: ComponentChildren; + onClick: (e: LeafletMouseEvent) => void; } -export default function Map({ coordinates, zoom, layerName, viewportChanged, children }: MapProps) { +export default function Map({ coordinates, zoom, layerName, viewportChanged, children, onClick }: MapProps) { const mapRef = useRef(null); const containerRef = useRef(null); @@ -80,6 +82,8 @@ export default function Map({ coordinates, zoom, layerName, viewportChanged, chi }; }, [ mapRef, viewportChanged ]); + useEffect(() => { mapRef.current && mapRef.current.on("click", onClick) }, [ mapRef, onClick ]); + return
{children} diff --git a/apps/client/src/widgets/view_widgets/geo_view/index.ts b/apps/client/src/widgets/view_widgets/geo_view/index.ts index 3a3a286bed..43c6f70d5f 100644 --- a/apps/client/src/widgets/view_widgets/geo_view/index.ts +++ b/apps/client/src/widgets/view_widgets/geo_view/index.ts @@ -13,10 +13,7 @@ import attributes from "../../../services/attributes.js"; import { DEFAULT_MAP_LAYER_NAME, MAP_LAYERS } from "./map_layer.js"; -enum State { - Normal, - NewNote -} + export default class GeoView extends ViewMode { @@ -38,7 +35,6 @@ export default class GeoView extends ViewMode { this.currentMarkerData = {}; this.currentTrackData = {}; - this._state = State.Normal; args.$parent.append(this.$root); } @@ -127,7 +123,6 @@ export default class GeoView extends ViewMode { #changeState(newState: State) { this._state = newState; - this.$container.toggleClass("placing-note", newState === State.NewNote); if (hasTouchBar) { this.triggerCommand("refreshTouchBar"); } @@ -153,39 +148,6 @@ export default class GeoView extends ViewMode { } } - async geoMapCreateChildNoteEvent({ ntxId }: EventData<"geoMapCreateChildNote">) { - toast.showPersistent({ - icon: "plus", - id: "geo-new-note", - title: "New note", - message: t("geo-map.create-child-note-instruction") - }); - - this.#changeState(State.NewNote); - - const globalKeyListener: (this: Window, ev: KeyboardEvent) => any = (e) => { - if (e.key !== "Escape") { - return; - } - - this.#changeState(State.Normal); - - window.removeEventListener("keydown", globalKeyListener); - toast.closePersistent("geo-new-note"); - }; - window.addEventListener("keydown", globalKeyListener); - } - - async #onMapClicked(e: LeafletMouseEvent) { - if (this._state !== State.NewNote) { - return; - } - - toast.closePersistent("geo-new-note"); - await createNewNote(this.parentNote.noteId, e); - this.#changeState(State.Normal); - } - deleteFromMapEvent({ noteId }: EventData<"deleteFromMap">) { moveMarker(noteId, null); } From dd2b718974b333e6025dd486a3753e3dbddab391 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 4 Sep 2025 21:39:50 +0300 Subject: [PATCH 043/233] chore(react/collections/geomap): bring back dark theme labels --- .../client/src/widgets/collections/geomap/map.tsx | 15 ++++++++++----- .../src/widgets/view_widgets/geo_view/index.ts | 15 --------------- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/apps/client/src/widgets/collections/geomap/map.tsx b/apps/client/src/widgets/collections/geomap/map.tsx index 1a0f642cc9..8fe1022108 100644 --- a/apps/client/src/widgets/collections/geomap/map.tsx +++ b/apps/client/src/widgets/collections/geomap/map.tsx @@ -84,9 +84,14 @@ export default function Map({ coordinates, zoom, layerName, viewportChanged, chi useEffect(() => { mapRef.current && mapRef.current.on("click", onClick) }, [ mapRef, onClick ]); - return
- - {children} - -
; + return ( +
+ + {children} + +
+ ); } diff --git a/apps/client/src/widgets/view_widgets/geo_view/index.ts b/apps/client/src/widgets/view_widgets/geo_view/index.ts index 43c6f70d5f..e663a449df 100644 --- a/apps/client/src/widgets/view_widgets/geo_view/index.ts +++ b/apps/client/src/widgets/view_widgets/geo_view/index.ts @@ -39,26 +39,12 @@ export default class GeoView extends ViewMode { args.$parent.append(this.$root); } - async renderList() { - this.renderMap(); - return this.$root; - } - async renderMap() { const layerName = this.parentNote.getLabelValue("map:style") ?? ; - if (this.parentNote.hasLabel("map:scale")) { L.control.scale().addTo(map); } - - this.$container.toggleClass("dark", !!layerData.isDarkTheme); - - layer.addTo(map); - - this.map = map; - - this.#onMapInitialized(); } async #onMapInitialized() { @@ -68,7 +54,6 @@ export default class GeoView extends ViewMode { } const isEditable = !this.isReadOnly; - map.on("click", (e) => this.#onMapClicked(e)) map.on("contextmenu", (e) => openMapContextMenu(this.parentNote.noteId, e, isEditable)); if (isEditable) { From 3b66522a5ee94741b4fa3b030bc510e2426bfccf Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 4 Sep 2025 21:46:05 +0300 Subject: [PATCH 044/233] chore(react/collections/geomap): bring back map context menu --- .../src/widgets/collections/geomap/index.tsx | 8 +++++++- .../src/widgets/collections/geomap/map.tsx | 19 ++++++++++++++++--- .../widgets/view_widgets/geo_view/index.ts | 1 - 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index fee1a36bfe..430f566947 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -12,7 +12,7 @@ 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 from "./context_menu"; +import openContextMenu, { openMapContextMenu } from "./context_menu"; import toast from "../../../services/toast"; import { t } from "../../../services/i18n"; @@ -66,6 +66,7 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM }; window.addEventListener("keydown", globalKeyListener); }); + const onClick = useCallback(async (e: LeafletMouseEvent) => { if (state === State.NewNote) { toast.closePersistent("geo-new-note"); @@ -74,6 +75,10 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM } }, [ state ]); + const onContextMenu = useCallback((e: LeafletMouseEvent) => { + openMapContextMenu(note.noteId, e, !isReadOnly); + }, [ note.noteId, isReadOnly ]); + return (
{notes.map(note => )} diff --git a/apps/client/src/widgets/collections/geomap/map.tsx b/apps/client/src/widgets/collections/geomap/map.tsx index 8fe1022108..bb4f22c03e 100644 --- a/apps/client/src/widgets/collections/geomap/map.tsx +++ b/apps/client/src/widgets/collections/geomap/map.tsx @@ -13,10 +13,11 @@ interface MapProps { layerName: string; viewportChanged: (coordinates: LatLng, zoom: number) => void; children: ComponentChildren; - onClick: (e: LeafletMouseEvent) => void; + onClick?: (e: LeafletMouseEvent) => void; + onContextMenu?: (e: LeafletMouseEvent) => void; } -export default function Map({ coordinates, zoom, layerName, viewportChanged, children, onClick }: MapProps) { +export default function Map({ coordinates, zoom, layerName, viewportChanged, children, onClick, onContextMenu }: MapProps) { const mapRef = useRef(null); const containerRef = useRef(null); @@ -82,7 +83,19 @@ export default function Map({ coordinates, zoom, layerName, viewportChanged, chi }; }, [ mapRef, viewportChanged ]); - useEffect(() => { mapRef.current && mapRef.current.on("click", onClick) }, [ mapRef, onClick ]); + 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 ]); return (
{ } const isEditable = !this.isReadOnly; - map.on("contextmenu", (e) => openMapContextMenu(this.parentNote.noteId, e, isEditable)); if (isEditable) { setupDragging(this.$container, map, this.parentNote.noteId); From 8bb8e011f330776a8705d6b4b068d4b539a0fad3 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 4 Sep 2025 21:50:45 +0300 Subject: [PATCH 045/233] chore(react/collections/geomap): properly dispose --- apps/client/src/widgets/collections/geomap/map.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/client/src/widgets/collections/geomap/map.tsx b/apps/client/src/widgets/collections/geomap/map.tsx index bb4f22c03e..6e1cb57f1d 100644 --- a/apps/client/src/widgets/collections/geomap/map.tsx +++ b/apps/client/src/widgets/collections/geomap/map.tsx @@ -23,9 +23,15 @@ export default function Map({ coordinates, zoom, layerName, viewportChanged, chi useEffect(() => { if (!containerRef.current) return; - mapRef.current = L.map(containerRef.current, { + const mapInstance = L.map(containerRef.current, { worldCopyJump: true }); + + mapRef.current = mapInstance; + return () => { + mapInstance.off(); + mapInstance.remove(); + }; }, []); // Load the layer asynchronously. From 9adf9a841caebe5cd48a1d841486a87cc1dcaa30 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 4 Sep 2025 21:52:41 +0300 Subject: [PATCH 046/233] chore(react/collections/geomap): bring back remove from map --- apps/client/src/widgets/collections/geomap/index.tsx | 4 ++++ apps/client/src/widgets/view_widgets/geo_view/index.ts | 4 ---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index 430f566947..a97cac980c 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -67,6 +67,10 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM 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"); diff --git a/apps/client/src/widgets/view_widgets/geo_view/index.ts b/apps/client/src/widgets/view_widgets/geo_view/index.ts index df87bd6697..16a36c8832 100644 --- a/apps/client/src/widgets/view_widgets/geo_view/index.ts +++ b/apps/client/src/widgets/view_widgets/geo_view/index.ts @@ -132,10 +132,6 @@ export default class GeoView extends ViewMode { } } - deleteFromMapEvent({ noteId }: EventData<"deleteFromMap">) { - moveMarker(noteId, null); - } - buildTouchBarCommand({ TouchBar }: CommandListenerData<"buildTouchBar">) { const map = this.map; const that = this; From ec378a8fc58748bcd034d73f074a2c44e0e2e02e Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 4 Sep 2025 22:05:44 +0300 Subject: [PATCH 047/233] refactor(react/collections): reintroduce scale --- .../src/widgets/collections/geomap/index.tsx | 2 ++ .../client/src/widgets/collections/geomap/map.tsx | 15 ++++++++++++--- .../src/widgets/view_widgets/geo_view/index.ts | 8 -------- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index a97cac980c..cc17d56914 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -35,6 +35,7 @@ enum State { export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewModeProps) { const [ state, setState ] = useState(State.Normal); const [ layerName ] = useNoteLabel(note, "map:style"); + const [ hasScale ] = useNoteLabelBoolean(note, "map:scale"); const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly"); const [ notes, setNotes ] = useState([]); const spacedUpdate = useSpacedUpdate(() => { @@ -96,6 +97,7 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM }} onClick={onClick} onContextMenu={onContextMenu} + scale={hasScale} > {notes.map(note => )} diff --git a/apps/client/src/widgets/collections/geomap/map.tsx b/apps/client/src/widgets/collections/geomap/map.tsx index 6e1cb57f1d..99372d994c 100644 --- a/apps/client/src/widgets/collections/geomap/map.tsx +++ b/apps/client/src/widgets/collections/geomap/map.tsx @@ -1,9 +1,8 @@ import { useEffect, useRef, useState } from "preact/hooks"; -import L, { LatLng, Layer, LeafletMouseEvent } from "leaflet"; +import L, { control, LatLng, Layer, LeafletMouseEvent } from "leaflet"; import "leaflet/dist/leaflet.css"; import { MAP_LAYERS } from "./map_layer"; import { ComponentChildren, createContext } from "preact"; -import { map } from "jquery"; export const ParentMap = createContext(null); @@ -15,9 +14,10 @@ interface MapProps { children: ComponentChildren; onClick?: (e: LeafletMouseEvent) => void; onContextMenu?: (e: LeafletMouseEvent) => void; + scale: boolean; } -export default function Map({ coordinates, zoom, layerName, viewportChanged, children, onClick, onContextMenu }: MapProps) { +export default function Map({ coordinates, zoom, layerName, viewportChanged, children, onClick, onContextMenu, scale }: MapProps) { const mapRef = useRef(null); const containerRef = useRef(null); @@ -103,6 +103,15 @@ export default function Map({ coordinates, zoom, layerName, viewportChanged, chi } }, [ mapRef, onContextMenu ]); + // Scale + useEffect(() => { + const map = mapRef.current; + if (!scale || !map) return; + const scaleControl = control.scale(); + scaleControl.addTo(map); + return () => scaleControl.remove(); + }, [ mapRef, scale ]); + return (
{ args.$parent.append(this.$root); } - async renderMap() { - const layerName = this.parentNote.getLabelValue("map:style") ?? ; - - if (this.parentNote.hasLabel("map:scale")) { - L.control.scale().addTo(map); - } - } - async #onMapInitialized() { const map = this.map; if (!map) { From b25f3094b7a6a4bb3d1a4a262c9bbdca7efa8317 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 4 Sep 2025 22:48:00 +0300 Subject: [PATCH 048/233] refactor(react/collections): reintroduce gpx tracks --- .../src/widgets/collections/geomap/index.tsx | 46 +++++++++++++++++-- .../src/widgets/collections/geomap/marker.tsx | 18 +++++++- .../widgets/view_widgets/geo_view/index.ts | 12 ----- .../widgets/view_widgets/geo_view/markers.ts | 38 --------------- 4 files changed, 59 insertions(+), 55 deletions(-) delete mode 100644 apps/client/src/widgets/view_widgets/geo_view/markers.ts diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index cc17d56914..4651abddb2 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -1,11 +1,11 @@ import Map from "./map"; import "./index.css"; import { ViewModeProps } from "../interface"; -import { useNoteLabel, useNoteLabelBoolean, useNoteProperty, useSpacedUpdate, useTriliumEvent } from "../../react/hooks"; +import { useNoteBlob, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useSpacedUpdate, useTriliumEvent } from "../../react/hooks"; import { DEFAULT_MAP_LAYER_NAME } from "./map_layer"; -import { divIcon, LatLng, LeafletMouseEvent } from "leaflet"; +import { divIcon, GPXOptions, LatLng, LeafletMouseEvent } from "leaflet"; import { useCallback, useEffect, useMemo, useState } from "preact/hooks"; -import Marker from "./marker"; +import Marker, { GpxTrack } from "./marker"; import froca from "../../../services/froca"; import FNote from "../../../entities/fnote"; import markerIcon from "leaflet/dist/images/marker-icon.png"; @@ -15,6 +15,7 @@ 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"; const DEFAULT_COORDINATES: [number, number] = [3.878638227135724, 446.6630455551659]; const DEFAULT_ZOOM = 2; @@ -99,7 +100,11 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM onContextMenu={onContextMenu} scale={hasScale} > - {notes.map(note => )} + {notes.map(note => ( + note.mime !== "application/gpx+xml" + ? + : + ))}
); @@ -148,6 +153,39 @@ function NoteMarker({ note, editable }: { note: FNote, editable: boolean }) { /> } +function NoteGpxTrack({ note }: { note: FNote }) { + const [ xmlString, setXmlString ] = useState(); + const blob = useNoteBlob(note); + + useEffect(() => { + server.get(`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(() => ({ + 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 && +} + function buildIcon(bxIconClass: string, colorClass?: string, title?: string, noteIdLink?: string) { let html = /*html*/`\ diff --git a/apps/client/src/widgets/collections/geomap/marker.tsx b/apps/client/src/widgets/collections/geomap/marker.tsx index 7f9a7cda91..2a2142d1c2 100644 --- a/apps/client/src/widgets/collections/geomap/marker.tsx +++ b/apps/client/src/widgets/collections/geomap/marker.tsx @@ -1,6 +1,7 @@ import { useContext, useEffect } from "preact/hooks"; import { ParentMap } from "./map"; -import { DivIcon, Icon, LatLng, Marker as LeafletMarker, LeafletMouseEvent, marker, MarkerOptions } from "leaflet"; +import { DivIcon, GPX, GPXOptions, Icon, LatLng, Marker as LeafletMarker, LeafletMouseEvent, marker, MarkerOptions } from "leaflet"; +import "leaflet-gpx"; export interface MarkerProps { coordinates: [ number, number ]; @@ -53,3 +54,18 @@ export default function Marker({ coordinates, icon, draggable, onClick, onDragge return (
) } + +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
; +} diff --git a/apps/client/src/widgets/view_widgets/geo_view/index.ts b/apps/client/src/widgets/view_widgets/geo_view/index.ts index a9bf023f37..46f535afe2 100644 --- a/apps/client/src/widgets/view_widgets/geo_view/index.ts +++ b/apps/client/src/widgets/view_widgets/geo_view/index.ts @@ -83,18 +83,6 @@ export default class GeoView extends ViewMode { this.currentMarkerData = {}; const notes = await this.parentNote.getSubtreeNotes(); const draggable = !this.isReadOnly; - for (const childNote of notes) { - if (childNote.mime === "application/gpx+xml") { - const track = await processNoteWithGpxTrack(this.map, childNote); - this.currentTrackData[childNote.noteId] = track; - continue; - } - - if (latLng) { - const marker = processNoteWithMarker(this.map, childNote, latLng, draggable); - this.currentMarkerData[childNote.noteId] = marker; - } - } } #changeState(newState: State) { diff --git a/apps/client/src/widgets/view_widgets/geo_view/markers.ts b/apps/client/src/widgets/view_widgets/geo_view/markers.ts deleted file mode 100644 index 0e8e9f4f1c..0000000000 --- a/apps/client/src/widgets/view_widgets/geo_view/markers.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { marker, latLng, divIcon, Map, type Marker } from "leaflet"; -import type FNote from "../../../entities/fnote.js"; -import openContextMenu from "./context_menu.js"; -import server from "../../../services/server.js"; -import { moveMarker } from "./editing.js"; -import L from "leaflet"; - -let gpxLoaded = false; - -export async function processNoteWithGpxTrack(map: Map, note: FNote) { - if (!gpxLoaded) { - const GPX = await import("leaflet-gpx"); - gpxLoaded = true; - } - - const xmlResponse = await server.get(`notes/${note.noteId}/open`, undefined, true); - let stringResponse: string; - if (xmlResponse instanceof Uint8Array) { - stringResponse = new TextDecoder().decode(xmlResponse); - } else { - stringResponse = xmlResponse; - } - - const track = new L.GPX(stringResponse, { - 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" - } - }); - track.addTo(map); - return track; -} From 9444195de73cf2028695c7dd8c7efbdce145b0e2 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 4 Sep 2025 23:35:18 +0300 Subject: [PATCH 049/233] chore(react/collections): set up dragging (partially) --- .../src/widgets/collections/geomap/index.tsx | 30 +++++++++++++- .../src/widgets/collections/geomap/map.tsx | 11 ++++-- apps/client/src/widgets/react/hooks.tsx | 39 ++++++++++++++++++- .../widgets/view_widgets/geo_view/editing.ts | 32 --------------- 4 files changed, 72 insertions(+), 40 deletions(-) diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index 4651abddb2..335a55825b 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -1,10 +1,10 @@ import Map from "./map"; import "./index.css"; import { ViewModeProps } from "../interface"; -import { useNoteBlob, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useSpacedUpdate, useTriliumEvent } from "../../react/hooks"; +import { useNoteBlob, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useNoteTreeDrag, useSpacedUpdate, useTriliumEvent } from "../../react/hooks"; import { DEFAULT_MAP_LAYER_NAME } from "./map_layer"; import { divIcon, GPXOptions, LatLng, LeafletMouseEvent } from "leaflet"; -import { useCallback, useEffect, useMemo, useState } from "preact/hooks"; +import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; import Marker, { GpxTrack } from "./marker"; import froca from "../../../services/froca"; import FNote from "../../../entities/fnote"; @@ -16,6 +16,7 @@ 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"; const DEFAULT_COORDINATES: [number, number] = [3.878638227135724, 446.6630455551659]; const DEFAULT_ZOOM = 2; @@ -85,9 +86,34 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM openMapContextMenu(note.noteId, e, !isReadOnly); }, [ note.noteId, isReadOnly ]); + // Dragging + const containerRef = useRef(null); + const apiRef = useRef(null); + useNoteTreeDrag(containerRef, async (treeData, e) => { + const api = apiRef.current; + if (!note || !api) 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 (
(null); interface MapProps { + apiRef?: RefObject; + containerRef?: RefObject; coordinates: LatLng | [number, number]; zoom: number; layerName: string; @@ -17,9 +20,9 @@ interface MapProps { scale: boolean; } -export default function Map({ coordinates, zoom, layerName, viewportChanged, children, onClick, onContextMenu, scale }: MapProps) { - const mapRef = useRef(null); - const containerRef = useRef(null); +export default function Map({ coordinates, zoom, layerName, viewportChanged, children, onClick, onContextMenu, scale, apiRef: _apiRef, containerRef: _containerRef }: MapProps) { + const mapRef = useSyncedRef(_apiRef); + const containerRef = useSyncedRef(_containerRef); useEffect(() => { if (!containerRef.current) return; diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 6b1d7af68a..cc4eb64fba 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -1,4 +1,4 @@ -import { useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; +import { MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; import { EventData, EventNames } from "../../components/app_context"; import { ParentComponent } from "./react_utils"; import SpacedUpdate from "../../services/spaced_update"; @@ -13,9 +13,10 @@ import FBlob from "../../entities/fblob"; import NoteContextAwareWidget from "../note_context_aware_widget"; import { RefObject, VNode } from "preact"; import { Tooltip } from "bootstrap"; -import { CSSProperties } from "preact/compat"; +import { CSSProperties, DragEventHandler } from "preact/compat"; import keyboard_actions from "../../services/keyboard_actions"; import Mark from "mark.js"; +import { DragData } from "../note_tree"; export function useTriliumEvent(eventName: T, handler: (data: EventData) => void) { const parentComponent = useContext(ParentComponent); @@ -576,3 +577,37 @@ export function useImperativeSearchHighlighlighting(highlightedTokens: string[] }); }; } + +export function useNoteTreeDrag(containerRef: MutableRef, callback: (data: DragData[], e: DragEvent) => void) { + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + function onDragOver(e: DragEvent) { + // Allow drag. + e.preventDefault(); + } + + function onDrop(e: DragEvent) { + const data = e.dataTransfer?.getData('text'); + if (!data) { + return; + } + + const parsedData = JSON.parse(data) as DragData[]; + if (!parsedData.length) { + return; + } + + callback(parsedData, e); + } + + container.addEventListener("dragover", onDragOver); + container.addEventListener("drop", onDrop); + + return () => { + container.removeEventListener("dragover", onDragOver); + container.removeEventListener("drop", onDrop); + }; + }, [ containerRef, callback ]); +} diff --git a/apps/client/src/widgets/view_widgets/geo_view/editing.ts b/apps/client/src/widgets/view_widgets/geo_view/editing.ts index 71041b50a9..16d3f435d7 100644 --- a/apps/client/src/widgets/view_widgets/geo_view/editing.ts +++ b/apps/client/src/widgets/view_widgets/geo_view/editing.ts @@ -10,41 +10,9 @@ import froca from "../../../services/froca.js"; import branches from "../../../services/branches.js"; export function setupDragging($container: JQuery, map: Map, mapNoteId: string) { - $container.on("dragover", (e) => { - // Allow drag. - e.preventDefault(); - }); $container.on("drop", async (e) => { - if (!e.originalEvent) { - return; - } - - const data = e.originalEvent.dataTransfer?.getData('text'); - if (!data) { - return; - } - try { - const parsedData = JSON.parse(data) as DragData[]; - if (!parsedData.length) { - return; - } - const { noteId } = parsedData[0]; - - const offset = $container.offset(); - const x = e.originalEvent.clientX - (offset?.left ?? 0); - const y = e.originalEvent.clientY - (offset?.top ?? 0); - const latlng = map.containerPointToLatLng([ x, y ]); - - const note = await froca.getNote(noteId, true); - const parents = note?.getParentNoteIds(); - if (parents?.includes(mapNoteId)) { - await moveMarker(noteId, latlng); - } else { - await branches.cloneNoteToParentNote(noteId, mapNoteId); - await moveMarker(noteId, latlng); - } } catch (e) { console.warn(e); } From d3c66714c29090a9f505b612ac6a0636043c62ab Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 5 Sep 2025 08:48:24 +0300 Subject: [PATCH 050/233] fix(react/collections/geomap): crash for notes without location --- .../src/widgets/collections/geomap/index.tsx | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index 335a55825b..16a2a9662c 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -46,6 +46,8 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM } }, 5000); + console.log("Got new notes IDs ", noteIds); + console.log("Got notes ", notes); useEffect(() => { froca.getNotes(noteIds).then(setNotes) }, [ noteIds ]); // Note creation. @@ -126,19 +128,30 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM onContextMenu={onContextMenu} scale={hasScale} > - {notes.map(note => ( - note.mime !== "application/gpx+xml" - ? - : - ))} + {notes.map(note => )}
); } -function NoteMarker({ note, editable }: { note: FNote, editable: boolean }) { +function NoteWrapper({ note, isReadOnly }: { note: FNote, isReadOnly: boolean }) { + const mime = useNoteProperty(note, "mime"); const [ location ] = useNoteLabel(note, LOCATION_ATTRIBUTE); + console.log("Got ", note, mime); + + if (mime === "application/gpx+xml") { + return ; + } + + if (location) { + const latLng = location?.split(",", 2).map((el) => parseFloat(el)) as [ number, number ] | undefined; + if (!latLng) return; + return ; + } +} + +function NoteMarker({ note, editable, latLng }: { note: FNote, editable: boolean, latLng: [number, number] }) { // React to changes useNoteLabel(note, "color"); useNoteLabel(note, "iconClass"); @@ -146,7 +159,6 @@ function NoteMarker({ note, editable }: { note: FNote, editable: boolean }) { const title = useNoteProperty(note, "title"); const colorClass = note.getColorClass(); const iconClass = note.getIcon(); - const latLng = location?.split(",", 2).map((el) => parseFloat(el)) as [ number, number ] | undefined; const icon = useMemo(() => buildIcon(iconClass, colorClass ?? undefined, title, note.noteId), [ iconClass, colorClass, title, note.noteId]); const onClick = useCallback(() => { @@ -168,6 +180,7 @@ function NoteMarker({ note, editable }: { note: FNote, editable: boolean }) { const onContextMenu = useCallback((e: LeafletMouseEvent) => openContextMenu(note.noteId, e, editable), [ note.noteId, editable ]); + console.log("Got ", latLng); return latLng && Date: Fri, 5 Sep 2025 10:32:26 +0300 Subject: [PATCH 051/233] fix(react/collections/geomap): drag not always working --- .../src/widgets/collections/geomap/index.tsx | 4 ++-- .../src/widgets/collections/geomap/map.tsx | 21 +++++++++++++++---- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index 16a2a9662c..49677f622a 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -1,4 +1,4 @@ -import Map from "./map"; +import Map, { MapApi } from "./map"; import "./index.css"; import { ViewModeProps } from "../interface"; import { useNoteBlob, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useNoteTreeDrag, useSpacedUpdate, useTriliumEvent } from "../../react/hooks"; @@ -90,7 +90,7 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM // Dragging const containerRef = useRef(null); - const apiRef = useRef(null); + const apiRef = useRef(null); useNoteTreeDrag(containerRef, async (treeData, e) => { const api = apiRef.current; if (!note || !api) return; diff --git a/apps/client/src/widgets/collections/geomap/map.tsx b/apps/client/src/widgets/collections/geomap/map.tsx index c558482413..8a7afb7817 100644 --- a/apps/client/src/widgets/collections/geomap/map.tsx +++ b/apps/client/src/widgets/collections/geomap/map.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "preact/hooks"; +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"; @@ -8,7 +8,7 @@ import { useSyncedRef } from "../../react/hooks"; export const ParentMap = createContext(null); interface MapProps { - apiRef?: RefObject; + apiRef?: RefObject; containerRef?: RefObject; coordinates: LatLng | [number, number]; zoom: number; @@ -20,10 +20,23 @@ interface MapProps { scale: boolean; } -export default function Map({ coordinates, zoom, layerName, viewportChanged, children, onClick, onContextMenu, scale, apiRef: _apiRef, containerRef: _containerRef }: MapProps) { - const mapRef = useSyncedRef(_apiRef); +export interface MapApi { + containerPointToLatLng: L.Map["containerPointToLatLng"]; +} + +export default function Map({ coordinates, zoom, layerName, viewportChanged, children, onClick, onContextMenu, scale, apiRef, containerRef: _containerRef }: MapProps) { + const mapRef = useRef(null); const containerRef = useSyncedRef(_containerRef); + useImperativeHandle(apiRef ?? null, () => { + const map = mapRef.current; + if (!map) return null; + + return { + containerPointToLatLng: (point) => map.containerPointToLatLng(point) + } satisfies MapApi; + }); + useEffect(() => { if (!containerRef.current) return; const mapInstance = L.map(containerRef.current, { From cb53ff880ded3ba28b2033d39110298fca4789fa Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 5 Sep 2025 11:04:36 +0300 Subject: [PATCH 052/233] chore(react/collections/geomap): clean up --- .../src/widgets/collections/geomap/index.tsx | 2 +- .../widgets/view_widgets/geo_view/editing.ts | 20 ----- .../widgets/view_widgets/geo_view/index.ts | 75 ------------------- 3 files changed, 1 insertion(+), 96 deletions(-) delete mode 100644 apps/client/src/widgets/view_widgets/geo_view/editing.ts diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index 49677f622a..df04a12257 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -93,7 +93,7 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM const apiRef = useRef(null); useNoteTreeDrag(containerRef, async (treeData, e) => { const api = apiRef.current; - if (!note || !api) return; + if (!note || !api || isReadOnly) return; const { noteId } = treeData[0]; diff --git a/apps/client/src/widgets/view_widgets/geo_view/editing.ts b/apps/client/src/widgets/view_widgets/geo_view/editing.ts deleted file mode 100644 index 16d3f435d7..0000000000 --- a/apps/client/src/widgets/view_widgets/geo_view/editing.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { LatLng, LeafletMouseEvent } from "leaflet"; -import attributes from "../../../services/attributes"; -import { LOCATION_ATTRIBUTE } from "./index.js"; -import dialog from "../../../services/dialog"; -import server from "../../../services/server"; -import { t } from "../../../services/i18n"; -import type { Map } from "leaflet"; -import type { DragData } from "../../note_tree.js"; -import froca from "../../../services/froca.js"; -import branches from "../../../services/branches.js"; - -export function setupDragging($container: JQuery, map: Map, mapNoteId: string) { - $container.on("drop", async (e) => { - try { - - } catch (e) { - console.warn(e); - } - }); -} diff --git a/apps/client/src/widgets/view_widgets/geo_view/index.ts b/apps/client/src/widgets/view_widgets/geo_view/index.ts index 46f535afe2..4786748c2c 100644 --- a/apps/client/src/widgets/view_widgets/geo_view/index.ts +++ b/apps/client/src/widgets/view_widgets/geo_view/index.ts @@ -17,41 +17,7 @@ import { DEFAULT_MAP_LAYER_NAME, MAP_LAYERS } from "./map_layer.js"; export default class GeoView extends ViewMode { - private $root: JQuery; - private $container!: JQuery; - private map?: Map; - private spacedUpdate: SpacedUpdate; - private _state: State; - private ignoreNextZoomEvent?: boolean; - - private currentMarkerData: Record; - private currentTrackData: Record; - - constructor(args: ViewModeArgs) { - super(args, "geoMap"); - this.$root = $(TPL); - this.$container = this.$root.find(".geo-map-container"); - this.spacedUpdate = new SpacedUpdate(() => this.onSave(), 5_000); - - this.currentMarkerData = {}; - this.currentTrackData = {}; - - args.$parent.append(this.$root); - } - async #onMapInitialized() { - const map = this.map; - if (!map) { - throw new Error(t("geo-map.unable-to-load-map")); - } - - const isEditable = !this.isReadOnly; - - if (isEditable) { - setupDragging(this.$container, map, this.parentNote.noteId); - } - - this.#reloadMarkers(); if (hasTouchBar) { map.on("zoom", () => { @@ -64,27 +30,6 @@ export default class GeoView extends ViewMode { } } - async #reloadMarkers() { - if (!this.map) { - return; - } - - // Delete all existing markers - for (const marker of Object.values(this.currentMarkerData)) { - marker.remove(); - } - - // Delete all existing tracks - for (const track of Object.values(this.currentTrackData)) { - track.remove(); - } - - // Add the new markers. - this.currentMarkerData = {}; - const notes = await this.parentNote.getSubtreeNotes(); - const draggable = !this.isReadOnly; - } - #changeState(newState: State) { this._state = newState; if (hasTouchBar) { @@ -92,26 +37,6 @@ export default class GeoView extends ViewMode { } } - async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) { - // If any of the children branches are altered. - if (loadResults.getBranchRows().find((branch) => branch.parentNoteId === this.parentNote.noteId)) { - this.#reloadMarkers(); - return; - } - - // If any of note has its location attribute changed. - // TODO: Should probably filter by parent here as well. - const attributeRows = loadResults.getAttributeRows(); - if (attributeRows.find((at) => [LOCATION_ATTRIBUTE, "color", "iconClass"].includes(at.name ?? ""))) { - this.#reloadMarkers(); - } - - // Full reload if map layer is changed. - if (loadResults.getAttributeRows().some(attr => (attr.name?.startsWith("map:") && attributes.isAffecting(attr, this.parentNote)))) { - return true; - } - } - buildTouchBarCommand({ TouchBar }: CommandListenerData<"buildTouchBar">) { const map = this.map; const that = this; From c79dd43105d2788993d0cf76f0be3878daa6a46d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 5 Sep 2025 11:54:58 +0300 Subject: [PATCH 053/233] chore(react/collections): bring back touch bar --- apps/client/src/components/app_context.ts | 2 +- apps/client/src/components/touch_bar.ts | 6 +- .../src/widgets/collections/geomap/index.tsx | 32 ++++++++- .../src/widgets/collections/geomap/map.tsx | 23 +++---- apps/client/src/widgets/react/hooks.tsx | 25 ++++++- .../widgets/view_widgets/geo_view/index.ts | 67 ------------------- 6 files changed, 67 insertions(+), 88 deletions(-) delete mode 100644 apps/client/src/widgets/view_widgets/geo_view/index.ts diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index d888eba6f2..cba5c91af3 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -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)) { diff --git a/apps/client/src/components/touch_bar.ts b/apps/client/src/components/touch_bar.ts index 7bf10d7f12..226318a927 100644 --- a/apps/client/src/components/touch_bar.ts +++ b/apps/client/src/components/touch_bar.ts @@ -23,11 +23,11 @@ export default class TouchBarComponent extends Component { this.$widget = $("
"); $(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(); }); } diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index df04a12257..43dbc4a7f8 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -1,7 +1,7 @@ import Map, { MapApi } from "./map"; import "./index.css"; import { ViewModeProps } from "../interface"; -import { useNoteBlob, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useNoteTreeDrag, useSpacedUpdate, useTriliumEvent } from "../../react/hooks"; +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, useEffect, useMemo, useRef, useState } from "preact/hooks"; @@ -17,6 +17,7 @@ import toast from "../../../services/toast"; import { t } from "../../../services/i18n"; import server from "../../../services/server"; import branches from "../../../services/branches"; +import { hasTouchBar } from "../../../services/utils"; const DEFAULT_COORDINATES: [number, number] = [3.878638227135724, 446.6630455551659]; const DEFAULT_ZOOM = 2; @@ -90,7 +91,7 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM // Dragging const containerRef = useRef(null); - const apiRef = useRef(null); + const apiRef = useRef(null); useNoteTreeDrag(containerRef, async (treeData, e) => { const api = apiRef.current; if (!note || !api || isReadOnly) return; @@ -112,6 +113,32 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM } }); + // Touch bar. + const [ zoomLevel, setZoomLevel ] = useState(); + const onZoom = useCallback(() => { + if (!apiRef.current) return; + setZoomLevel(apiRef.current.getZoom()); + }, []); + useTouchBar(({ TouchBar, parentComponent }) => { + const map = apiRef.current; + if (!note || !map) return; + + return [ + new TouchBar.TouchBarSlider({ + label: "Zoom", + value: zoomLevel ?? map.getZoom(), + minValue: map.getMinZoom(), + maxValue: map.getMaxZoom(), + change: (newValue) => map.setZoom(newValue) + }), + new TouchBar.TouchBarButton({ + label: "New geo note", + click: () => parentComponent?.triggerCommand("geoMapCreateChildNote"), + enabled: (state === State.Normal) + }) + ]; + }, [ zoomLevel, state ]); + return (
{notes.map(note => )} diff --git a/apps/client/src/widgets/collections/geomap/map.tsx b/apps/client/src/widgets/collections/geomap/map.tsx index 8a7afb7817..85c7b9b340 100644 --- a/apps/client/src/widgets/collections/geomap/map.tsx +++ b/apps/client/src/widgets/collections/geomap/map.tsx @@ -17,25 +17,15 @@ interface MapProps { children: ComponentChildren; onClick?: (e: LeafletMouseEvent) => void; onContextMenu?: (e: LeafletMouseEvent) => void; + onZoom?: () => void; scale: boolean; } -export interface MapApi { - containerPointToLatLng: L.Map["containerPointToLatLng"]; -} - -export default function Map({ coordinates, zoom, layerName, viewportChanged, children, onClick, onContextMenu, scale, apiRef, containerRef: _containerRef }: MapProps) { +export default function Map({ coordinates, zoom, layerName, viewportChanged, children, onClick, onContextMenu, scale, apiRef, containerRef: _containerRef, onZoom }: MapProps) { const mapRef = useRef(null); const containerRef = useSyncedRef(_containerRef); - useImperativeHandle(apiRef ?? null, () => { - const map = mapRef.current; - if (!map) return null; - - return { - containerPointToLatLng: (point) => map.containerPointToLatLng(point) - } satisfies MapApi; - }); + useImperativeHandle(apiRef ?? null, () => mapRef.current); useEffect(() => { if (!containerRef.current) return; @@ -119,6 +109,13 @@ export default function Map({ coordinates, zoom, layerName, viewportChanged, chi } }, [ 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; diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index cc4eb64fba..bc1c146892 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -1,5 +1,5 @@ -import { MutableRef, 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"; @@ -17,6 +17,7 @@ import { CSSProperties, DragEventHandler } 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"; export function useTriliumEvent(eventName: T, handler: (data: EventData) => void) { const parentComponent = useContext(ParentComponent); @@ -611,3 +612,23 @@ export function useNoteTreeDrag(containerRef: MutableRef & { parentComponent: Component | null }) => void, + inputs: Inputs +) { + const parentComponent = useContext(ParentComponent); + + useLegacyImperativeHandlers({ + buildTouchBarCommand(context: CommandListenerData<"buildTouchBar">) { + return factory({ + ...context, + parentComponent + }); + } + }); + + useEffect(() => { + parentComponent?.triggerCommand("refreshTouchBar"); + }, inputs); +} diff --git a/apps/client/src/widgets/view_widgets/geo_view/index.ts b/apps/client/src/widgets/view_widgets/geo_view/index.ts deleted file mode 100644 index 4786748c2c..0000000000 --- a/apps/client/src/widgets/view_widgets/geo_view/index.ts +++ /dev/null @@ -1,67 +0,0 @@ -import ViewMode, { ViewModeArgs } from "../view_mode.js"; -import L from "leaflet"; -import type { GPX, LatLng, Layer, LeafletMouseEvent, Map, Marker } from "leaflet"; -import SpacedUpdate from "../../../services/spaced_update.js"; -import { t } from "../../../services/i18n.js"; -import processNoteWithMarker, { processNoteWithGpxTrack } from "./markers.js"; -import { hasTouchBar } from "../../../services/utils.js"; -import toast from "../../../services/toast.js"; -import { CommandListenerData, EventData } from "../../../components/app_context.js"; -import { createNewNote, moveMarker, setupDragging } from "./editing.js"; -import { openMapContextMenu } from "./context_menu.js"; -import attributes from "../../../services/attributes.js"; -import { DEFAULT_MAP_LAYER_NAME, MAP_LAYERS } from "./map_layer.js"; - - - - -export default class GeoView extends ViewMode { - - async #onMapInitialized() { - - if (hasTouchBar) { - map.on("zoom", () => { - if (!this.ignoreNextZoomEvent) { - this.triggerCommand("refreshTouchBar"); - } - - this.ignoreNextZoomEvent = false; - }); - } - } - - #changeState(newState: State) { - this._state = newState; - if (hasTouchBar) { - this.triggerCommand("refreshTouchBar"); - } - } - - buildTouchBarCommand({ TouchBar }: CommandListenerData<"buildTouchBar">) { - const map = this.map; - const that = this; - if (!map) { - return; - } - - return [ - new TouchBar.TouchBarSlider({ - label: "Zoom", - value: map.getZoom(), - minValue: map.getMinZoom(), - maxValue: map.getMaxZoom(), - change(newValue) { - that.ignoreNextZoomEvent = true; - map.setZoom(newValue); - }, - }), - new TouchBar.TouchBarButton({ - label: "New geo note", - click: () => this.triggerCommand("geoMapCreateChildNote"), - enabled: (this._state === State.Normal) - }) - ]; - } - -} - From aada49e548c7cef430d359119c1113c7716ac427 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 5 Sep 2025 16:02:35 +0300 Subject: [PATCH 054/233] chore(react/collections/calendar): get calendar to render --- .../src/widgets/collections/NoteList.tsx | 3 ++ .../widgets/collections/calendar/calendar.tsx | 29 +++++++++++++ .../widgets/collections/calendar/index.tsx | 42 +++++++++++++++++++ .../src/widgets/view_widgets/calendar_view.ts | 14 ------- 4 files changed, 74 insertions(+), 14 deletions(-) create mode 100644 apps/client/src/widgets/collections/calendar/calendar.tsx create mode 100644 apps/client/src/widgets/collections/calendar/index.tsx diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index f19d983c8e..a963c50efd 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -6,6 +6,7 @@ import { ListView, GridView } from "./legacy/ListOrGridView"; import { useEffect, useMemo, useRef, useState } from "preact/hooks"; import GeoView from "./geomap"; import ViewModeStorage from "../view_widgets/view_mode_storage"; +import CalendarView from "./calendar"; interface NoteListProps { note?: FNote | null; @@ -82,6 +83,8 @@ function getComponentByViewType(viewType: ViewTypeOptions, props: ViewModeProps< return ; case "geoMap": return ; + case "calendar": + return } } diff --git a/apps/client/src/widgets/collections/calendar/calendar.tsx b/apps/client/src/widgets/collections/calendar/calendar.tsx new file mode 100644 index 0000000000..b036a7bdda --- /dev/null +++ b/apps/client/src/widgets/collections/calendar/calendar.tsx @@ -0,0 +1,29 @@ +import { useEffect, useRef } from "preact/hooks"; +import { Calendar as FullCalendar, PluginDef } from "@fullcalendar/core"; + +interface CalendarProps { + view: string; + tabIndex?: number; + plugins: PluginDef[]; +} + +export default function Calendar({ tabIndex, view, plugins }: CalendarProps) { + const calendarRef = useRef(); + const containerRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + + const calendar = new FullCalendar(containerRef.current, { + initialView: view, + plugins: plugins + }); + calendar.render(); + + return () => calendar.destroy(); + }, [ containerRef ]); + + return ( +
+ ); +} diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx new file mode 100644 index 0000000000..93ec300dff --- /dev/null +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -0,0 +1,42 @@ +import { PluginDef } from "@fullcalendar/core/index.js"; +import { ViewModeProps } from "../interface"; +import Calendar from "./calendar"; +import { useEffect, useState } from "preact/hooks"; + +interface CalendarViewData { + +} + +export default function CalendarView({ note, noteIds }: ViewModeProps) { + const plugins = usePlugins(false, false); + + return (plugins && + + ); +} + +function usePlugins(isEditable: boolean, isCalendarRoot: boolean) { + const [ plugins, setPlugins ] = useState(); + + 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; +} diff --git a/apps/client/src/widgets/view_widgets/calendar_view.ts b/apps/client/src/widgets/view_widgets/calendar_view.ts index ff32474f1b..1befd1bb56 100644 --- a/apps/client/src/widgets/view_widgets/calendar_view.ts +++ b/apps/client/src/widgets/view_widgets/calendar_view.ts @@ -98,9 +98,6 @@ const TPL = /*html*/` overflow: hidden; } - -
-
`; @@ -148,14 +145,6 @@ export default class CalendarView extends ViewMode<{}> { const isEditable = !this.isCalendarRoot; const { Calendar } = await import("@fullcalendar/core"); - 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 || this.isCalendarRoot) { - plugins.push((await import("@fullcalendar/interaction")).default); - } let eventBuilder: EventSourceFunc; if (!this.isCalendarRoot) { @@ -165,7 +154,6 @@ export default class CalendarView extends ViewMode<{}> { } // Parse user's initial view, if valid. - let initialView = "dayGridMonth"; const userInitialView = this.parentNote.getLabelValue("calendar:view"); if (userInitialView && CALENDAR_VIEWS.includes(userInitialView)) { initialView = userInitialView; @@ -253,8 +241,6 @@ export default class CalendarView extends ViewMode<{}> { end: `${CALENDAR_VIEWS.join(",")} today prev,next` } }); - calendar.render(); - this.calendar = calendar; new ResizeObserver(() => calendar.updateSize()) .observe(this.$calendarContainer[0]); From feb984649febd4e146bbab140e389e6ecc12359e Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 5 Sep 2025 16:22:48 +0300 Subject: [PATCH 055/233] chore(react/collections/calendar): set up CSS --- .../widgets/collections/calendar/index.css | 62 +++++++++++++++++ .../widgets/collections/calendar/index.tsx | 13 ++-- .../src/widgets/view_widgets/calendar_view.ts | 69 ------------------- 3 files changed, 70 insertions(+), 74 deletions(-) create mode 100644 apps/client/src/widgets/collections/calendar/index.css diff --git a/apps/client/src/widgets/collections/calendar/index.css b/apps/client/src/widgets/collections/calendar/index.css new file mode 100644 index 0000000000..69b116a182 --- /dev/null +++ b/apps/client/src/widgets/collections/calendar/index.css @@ -0,0 +1,62 @@ +.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-toolbar.fc-header-toolbar { + margin-bottom: 0.5em; +} + +.calendar-container .fc-list-sticky .fc-list-day > * { + z-index: 50; +} + +body.desktop:not(.zen) .calendar-container .fc-toolbar.fc-header-toolbar { + padding-right: 5em; +} + +.search-result-widget-content .calendar-view .fc-toolbar.fc-header-toolbar { + padding-right: unset !important; +} + +.calendar-container .fc-toolbar-title { + font-size: 1.3rem; + font-weight: normal; +} + +.calendar-container a.fc-event { + text-decoration: none; +} + +.calendar-container .fc-button { + padding: 0.2em 0.5em; +} + +.calendar-container .promoted-attribute { + font-size: 0.85em; + opacity: 0.85; + overflow: hidden; +} \ No newline at end of file diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index 93ec300dff..42b766b8f7 100644 --- a/apps/client/src/widgets/collections/calendar/index.tsx +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -2,6 +2,7 @@ import { PluginDef } from "@fullcalendar/core/index.js"; import { ViewModeProps } from "../interface"; import Calendar from "./calendar"; import { useEffect, useState } from "preact/hooks"; +import "./index.css"; interface CalendarViewData { @@ -11,11 +12,13 @@ export default function CalendarView({ note, noteIds }: ViewModeProps +
+ +
); } diff --git a/apps/client/src/widgets/view_widgets/calendar_view.ts b/apps/client/src/widgets/view_widgets/calendar_view.ts index 1befd1bb56..d4e10a0fcf 100644 --- a/apps/client/src/widgets/view_widgets/calendar_view.ts +++ b/apps/client/src/widgets/view_widgets/calendar_view.ts @@ -32,75 +32,6 @@ const LOCALE_MAPPINGS: Record Promise<{ default: LocaleInput en: null }; -const TPL = /*html*/` -
- -
-`; - // TODO: Deduplicate interface CreateChildResponse { note: { From d33b1eb394473c74c0780acbe796968004c94ea0 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 5 Sep 2025 16:26:52 +0300 Subject: [PATCH 056/233] chore(react/collections/calendar): add views & first day of week --- .../widgets/collections/calendar/calendar.tsx | 15 +++++---------- .../src/widgets/collections/calendar/index.tsx | 16 +++++++++++++++- .../src/widgets/view_widgets/calendar_view.ts | 14 +------------- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/apps/client/src/widgets/collections/calendar/calendar.tsx b/apps/client/src/widgets/collections/calendar/calendar.tsx index b036a7bdda..62842580aa 100644 --- a/apps/client/src/widgets/collections/calendar/calendar.tsx +++ b/apps/client/src/widgets/collections/calendar/calendar.tsx @@ -1,27 +1,22 @@ import { useEffect, useRef } from "preact/hooks"; -import { Calendar as FullCalendar, PluginDef } from "@fullcalendar/core"; +import { CalendarOptions, Calendar as FullCalendar, PluginDef } from "@fullcalendar/core"; -interface CalendarProps { - view: string; +interface CalendarProps extends CalendarOptions { tabIndex?: number; - plugins: PluginDef[]; } -export default function Calendar({ tabIndex, view, plugins }: CalendarProps) { +export default function Calendar({ tabIndex, ...options }: CalendarProps) { const calendarRef = useRef(); const containerRef = useRef(null); useEffect(() => { if (!containerRef.current) return; - const calendar = new FullCalendar(containerRef.current, { - initialView: view, - plugins: plugins - }); + const calendar = new FullCalendar(containerRef.current, options); calendar.render(); return () => calendar.destroy(); - }, [ containerRef ]); + }, [ containerRef, options ]); return (
diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index 42b766b8f7..b14fd889d5 100644 --- a/apps/client/src/widgets/collections/calendar/index.tsx +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -3,20 +3,34 @@ import { ViewModeProps } from "../interface"; import Calendar from "./calendar"; import { useEffect, useState } from "preact/hooks"; import "./index.css"; +import { useTriliumOption, useTriliumOptionInt } from "../../react/hooks"; interface CalendarViewData { } +const CALENDAR_VIEWS = [ + "timeGridWeek", + "dayGridMonth", + "multiMonthYear", + "listMonth" +] + export default function CalendarView({ note, noteIds }: ViewModeProps) { const plugins = usePlugins(false, false); + const [ firstDayOfWeek ] = useTriliumOptionInt("firstDayOfWeek"); return (plugins &&
); diff --git a/apps/client/src/widgets/view_widgets/calendar_view.ts b/apps/client/src/widgets/view_widgets/calendar_view.ts index d4e10a0fcf..370f841fb0 100644 --- a/apps/client/src/widgets/view_widgets/calendar_view.ts +++ b/apps/client/src/widgets/view_widgets/calendar_view.ts @@ -46,12 +46,7 @@ interface Event { endTime?: string | null } -const CALENDAR_VIEWS = [ - "timeGridWeek", - "dayGridMonth", - "multiMonthYear", - "listMonth" -] + export default class CalendarView extends ViewMode<{}> { @@ -91,14 +86,11 @@ export default class CalendarView extends ViewMode<{}> { } const calendar = new Calendar(this.$calendarContainer[0], { - plugins, - initialView, events: eventBuilder, editable: isEditable, selectable: isEditable, select: (e) => this.#onCalendarSelection(e), eventChange: (e) => this.#onEventMoved(e), - firstDay: options.getInt("firstDayOfWeek") ?? 0, weekends: !this.parentNote.hasAttribute("label", "calendar:hideWeekends"), weekNumbers: this.parentNote.hasAttribute("label", "calendar:weekNumbers"), locale: await getFullCalendarLocale(options.get("locale")), @@ -167,10 +159,6 @@ export default class CalendarView extends ViewMode<{}> { } }, datesSet: (e) => this.#onDatesSet(e), - headerToolbar: { - start: "title", - end: `${CALENDAR_VIEWS.join(",")} today prev,next` - } }); new ResizeObserver(() => calendar.updateSize()) From 7f7eaea2b1fddb1248a2c40c6bea2f15b2ed0526 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 5 Sep 2025 16:28:34 +0300 Subject: [PATCH 057/233] chore(react/collections/calendar): hide weekends & week numbers --- apps/client/src/widgets/collections/calendar/index.tsx | 6 +++++- apps/client/src/widgets/view_widgets/calendar_view.ts | 2 -- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index b14fd889d5..73bce3585a 100644 --- a/apps/client/src/widgets/collections/calendar/index.tsx +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -3,7 +3,7 @@ import { ViewModeProps } from "../interface"; import Calendar from "./calendar"; import { useEffect, useState } from "preact/hooks"; import "./index.css"; -import { useTriliumOption, useTriliumOptionInt } from "../../react/hooks"; +import { useNoteLabel, useNoteLabelBoolean, useTriliumOption, useTriliumOptionInt } from "../../react/hooks"; interface CalendarViewData { @@ -19,6 +19,8 @@ const CALENDAR_VIEWS = [ export default function CalendarView({ note, noteIds }: ViewModeProps) { const plugins = usePlugins(false, false); const [ firstDayOfWeek ] = useTriliumOptionInt("firstDayOfWeek"); + const [ hideWeekends ] = useNoteLabelBoolean(note, "calendar:hideWeekends"); + const [ weekNumbers ] = useNoteLabelBoolean(note, "calendar:weekNumbers"); return (plugins &&
@@ -31,6 +33,8 @@ export default function CalendarView({ note, noteIds }: ViewModeProps
); diff --git a/apps/client/src/widgets/view_widgets/calendar_view.ts b/apps/client/src/widgets/view_widgets/calendar_view.ts index 370f841fb0..b738bc2c16 100644 --- a/apps/client/src/widgets/view_widgets/calendar_view.ts +++ b/apps/client/src/widgets/view_widgets/calendar_view.ts @@ -91,8 +91,6 @@ export default class CalendarView extends ViewMode<{}> { selectable: isEditable, select: (e) => this.#onCalendarSelection(e), eventChange: (e) => this.#onEventMoved(e), - weekends: !this.parentNote.hasAttribute("label", "calendar:hideWeekends"), - weekNumbers: this.parentNote.hasAttribute("label", "calendar:weekNumbers"), locale: await getFullCalendarLocale(options.get("locale")), height: "100%", nowIndicator: true, From d6ccd106e6f08072d0751e1bedd4259f2969da44 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 5 Sep 2025 16:51:36 +0300 Subject: [PATCH 058/233] chore(react/collections/calendar): bring back locale --- .../widgets/collections/calendar/index.tsx | 36 ++++++++++++++++++- .../src/widgets/view_widgets/calendar_view.ts | 25 ------------- 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index 73bce3585a..79cb2ac0e5 100644 --- a/apps/client/src/widgets/collections/calendar/index.tsx +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -1,9 +1,10 @@ -import { PluginDef } from "@fullcalendar/core/index.js"; +import { LocaleInput, PluginDef } from "@fullcalendar/core/index.js"; import { ViewModeProps } from "../interface"; import Calendar from "./calendar"; import { useEffect, useState } from "preact/hooks"; import "./index.css"; import { useNoteLabel, useNoteLabelBoolean, useTriliumOption, useTriliumOptionInt } from "../../react/hooks"; +import { LOCALE_IDS } from "@triliumnext/commons"; interface CalendarViewData { @@ -16,8 +17,24 @@ const CALENDAR_VIEWS = [ "listMonth" ] +// Here we hard-code the imports in order to ensure that they are embedded by webpack without having to load all the languages. +const LOCALE_MAPPINGS: Record 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) { const plugins = usePlugins(false, false); + const locale = useLocale(); const [ firstDayOfWeek ] = useTriliumOptionInt("firstDayOfWeek"); const [ hideWeekends ] = useNoteLabelBoolean(note, "calendar:hideWeekends"); const [ weekNumbers ] = useNoteLabelBoolean(note, "calendar:weekNumbers"); @@ -35,6 +52,7 @@ export default function CalendarView({ note, noteIds }: ViewModeProps
); @@ -61,3 +79,19 @@ function usePlugins(isEditable: boolean, isCalendarRoot: boolean) { return plugins; } + +function useLocale() { + const [ locale ] = useTriliumOption("locale"); + const [ calendarLocale, setCalendarLocale ] = useState(); + + useEffect(() => { + const correspondingLocale = LOCALE_MAPPINGS[locale]; + if (correspondingLocale) { + correspondingLocale().then((locale) => setCalendarLocale(locale.default)); + } else { + setCalendarLocale(undefined); + } + }); + + return calendarLocale; +} diff --git a/apps/client/src/widgets/view_widgets/calendar_view.ts b/apps/client/src/widgets/view_widgets/calendar_view.ts index b738bc2c16..14e849532f 100644 --- a/apps/client/src/widgets/view_widgets/calendar_view.ts +++ b/apps/client/src/widgets/view_widgets/calendar_view.ts @@ -17,21 +17,6 @@ import type { TouchBarItem } from "../../components/touch_bar.js"; import type { SegmentedControlSegment } from "electron"; import { LOCALE_IDS } from "@triliumnext/commons"; -// Here we hard-code the imports in order to ensure that they are embedded by webpack without having to load all the languages. -const LOCALE_MAPPINGS: Record 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 -}; - // TODO: Deduplicate interface CreateChildResponse { note: { @@ -91,7 +76,6 @@ export default class CalendarView extends ViewMode<{}> { selectable: isEditable, select: (e) => this.#onCalendarSelection(e), eventChange: (e) => this.#onEventMoved(e), - locale: await getFullCalendarLocale(options.get("locale")), height: "100%", nowIndicator: true, handleWindowResize: false, @@ -575,12 +559,3 @@ export default class CalendarView extends ViewMode<{}> { } } - -export async function getFullCalendarLocale(locale: LOCALE_IDS) { - const correspondingLocale = LOCALE_MAPPINGS[locale]; - if (correspondingLocale) { - return (await correspondingLocale()).default; - } else { - return undefined; - } -} From 10d1ec1bb2f9e22ba30b69a2b8418ebda292edbe Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 5 Sep 2025 17:18:02 +0300 Subject: [PATCH 059/233] chore(react/collections/calendar): bring back saving of view --- .../widgets/collections/calendar/calendar.tsx | 9 +++++-- .../widgets/collections/calendar/index.tsx | 20 +++++++++++++--- apps/client/src/widgets/react/hooks.tsx | 13 ++++------ .../src/widgets/view_widgets/calendar_view.ts | 24 ------------------- 4 files changed, 28 insertions(+), 38 deletions(-) diff --git a/apps/client/src/widgets/collections/calendar/calendar.tsx b/apps/client/src/widgets/collections/calendar/calendar.tsx index 62842580aa..061680ccfc 100644 --- a/apps/client/src/widgets/collections/calendar/calendar.tsx +++ b/apps/client/src/widgets/collections/calendar/calendar.tsx @@ -1,12 +1,13 @@ import { useEffect, useRef } from "preact/hooks"; import { CalendarOptions, Calendar as FullCalendar, PluginDef } from "@fullcalendar/core"; +import { RefObject } from "preact"; interface CalendarProps extends CalendarOptions { + calendarRef?: RefObject; tabIndex?: number; } -export default function Calendar({ tabIndex, ...options }: CalendarProps) { - const calendarRef = useRef(); +export default function Calendar({ tabIndex, calendarRef, ...options }: CalendarProps) { const containerRef = useRef(null); useEffect(() => { @@ -15,6 +16,10 @@ export default function Calendar({ tabIndex, ...options }: CalendarProps) { const calendar = new FullCalendar(containerRef.current, options); calendar.render(); + if (calendarRef) { + calendarRef.current = calendar; + } + return () => calendar.destroy(); }, [ containerRef, options ]); diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index 79cb2ac0e5..3ffe1fc9d6 100644 --- a/apps/client/src/widgets/collections/calendar/index.tsx +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -1,10 +1,13 @@ import { LocaleInput, PluginDef } from "@fullcalendar/core/index.js"; import { ViewModeProps } from "../interface"; import Calendar from "./calendar"; -import { useEffect, useState } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; import "./index.css"; -import { useNoteLabel, useNoteLabelBoolean, useTriliumOption, useTriliumOptionInt } from "../../react/hooks"; +import { useNoteLabel, useNoteLabelBoolean, useSpacedUpdate, useTriliumOption, useTriliumOptionInt } from "../../react/hooks"; import { LOCALE_IDS } from "@triliumnext/commons"; +import { Calendar as FullCalendar } from "@fullcalendar/core"; +import { setLabel } from "../../../services/attributes"; +import { circle } from "leaflet"; interface CalendarViewData { @@ -33,18 +36,23 @@ const LOCALE_MAPPINGS: Record Promise<{ default: LocaleInput }; export default function CalendarView({ note, noteIds }: ViewModeProps) { + const calendarRef = useRef(null); const plugins = usePlugins(false, false); const locale = useLocale(); 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)); return (plugins &&
{ + if (initialView.current !== view.type) { + initialView.current = view.type; + viewSpacedUpdate.scheduleUpdate(); + } + }} />
); diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index bc1c146892..97b890e028 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -54,21 +54,16 @@ export function useTriliumEvents(eventNames: T[], handler: export function useSpacedUpdate(callback: () => void | Promise, interval = 1000) { const callbackRef = useRef(callback); - const spacedUpdateRef = useRef(); + const spacedUpdateRef = useRef(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); diff --git a/apps/client/src/widgets/view_widgets/calendar_view.ts b/apps/client/src/widgets/view_widgets/calendar_view.ts index 14e849532f..ee771144a8 100644 --- a/apps/client/src/widgets/view_widgets/calendar_view.ts +++ b/apps/client/src/widgets/view_widgets/calendar_view.ts @@ -39,8 +39,6 @@ export default class CalendarView extends ViewMode<{}> { private $calendarContainer: JQuery; private calendar?: Calendar; private isCalendarRoot: boolean; - private lastView?: string; - private debouncedSaveView?: DebouncedFunction<() => void>; constructor(args: ViewModeArgs) { super(args, "calendar"); @@ -64,12 +62,6 @@ export default class CalendarView extends ViewMode<{}> { eventBuilder = async (e: EventSourceFuncArg) => await this.#buildEventsForCalendar(e); } - // Parse user's initial view, if valid. - const userInitialView = this.parentNote.getLabelValue("calendar:view"); - if (userInitialView && CALENDAR_VIEWS.includes(userInitialView)) { - initialView = userInitialView; - } - const calendar = new Calendar(this.$calendarContainer[0], { events: eventBuilder, editable: isEditable, @@ -150,22 +142,6 @@ export default class CalendarView extends ViewMode<{}> { } #onDatesSet(e: DatesSetArg) { - const currentView = e.view.type; - if (currentView === this.lastView) { - return; - } - - if (!this.debouncedSaveView) { - this.debouncedSaveView = debounce(() => { - if (this.lastView) { - attributes.setLabel(this.parentNote.noteId, "calendar:view", this.lastView); - } - }, 1_000); - } - - this.debouncedSaveView(); - this.lastView = currentView; - if (hasTouchBar) { appContext.triggerCommand("refreshTouchBar"); } From ba42e9050236d68bb66ec902c841b49b27a68592 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 5 Sep 2025 17:33:46 +0300 Subject: [PATCH 060/233] chore(react/collections/calendar): handle resize --- .../src/widgets/collections/calendar/index.tsx | 7 +++++-- apps/client/src/widgets/react/hooks.tsx | 15 +++++++++++++++ .../src/widgets/view_widgets/calendar_view.ts | 4 ---- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index 3ffe1fc9d6..1df397f88b 100644 --- a/apps/client/src/widgets/collections/calendar/index.tsx +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -3,7 +3,7 @@ import { ViewModeProps } from "../interface"; import Calendar from "./calendar"; import { useEffect, useRef, useState } from "preact/hooks"; import "./index.css"; -import { useNoteLabel, useNoteLabelBoolean, useSpacedUpdate, useTriliumOption, useTriliumOptionInt } from "../../react/hooks"; +import { useNoteLabel, useNoteLabelBoolean, useResizeObserver, useSpacedUpdate, useTriliumOption, useTriliumOptionInt } from "../../react/hooks"; import { LOCALE_IDS } from "@triliumnext/commons"; import { Calendar as FullCalendar } from "@fullcalendar/core"; import { setLabel } from "../../../services/attributes"; @@ -36,6 +36,7 @@ const LOCALE_MAPPINGS: Record Promise<{ default: LocaleInput }; export default function CalendarView({ note, noteIds }: ViewModeProps) { + const containerRef = useRef(null); const calendarRef = useRef(null); const plugins = usePlugins(false, false); const locale = useLocale(); @@ -45,9 +46,10 @@ export default function CalendarView({ note, noteIds }: ViewModeProps setCalendarView(initialView.current)); + useResizeObserver(containerRef, () => calendarRef.current?.updateSize()); return (plugins && -
+
{ if (initialView.current !== view.type) { diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 97b890e028..c86fa544db 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -627,3 +627,18 @@ export function useTouchBar( parentComponent?.triggerCommand("refreshTouchBar"); }, inputs); } + +export function useResizeObserver(ref: RefObject, callback: () => void) { + const resizeObserver = useRef(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 ]); +} diff --git a/apps/client/src/widgets/view_widgets/calendar_view.ts b/apps/client/src/widgets/view_widgets/calendar_view.ts index ee771144a8..b091126d07 100644 --- a/apps/client/src/widgets/view_widgets/calendar_view.ts +++ b/apps/client/src/widgets/view_widgets/calendar_view.ts @@ -70,7 +70,6 @@ export default class CalendarView extends ViewMode<{}> { eventChange: (e) => this.#onEventMoved(e), height: "100%", nowIndicator: true, - handleWindowResize: false, eventDidMount: (e) => { const { iconClass, promotedAttributes } = e.event.extendedProps; @@ -135,9 +134,6 @@ export default class CalendarView extends ViewMode<{}> { datesSet: (e) => this.#onDatesSet(e), }); - new ResizeObserver(() => calendar.updateSize()) - .observe(this.$calendarContainer[0]); - return this.$root; } From 84d35c1a370f5909325d5c25893dbe7554d4596e Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 5 Sep 2025 17:44:24 +0300 Subject: [PATCH 061/233] chore(react/collections/calendar): create event from selection --- .../widgets/collections/calendar/index.tsx | 58 ++++++++++- .../src/widgets/collections/calendar/utils.ts | 59 +++++++++++ .../src/widgets/view_widgets/calendar_view.ts | 98 ------------------- 3 files changed, 112 insertions(+), 103 deletions(-) create mode 100644 apps/client/src/widgets/collections/calendar/utils.ts diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index 1df397f88b..7f15876d39 100644 --- a/apps/client/src/widgets/collections/calendar/index.tsx +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -1,13 +1,17 @@ -import { LocaleInput, PluginDef } from "@fullcalendar/core/index.js"; +import { DateSelectArg, LocaleInput, PluginDef } from "@fullcalendar/core/index.js"; import { ViewModeProps } from "../interface"; import Calendar from "./calendar"; -import { useEffect, useRef, useState } from "preact/hooks"; +import { useCallback, useEffect, useRef, useState } from "preact/hooks"; import "./index.css"; import { useNoteLabel, useNoteLabelBoolean, useResizeObserver, useSpacedUpdate, useTriliumOption, useTriliumOptionInt } from "../../react/hooks"; -import { LOCALE_IDS } from "@triliumnext/commons"; +import { CreateChildrenResponse, LOCALE_IDS } from "@triliumnext/commons"; import { Calendar as FullCalendar } from "@fullcalendar/core"; import { setLabel } from "../../../services/attributes"; import { circle } from "leaflet"; +import server from "../../../services/server"; +import { parseStartEndDateFromEvent, parseStartEndTimeFromEvent } from "./utils"; +import dialog from "../../../services/dialog"; +import { t } from "../../../services/i18n"; interface CalendarViewData { @@ -38,8 +42,9 @@ const LOCALE_MAPPINGS: Record Promise<{ default: LocaleInput export default function CalendarView({ note, noteIds }: ViewModeProps) { const containerRef = useRef(null); const calendarRef = useRef(null); - const plugins = usePlugins(false, false); - const locale = useLocale(); + + 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"); @@ -47,6 +52,47 @@ export default function CalendarView({ note, noteIds }: ViewModeProps setCalendarView(initialView.current)); useResizeObserver(containerRef, () => calendarRef.current?.updateSize()); + const isCalendarRoot = (calendarRoot || workspaceCalendarRoot); + const isEditable = !isCalendarRoot; + + const plugins = usePlugins(isEditable, isCalendarRoot); + const locale = useLocale(); + + const onCalendarSelection = useCallback(async (e: DateSelectArg) => { + // Handle start and end date + const { startDate, endDate } = parseStartEndDateFromEvent(e); + if (!startDate) { + return; + } + + // Handle start and end time. + 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; + } + + // Create the note. + const { note: eventNote } = await server.post(`notes/${note.noteId}/children?target=into`, { + title, + content: "", + type: "text" + }); + + // Set the attributes. + setLabel(eventNote.noteId, "startDate", startDate); + if (endDate) { + setLabel(eventNote.noteId, "endDate", endDate); + } + if (startTime) { + setLabel(eventNote.noteId, "startTime", startTime); + } + if (endTime) { + setLabel(eventNote.noteId, "endTime", endTime); + } + }, []); return (plugins &&
@@ -64,6 +110,8 @@ export default function CalendarView({ note, noteIds }: ViewModeProps { if (initialView.current !== view.type) { initialView.current = view.type; diff --git a/apps/client/src/widgets/collections/calendar/utils.ts b/apps/client/src/widgets/collections/calendar/utils.ts new file mode 100644 index 0000000000..f8b75386bc --- /dev/null +++ b/apps/client/src/widgets/collections/calendar/utils.ts @@ -0,0 +1,59 @@ +import { DateSelectArg } from "@fullcalendar/core/index.js"; +import { EventImpl } from "@fullcalendar/core/internal"; + +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); +} diff --git a/apps/client/src/widgets/view_widgets/calendar_view.ts b/apps/client/src/widgets/view_widgets/calendar_view.ts index b091126d07..186c4b980e 100644 --- a/apps/client/src/widgets/view_widgets/calendar_view.ts +++ b/apps/client/src/widgets/view_widgets/calendar_view.ts @@ -50,9 +50,6 @@ export default class CalendarView extends ViewMode<{}> { } async renderList(): Promise | undefined> { - this.isCalendarRoot = this.parentNote.hasLabel("calendarRoot") || this.parentNote.hasLabel("workspaceCalendarRoot"); - const isEditable = !this.isCalendarRoot; - const { Calendar } = await import("@fullcalendar/core"); let eventBuilder: EventSourceFunc; @@ -64,8 +61,6 @@ export default class CalendarView extends ViewMode<{}> { const calendar = new Calendar(this.$calendarContainer[0], { events: eventBuilder, - editable: isEditable, - selectable: isEditable, select: (e) => this.#onCalendarSelection(e), eventChange: (e) => this.#onEventMoved(e), height: "100%", @@ -143,67 +138,6 @@ export default class CalendarView extends ViewMode<{}> { } } - async #onCalendarSelection(e: DateSelectArg) { - // Handle start and end date - const { startDate, endDate } = this.#parseStartEndDateFromEvent(e); - if (!startDate) { - return; - } - - // Handle start and end time. - const { startTime, endTime } = this.#parseStartEndTimeFromEvent(e); - - // Ask for the title - const title = await dialogService.prompt({ message: t("relation_map.enter_title_of_new_note"), defaultValue: t("relation_map.default_new_note_title") }); - if (!title?.trim()) { - return; - } - - // Create the note. - const { note } = await server.post(`notes/${this.parentNote.noteId}/children?target=into`, { - title, - content: "", - type: "text" - }); - - // Set the attributes. - attributes.setLabel(note.noteId, "startDate", startDate); - if (endDate) { - attributes.setLabel(note.noteId, "endDate", endDate); - } - if (startTime) { - attributes.setLabel(note.noteId, "startTime", startTime); - } - if (endTime) { - attributes.setLabel(note.noteId, "endTime", endTime); - } - } - - #parseStartEndDateFromEvent(e: DateSelectArg | EventImpl) { - const startDate = CalendarView.#formatDateToLocalISO(e.start); - if (!startDate) { - return { startDate: null, endDate: null }; - } - let endDate; - if (e.allDay) { - endDate = CalendarView.#formatDateToLocalISO(CalendarView.#offsetDate(e.end, -1)); - } else { - endDate = CalendarView.#formatDateToLocalISO(e.end); - } - return { startDate, endDate }; - } - - #parseStartEndTimeFromEvent(e: DateSelectArg | EventImpl) { - let startTime: string | undefined | null = null; - let endTime: string | undefined | null = null; - if (!e.allDay) { - startTime = CalendarView.#formatTimeToLocalISO(e.start); - endTime = CalendarView.#formatTimeToLocalISO(e.end); - } - - return { startTime, endTime }; - } - async #onEventMoved(e: EventChangeArg) { // Handle start and end date let { startDate, endDate } = this.#parseStartEndDateFromEvent(e.event); @@ -431,38 +365,6 @@ export default class CalendarView extends ViewMode<{}> { return [note.title]; } - static #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]; - } - - static #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); - } - - static #offsetDate(date: Date | string | null | undefined, offset: number) { - if (!date) { - return undefined; - } - - const newDate = new Date(date); - newDate.setDate(newDate.getDate() + offset); - return newDate; - } - buildTouchBarCommand({ TouchBar, buildIcon }: CommandListenerData<"buildTouchBar">) { if (!this.calendar) { return; From 5bb9117fde05915e4c533b6e10af0f9310a2af6d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 5 Sep 2025 17:56:35 +0300 Subject: [PATCH 062/233] chore(react/collections/calendar): render non-calendar events --- .../collections/calendar/event_builder.ts | 112 ++++++++++++++ .../widgets/collections/calendar/index.tsx | 9 +- .../src/widgets/collections/calendar/utils.ts | 22 +++ .../src/widgets/view_widgets/calendar_view.ts | 140 +----------------- 4 files changed, 143 insertions(+), 140 deletions(-) create mode 100644 apps/client/src/widgets/collections/calendar/event_builder.ts diff --git a/apps/client/src/widgets/collections/calendar/event_builder.ts b/apps/client/src/widgets/collections/calendar/event_builder.ts new file mode 100644 index 0000000000..4a256728b6 --- /dev/null +++ b/apps/client/src/widgets/collections/calendar/event_builder.ts @@ -0,0 +1,112 @@ +import { EventInput, EventSourceInput } from "@fullcalendar/core/index.js"; +import froca from "../../../services/froca"; +import { formatDateToLocalISO, getCustomisableLabel, offsetDate } from "./utils"; +import FNote from "../../../entities/fnote"; + +interface Event { + startDate: string, + endDate?: string | null, + startTime?: string | null, + endTime?: string | null +} + +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"); + events.push(await buildEvent(note, { startDate, endDate, startTime, endTime })); + } + + return events.flat(); +} + +async function buildEvent(note: FNote, { startDate, endDate, startTime, endTime }: 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 + }; + if (endDate) { + eventData.end = endDate; + } + events.push(eventData); + } + return events; +} + +async function parseCustomTitle(customTitlettributeName: string | null, note: FNote, allowRelations = true): Promise { + 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; +} diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index 7f15876d39..803f1f2ccb 100644 --- a/apps/client/src/widgets/collections/calendar/index.tsx +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -1,7 +1,7 @@ import { DateSelectArg, LocaleInput, PluginDef } from "@fullcalendar/core/index.js"; import { ViewModeProps } from "../interface"; import Calendar from "./calendar"; -import { useCallback, useEffect, useRef, useState } from "preact/hooks"; +import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; import "./index.css"; import { useNoteLabel, useNoteLabelBoolean, useResizeObserver, useSpacedUpdate, useTriliumOption, useTriliumOptionInt } from "../../react/hooks"; import { CreateChildrenResponse, LOCALE_IDS } from "@triliumnext/commons"; @@ -12,6 +12,7 @@ import server from "../../../services/server"; import { parseStartEndDateFromEvent, parseStartEndTimeFromEvent } from "./utils"; import dialog from "../../../services/dialog"; import { t } from "../../../services/i18n"; +import { buildEvents } from "./event_builder"; interface CalendarViewData { @@ -54,6 +55,11 @@ export default function CalendarView({ note, noteIds }: ViewModeProps calendarRef.current?.updateSize()); const isCalendarRoot = (calendarRoot || workspaceCalendarRoot); const isEditable = !isCalendarRoot; + const eventBuilder = useMemo(() => { + if (!isCalendarRoot) { + return async () => await buildEvents(noteIds); + } + }, [isCalendarRoot, noteIds]); const plugins = usePlugins(isEditable, isCalendarRoot); const locale = useLocale(); @@ -97,6 +103,7 @@ export default function CalendarView({ note, noteIds }: ViewModeProps { @@ -54,7 +39,7 @@ export default class CalendarView extends ViewMode<{}> { let eventBuilder: EventSourceFunc; if (!this.isCalendarRoot) { - eventBuilder = async () => await CalendarView.buildEvents(this.noteIds) + eventBuilder = } else { eventBuilder = async (e: EventSourceFuncArg) => await this.#buildEventsForCalendar(e); } @@ -242,129 +227,6 @@ export default class CalendarView extends ViewMode<{}> { return events.flat(); } - static async buildEvents(noteIds: string[]) { - const notes = await froca.getNotes(noteIds); - const events: EventSourceInput = []; - - for (const note of notes) { - const startDate = CalendarView.#getCustomisableLabel(note, "startDate", "calendar:startDate"); - - if (!startDate) { - continue; - } - - const endDate = CalendarView.#getCustomisableLabel(note, "endDate", "calendar:endDate"); - const startTime = CalendarView.#getCustomisableLabel(note, "startTime", "calendar:startTime"); - const endTime = CalendarView.#getCustomisableLabel(note, "endTime", "calendar:endTime"); - events.push(await CalendarView.buildEvent(note, { startDate, endDate, startTime, endTime })); - } - - return events.flat(); - } - - /** - * 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. - */ - static #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); - } - - static async buildEvent(note: FNote, { startDate, endDate, startTime, endTime }: Event) { - const customTitleAttributeName = note.getLabelValue("calendar:title"); - const titles = await CalendarView.#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 this.#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 = CalendarView.#offsetDate(endDate ?? startDate, 1); - if (endDateOffset) { - endDate = CalendarView.#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 - }; - if (endDate) { - eventData.end = endDate; - } - events.push(eventData); - } - return events; - } - - static async #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; - } - - static async #parseCustomTitle(customTitlettributeName: string | null, note: FNote, allowRelations = true): Promise { - 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 CalendarView.#parseCustomTitle(targetCustomTitleValue, targetNote, false); - titles.push(targetTitles.flat()); - } - - return titles.flat(); - } - } - } - - return [note.title]; - } - buildTouchBarCommand({ TouchBar, buildIcon }: CommandListenerData<"buildTouchBar">) { if (!this.calendar) { return; From b93d9a6b6ede9be08f221170a874930cc7dabcac Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 5 Sep 2025 17:59:51 +0300 Subject: [PATCH 063/233] chore(react/collections/calendar): render calendar events --- apps/client/src/services/utils.ts | 22 -------- .../collections/calendar/event_builder.ts | 49 ++++++++++++++++- .../widgets/collections/calendar/index.tsx | 6 +- .../src/widgets/collections/calendar/utils.ts | 22 ++++++++ .../src/widgets/view_widgets/calendar_view.ts | 55 ------------------- 5 files changed, 73 insertions(+), 81 deletions(-) diff --git a/apps/client/src/services/utils.ts b/apps/client/src/services/utils.ts index 4b425f8320..015c509d60 100644 --- a/apps/client/src/services/utils.ts +++ b/apps/client/src/services/utils.ts @@ -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}`; } @@ -835,7 +814,6 @@ export default { restartDesktopApp, reloadTray, parseDate, - getMonthsInDateRange, formatDateISO, formatDateTime, formatTimeInterval, diff --git a/apps/client/src/widgets/collections/calendar/event_builder.ts b/apps/client/src/widgets/collections/calendar/event_builder.ts index 4a256728b6..c9a13c8161 100644 --- a/apps/client/src/widgets/collections/calendar/event_builder.ts +++ b/apps/client/src/widgets/collections/calendar/event_builder.ts @@ -1,7 +1,8 @@ -import { EventInput, EventSourceInput } from "@fullcalendar/core/index.js"; +import { EventInput, EventSourceFuncArg, EventSourceInput } from "@fullcalendar/core/index.js"; import froca from "../../../services/froca"; -import { formatDateToLocalISO, getCustomisableLabel, offsetDate } from "./utils"; +import { formatDateToLocalISO, getCustomisableLabel, getMonthsInDateRange, offsetDate } from "./utils"; import FNote from "../../../entities/fnote"; +import server from "../../../services/server"; interface Event { startDate: string, @@ -30,6 +31,50 @@ export async function buildEvents(noteIds: string[]) { 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>(`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 = {}; + 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(); +} + async function buildEvent(note: FNote, { startDate, endDate, startTime, endTime }: Event) { const customTitleAttributeName = note.getLabelValue("calendar:title"); const titles = await parseCustomTitle(customTitleAttributeName, note); diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index 803f1f2ccb..6ed019df1a 100644 --- a/apps/client/src/widgets/collections/calendar/index.tsx +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -1,4 +1,4 @@ -import { DateSelectArg, LocaleInput, PluginDef } from "@fullcalendar/core/index.js"; +import { DateSelectArg, 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"; @@ -12,7 +12,7 @@ import server from "../../../services/server"; import { parseStartEndDateFromEvent, parseStartEndTimeFromEvent } from "./utils"; import dialog from "../../../services/dialog"; import { t } from "../../../services/i18n"; -import { buildEvents } from "./event_builder"; +import { buildEvents, buildEventsForCalendar } from "./event_builder"; interface CalendarViewData { @@ -58,6 +58,8 @@ export default function CalendarView({ note, noteIds }: ViewModeProps { if (!isCalendarRoot) { return async () => await buildEvents(noteIds); + } else { + return async (e: EventSourceFuncArg) => await buildEventsForCalendar(note, e); } }, [isCalendarRoot, noteIds]); diff --git a/apps/client/src/widgets/collections/calendar/utils.ts b/apps/client/src/widgets/collections/calendar/utils.ts index e13ad4b3ef..992e1a1a0b 100644 --- a/apps/client/src/widgets/collections/calendar/utils.ts +++ b/apps/client/src/widgets/collections/calendar/utils.ts @@ -1,5 +1,6 @@ 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); @@ -79,3 +80,24 @@ export function getCustomisableLabel(note: FNote, defaultLabelName: string, cust 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; +} diff --git a/apps/client/src/widgets/view_widgets/calendar_view.ts b/apps/client/src/widgets/view_widgets/calendar_view.ts index e8dc01bf96..73b92aa5b3 100644 --- a/apps/client/src/widgets/view_widgets/calendar_view.ts +++ b/apps/client/src/widgets/view_widgets/calendar_view.ts @@ -30,22 +30,11 @@ export default class CalendarView extends ViewMode<{}> { this.$root = $(TPL); this.$calendarContainer = this.$root.find(".calendar-container"); - this.isCalendarRoot = false; args.$parent.append(this.$root); } async renderList(): Promise | undefined> { - const { Calendar } = await import("@fullcalendar/core"); - - let eventBuilder: EventSourceFunc; - if (!this.isCalendarRoot) { - eventBuilder = - } else { - eventBuilder = async (e: EventSourceFuncArg) => await this.#buildEventsForCalendar(e); - } - const calendar = new Calendar(this.$calendarContainer[0], { - events: eventBuilder, select: (e) => this.#onCalendarSelection(e), eventChange: (e) => this.#onEventMoved(e), height: "100%", @@ -183,50 +172,6 @@ export default class CalendarView extends ViewMode<{}> { } } - async #buildEventsForCalendar(e: EventSourceFuncArg) { - const events: EventInput[] = []; - - // Gather all the required date note IDs. - const dateRange = utils.getMonthsInDateRange(e.startStr, e.endStr); - let allDateNoteIds: string[] = []; - for (const month of dateRange) { - // TODO: Deduplicate get type. - const dateNotesForMonth = await server.get>(`special-notes/notes-for-month/${month}?calendarRoot=${this.parentNote.noteId}`); - const dateNoteIds = Object.values(dateNotesForMonth); - allDateNoteIds = [...allDateNoteIds, ...dateNoteIds]; - } - - // Request all the date notes. - const dateNotes = await froca.getNotes(allDateNoteIds); - const childNoteToDateMapping: Record = {}; - for (const dateNote of dateNotes) { - const startDate = dateNote.getLabelValue("dateNote"); - if (!startDate) { - continue; - } - - events.push(await CalendarView.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 CalendarView.buildEvent(childNote, { startDate }); - events.push(event); - } - - return events.flat(); - } - buildTouchBarCommand({ TouchBar, buildIcon }: CommandListenerData<"buildTouchBar">) { if (!this.calendar) { return; From f0b5954c54138942f9c7314a868bbe7c45194b6f Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 5 Sep 2025 18:10:34 +0300 Subject: [PATCH 064/233] refactor(react/collections/calendar): refactor into API --- .../src/widgets/collections/calendar/api.ts | 33 +++++++++++++++++++ .../widgets/collections/calendar/index.tsx | 31 +++-------------- 2 files changed, 38 insertions(+), 26 deletions(-) create mode 100644 apps/client/src/widgets/collections/calendar/api.ts diff --git a/apps/client/src/widgets/collections/calendar/api.ts b/apps/client/src/widgets/collections/calendar/api.ts new file mode 100644 index 0000000000..e12844fabd --- /dev/null +++ b/apps/client/src/widgets/collections/calendar/api.ts @@ -0,0 +1,33 @@ +import { CreateChildrenResponse } from "@triliumnext/commons"; +import server from "../../../services/server"; +import FNote from "../../../entities/fnote"; +import { setLabel } from "../../../services/attributes"; + +interface NewEventOpts { + title: string; + 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(`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); + } +} diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index 6ed019df1a..ed4450b304 100644 --- a/apps/client/src/widgets/collections/calendar/index.tsx +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -6,13 +6,14 @@ import "./index.css"; import { useNoteLabel, useNoteLabelBoolean, useResizeObserver, useSpacedUpdate, useTriliumOption, useTriliumOptionInt } from "../../react/hooks"; import { CreateChildrenResponse, LOCALE_IDS } from "@triliumnext/commons"; import { Calendar as FullCalendar } from "@fullcalendar/core"; -import { setLabel } from "../../../services/attributes"; +import { removeOwnedAttributesByNameOrType, setLabel } from "../../../services/attributes"; import { circle } from "leaflet"; import server from "../../../services/server"; import { parseStartEndDateFromEvent, parseStartEndTimeFromEvent } from "./utils"; import dialog from "../../../services/dialog"; import { t } from "../../../services/i18n"; import { buildEvents, buildEventsForCalendar } from "./event_builder"; +import { newEvent } from "./api"; interface CalendarViewData { @@ -67,13 +68,8 @@ export default function CalendarView({ note, noteIds }: ViewModeProps { - // Handle start and end date const { startDate, endDate } = parseStartEndDateFromEvent(e); - if (!startDate) { - return; - } - - // Handle start and end time. + if (!startDate) return; const { startTime, endTime } = parseStartEndTimeFromEvent(e); // Ask for the title @@ -82,25 +78,8 @@ export default function CalendarView({ note, noteIds }: ViewModeProps(`notes/${note.noteId}/children?target=into`, { - title, - content: "", - type: "text" - }); - - // Set the attributes. - setLabel(eventNote.noteId, "startDate", startDate); - if (endDate) { - setLabel(eventNote.noteId, "endDate", endDate); - } - if (startTime) { - setLabel(eventNote.noteId, "startTime", startTime); - } - if (endTime) { - setLabel(eventNote.noteId, "endTime", endTime); - } - }, []); + newEvent(note, { title, startDate, endDate, startTime, endTime }); + }, [ note ]); return (plugins &&
From cfddb6f04ee58fda23cd5ca476ff0071cbe14e72 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 6 Sep 2025 10:36:32 +0300 Subject: [PATCH 065/233] chore(react/collections/calendar): port dragging items --- .../widgets/collections/calendar/index.tsx | 44 ++++++++++++++++++- .../src/widgets/view_widgets/calendar_view.ts | 40 ----------------- 2 files changed, 43 insertions(+), 41 deletions(-) diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index ed4450b304..da30ed0ae9 100644 --- a/apps/client/src/widgets/collections/calendar/index.tsx +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -1,4 +1,4 @@ -import { DateSelectArg, EventSourceFuncArg, LocaleInput, PluginDef } from "@fullcalendar/core/index.js"; +import { DateSelectArg, EventChangeArg, 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"; @@ -14,6 +14,7 @@ import dialog from "../../../services/dialog"; import { t } from "../../../services/i18n"; import { buildEvents, buildEventsForCalendar } from "./event_builder"; import { newEvent } from "./api"; +import froca from "../../../services/froca"; interface CalendarViewData { @@ -81,6 +82,46 @@ export default function CalendarView({ note, noteIds }: ViewModeProps { + // Handle start and end date + let { startDate, endDate } = parseStartEndDateFromEvent(e.event); + if (!startDate) { + return; + } + const noteId = e.event.extendedProps.noteId; + + // Don't store the end date if it's empty. + if (endDate === startDate) { + endDate = undefined; + } + + // Update start date + const note = await froca.getNote(noteId); + if (!note) { + return; + } + + // Since they can be customized via calendar:startDate=$foo and calendar:endDate=$bar we need to determine the + // attributes to be effectively updated + const startAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:startDate").shift()?.value||"startDate"; + const endAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:endDate").shift()?.value||"endDate"; + + setLabel(noteId, startAttribute, startDate); + setLabel(noteId, endAttribute, endDate); + + // Update start time and end time if needed. + if (!e.event.allDay) { + const startAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:startTime").shift()?.value||"startTime"; + const endAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:endTime").shift()?.value||"endTime"; + + const { startTime, endTime } = parseStartEndTimeFromEvent(e.event); + if (startTime && endTime) { + setLabel(noteId, startAttribute, startTime); + setLabel(noteId, endAttribute, endTime); + } + } + }, []); + return (plugins &&
{ if (initialView.current !== view.type) { initialView.current = view.type; diff --git a/apps/client/src/widgets/view_widgets/calendar_view.ts b/apps/client/src/widgets/view_widgets/calendar_view.ts index 73b92aa5b3..8448baf1aa 100644 --- a/apps/client/src/widgets/view_widgets/calendar_view.ts +++ b/apps/client/src/widgets/view_widgets/calendar_view.ts @@ -35,8 +35,6 @@ export default class CalendarView extends ViewMode<{}> { async renderList(): Promise | undefined> { const calendar = new Calendar(this.$calendarContainer[0], { - select: (e) => this.#onCalendarSelection(e), - eventChange: (e) => this.#onEventMoved(e), height: "100%", nowIndicator: true, eventDidMount: (e) => { @@ -112,44 +110,6 @@ export default class CalendarView extends ViewMode<{}> { } } - async #onEventMoved(e: EventChangeArg) { - // Handle start and end date - let { startDate, endDate } = this.#parseStartEndDateFromEvent(e.event); - if (!startDate) { - return; - } - const noteId = e.event.extendedProps.noteId; - - // Don't store the end date if it's empty. - if (endDate === startDate) { - endDate = undefined; - } - - // Update start date - const note = await froca.getNote(noteId); - if (!note) { - return; - } - - // Since they can be customized via calendar:startDate=$foo and calendar:endDate=$bar we need to determine the - // attributes to be effectively updated - const startAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:startDate").shift()?.value||"startDate"; - const endAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:endDate").shift()?.value||"endDate"; - - attributes.setAttribute(note, "label", startAttribute, startDate); - attributes.setAttribute(note, "label", endAttribute, endDate); - - // Update start time and end time if needed. - if (!e.event.allDay) { - const startAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:startTime").shift()?.value||"startTime"; - const endAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:endTime").shift()?.value||"endTime"; - - const { startTime, endTime } = this.#parseStartEndTimeFromEvent(e.event); - attributes.setAttribute(note, "label", startAttribute, startTime); - attributes.setAttribute(note, "label", endAttribute, endTime); - } - } - async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) { // Refresh note IDs if they got changed. if (loadResults.getBranchRows().some((branch) => branch.parentNoteId === this.parentNote.noteId)) { From 6237afe3cd503e57ceb1434fe4b5e049b91eb17d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 6 Sep 2025 10:43:43 +0300 Subject: [PATCH 066/233] refactor(react/collections/calendar): change event in api --- .../src/widgets/collections/calendar/api.ts | 34 +++++++++++++- .../widgets/collections/calendar/index.tsx | 44 +++---------------- 2 files changed, 40 insertions(+), 38 deletions(-) diff --git a/apps/client/src/widgets/collections/calendar/api.ts b/apps/client/src/widgets/collections/calendar/api.ts index e12844fabd..934edcb2e4 100644 --- a/apps/client/src/widgets/collections/calendar/api.ts +++ b/apps/client/src/widgets/collections/calendar/api.ts @@ -1,7 +1,8 @@ import { CreateChildrenResponse } from "@triliumnext/commons"; import server from "../../../services/server"; import FNote from "../../../entities/fnote"; -import { setLabel } from "../../../services/attributes"; +import { setAttribute, setLabel } from "../../../services/attributes"; +import froca from "../../../services/froca"; interface NewEventOpts { title: string; @@ -11,6 +12,13 @@ interface NewEventOpts { 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(`notes/${parentNote.noteId}/children?target=into`, { @@ -31,3 +39,27 @@ export async function newEvent(parentNote: FNote, { title, startDate, endDate, s 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); + } +} diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index da30ed0ae9..1a4114251d 100644 --- a/apps/client/src/widgets/collections/calendar/index.tsx +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -13,7 +13,7 @@ import { parseStartEndDateFromEvent, parseStartEndTimeFromEvent } from "./utils" import dialog from "../../../services/dialog"; import { t } from "../../../services/i18n"; import { buildEvents, buildEventsForCalendar } from "./event_builder"; -import { newEvent } from "./api"; +import { changeEvent, newEvent } from "./api"; import froca from "../../../services/froca"; interface CalendarViewData { @@ -83,43 +83,13 @@ export default function CalendarView({ note, noteIds }: ViewModeProps { - // Handle start and end date - let { startDate, endDate } = parseStartEndDateFromEvent(e.event); - if (!startDate) { - return; - } - const noteId = e.event.extendedProps.noteId; + const { startDate, endDate } = parseStartEndDateFromEvent(e.event); + if (!startDate) return; - // Don't store the end date if it's empty. - if (endDate === startDate) { - endDate = undefined; - } - - // Update start date - const note = await froca.getNote(noteId); - if (!note) { - return; - } - - // Since they can be customized via calendar:startDate=$foo and calendar:endDate=$bar we need to determine the - // attributes to be effectively updated - const startAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:startDate").shift()?.value||"startDate"; - const endAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:endDate").shift()?.value||"endDate"; - - setLabel(noteId, startAttribute, startDate); - setLabel(noteId, endAttribute, endDate); - - // Update start time and end time if needed. - if (!e.event.allDay) { - const startAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:startTime").shift()?.value||"startTime"; - const endAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:endTime").shift()?.value||"endTime"; - - const { startTime, endTime } = parseStartEndTimeFromEvent(e.event); - if (startTime && endTime) { - setLabel(noteId, startAttribute, startTime); - setLabel(noteId, endAttribute, endTime); - } - } + const { startTime, endTime } = parseStartEndTimeFromEvent(e.event); + const note = await froca.getNote(e.event.extendedProps.noteId); + if (!note) return; + changeEvent(note, { startDate, endDate, startTime, endTime }); }, []); return (plugins && From 85e5f4d2c021829ccce4aa28a5a172926438d20f Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 6 Sep 2025 10:52:14 +0300 Subject: [PATCH 067/233] refactor(react/collections/calendar): add back clicking on date notes --- .../src/widgets/collections/calendar/index.tsx | 14 ++++++++++++++ .../src/widgets/view_widgets/calendar_view.ts | 12 ------------ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index 1a4114251d..be3607f96a 100644 --- a/apps/client/src/widgets/collections/calendar/index.tsx +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -15,6 +15,9 @@ 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"; interface CalendarViewData { @@ -92,6 +95,16 @@ export default function CalendarView({ note, noteIds }: ViewModeProps { + if (!isCalendarRoot) return; + + const eventNote = await date_notes.getDayNote(e.dateStr); + if (eventNote) { + appContext.triggerCommand("openInPopup", { noteIdOrPath: eventNote.noteId }); + } + }, []); + return (plugins &&
{ if (initialView.current !== view.type) { initialView.current = view.type; diff --git a/apps/client/src/widgets/view_widgets/calendar_view.ts b/apps/client/src/widgets/view_widgets/calendar_view.ts index 8448baf1aa..d2e8bcc8d0 100644 --- a/apps/client/src/widgets/view_widgets/calendar_view.ts +++ b/apps/client/src/widgets/view_widgets/calendar_view.ts @@ -86,18 +86,6 @@ export default class CalendarView extends ViewMode<{}> { $(mainContainer ?? e.el).append($(promotedAttributesHtml)); } }, - // Called upon when clicking the day number in the calendar, opens or creates the day note but only if in a calendar root. - dateClick: async (e) => { - if (!this.isCalendarRoot) { - return; - } - - const note = await date_notes.getDayNote(e.dateStr); - if (note) { - appContext.triggerCommand("openInPopup", { noteIdOrPath: note.noteId }); - appContext.triggerCommand("refreshNoteList", { noteId: this.parentNote.noteId }); - } - }, datesSet: (e) => this.#onDatesSet(e), }); From ce67e460c625bfcb2cb56af0c94c35515a5825b3 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 6 Sep 2025 10:53:15 +0300 Subject: [PATCH 068/233] refactor(react/collections/calendar): add a few more options --- apps/client/src/widgets/collections/calendar/index.tsx | 2 ++ apps/client/src/widgets/view_widgets/calendar_view.ts | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index be3607f96a..af027b1efd 100644 --- a/apps/client/src/widgets/collections/calendar/index.tsx +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -120,6 +120,8 @@ export default function CalendarView({ note, noteIds }: ViewModeProps { async renderList(): Promise | undefined> { const calendar = new Calendar(this.$calendarContainer[0], { - height: "100%", - nowIndicator: true, eventDidMount: (e) => { const { iconClass, promotedAttributes } = e.event.extendedProps; From fc52e73153fb4b5b2c884c8a9441073482d7ac95 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 6 Sep 2025 10:54:18 +0300 Subject: [PATCH 069/233] refactor(react/collections/calendar): change event handling --- apps/client/src/widgets/collections/calendar/index.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index af027b1efd..354adf36c8 100644 --- a/apps/client/src/widgets/collections/calendar/index.tsx +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -97,8 +97,6 @@ export default function CalendarView({ note, noteIds }: ViewModeProps { - if (!isCalendarRoot) return; - const eventNote = await date_notes.getDayNote(e.dateStr); if (eventNote) { appContext.triggerCommand("openInPopup", { noteIdOrPath: eventNote.noteId }); @@ -127,7 +125,7 @@ export default function CalendarView({ note, noteIds }: ViewModeProps { if (initialView.current !== view.type) { initialView.current = view.type; From 0cc8b5def097e9b4bbc48a10dd0c4d0541eafa5b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 6 Sep 2025 10:58:24 +0300 Subject: [PATCH 070/233] chore(react/collections/calendar): add back event customization --- .../widgets/collections/calendar/index.tsx | 58 ++++++++++++++++++- .../src/widgets/view_widgets/calendar_view.ts | 49 ---------------- 2 files changed, 57 insertions(+), 50 deletions(-) diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index 354adf36c8..49aec00ccf 100644 --- a/apps/client/src/widgets/collections/calendar/index.tsx +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -1,4 +1,4 @@ -import { DateSelectArg, EventChangeArg, EventSourceFuncArg, LocaleInput, PluginDef } from "@fullcalendar/core/index.js"; +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"; @@ -103,6 +103,8 @@ export default function CalendarView({ note, noteIds }: ViewModeProps { if (initialView.current !== view.type) { initialView.current = view.type; @@ -174,3 +177,56 @@ function useLocale() { return calendarLocale; } + +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*/` `; + 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*/`\ + `; + } + + 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 }; +} diff --git a/apps/client/src/widgets/view_widgets/calendar_view.ts b/apps/client/src/widgets/view_widgets/calendar_view.ts index a608ca3add..8834eb8ecd 100644 --- a/apps/client/src/widgets/view_widgets/calendar_view.ts +++ b/apps/client/src/widgets/view_widgets/calendar_view.ts @@ -35,55 +35,6 @@ export default class CalendarView extends ViewMode<{}> { async renderList(): Promise | undefined> { const calendar = new Calendar(this.$calendarContainer[0], { - eventDidMount: (e) => { - 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*/` `; - 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*/`\ - `; - } - - 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)); - } - }, datesSet: (e) => this.#onDatesSet(e), }); From 69af62cde0f52c6b3025aefc53ef16452dde9608 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 6 Sep 2025 11:06:24 +0300 Subject: [PATCH 071/233] refactor(react/collections/calendar): split editing --- .../widgets/collections/calendar/index.tsx | 81 ++++++++++--------- .../src/widgets/view_widgets/calendar_view.ts | 8 -- 2 files changed, 45 insertions(+), 44 deletions(-) diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index 49aec00ccf..52ca329d88 100644 --- a/apps/client/src/widgets/collections/calendar/index.tsx +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -18,6 +18,7 @@ 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"; interface CalendarViewData { @@ -71,39 +72,8 @@ export default function CalendarView({ note, noteIds }: ViewModeProps { - 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 }); - } - }, []); - const { eventDidMount } = useEventDisplayCustomization(); + const editingProps = useEditing(note, isEditable, isCalendarRoot); return (plugins &&
@@ -124,10 +94,7 @@ export default function CalendarView({ note, noteIds }: ViewModeProps { if (initialView.current !== view.type) { @@ -178,6 +145,48 @@ function useLocale() { 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; diff --git a/apps/client/src/widgets/view_widgets/calendar_view.ts b/apps/client/src/widgets/view_widgets/calendar_view.ts index 8834eb8ecd..5947347135 100644 --- a/apps/client/src/widgets/view_widgets/calendar_view.ts +++ b/apps/client/src/widgets/view_widgets/calendar_view.ts @@ -33,14 +33,6 @@ export default class CalendarView extends ViewMode<{}> { args.$parent.append(this.$root); } - async renderList(): Promise | undefined> { - const calendar = new Calendar(this.$calendarContainer[0], { - datesSet: (e) => this.#onDatesSet(e), - }); - - return this.$root; - } - #onDatesSet(e: DatesSetArg) { if (hasTouchBar) { appContext.triggerCommand("refreshTouchBar"); From 10a6a3056ad301fd684281d91071bccc627a830f Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 6 Sep 2025 11:20:39 +0300 Subject: [PATCH 072/233] chore(react/collections/calendar): reintroduce tests --- .../calendar/event_builder.spec.ts} | 33 ++++++++++--------- .../collections/calendar/event_builder.ts | 2 +- .../widgets/collections/calendar/index.tsx | 18 ++++++---- .../src/widgets/view_widgets/calendar_view.ts | 22 ------------- 4 files changed, 30 insertions(+), 45 deletions(-) rename apps/client/src/widgets/{view_widgets/calendar_view.spec.ts => collections/calendar/event_builder.spec.ts} (87%) diff --git a/apps/client/src/widgets/view_widgets/calendar_view.spec.ts b/apps/client/src/widgets/collections/calendar/event_builder.spec.ts similarity index 87% rename from apps/client/src/widgets/view_widgets/calendar_view.spec.ts rename to apps/client/src/widgets/collections/calendar/event_builder.spec.ts index ad6c38b028..2c872a14e5 100644 --- a/apps/client/src/widgets/view_widgets/calendar_view.spec.ts +++ b/apps/client/src/widgets/collections/calendar/event_builder.spec.ts @@ -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(); } } }); diff --git a/apps/client/src/widgets/collections/calendar/event_builder.ts b/apps/client/src/widgets/collections/calendar/event_builder.ts index c9a13c8161..3ea4c10013 100644 --- a/apps/client/src/widgets/collections/calendar/event_builder.ts +++ b/apps/client/src/widgets/collections/calendar/event_builder.ts @@ -75,7 +75,7 @@ export async function buildEventsForCalendar(note: FNote, e: EventSourceFuncArg) return events.flat(); } -async function buildEvent(note: FNote, { startDate, endDate, startTime, endTime }: Event) { +export async function buildEvent(note: FNote, { startDate, endDate, startTime, endTime }: Event) { const customTitleAttributeName = note.getLabelValue("calendar:title"); const titles = await parseCustomTitle(customTitleAttributeName, note); const color = note.getLabelValue("calendar:color") ?? note.getLabelValue("color"); diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index 52ca329d88..19ff79040c 100644 --- a/apps/client/src/widgets/collections/calendar/index.tsx +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -3,12 +3,9 @@ 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, useTriliumOption, useTriliumOptionInt } from "../../react/hooks"; -import { CreateChildrenResponse, LOCALE_IDS } from "@triliumnext/commons"; +import { useNoteLabel, useNoteLabelBoolean, useResizeObserver, useSpacedUpdate, useTriliumEvent, useTriliumOption, useTriliumOptionInt } from "../../react/hooks"; +import { LOCALE_IDS } from "@triliumnext/commons"; import { Calendar as FullCalendar } from "@fullcalendar/core"; -import { removeOwnedAttributesByNameOrType, setLabel } from "../../../services/attributes"; -import { circle } from "leaflet"; -import server from "../../../services/server"; import { parseStartEndDateFromEvent, parseStartEndTimeFromEvent } from "./utils"; import dialog from "../../../services/dialog"; import { t } from "../../../services/i18n"; @@ -32,7 +29,7 @@ const CALENDAR_VIEWS = [ ] // Here we hard-code the imports in order to ensure that they are embedded by webpack without having to load all the languages. -const LOCALE_MAPPINGS: Record Promise<{ default: LocaleInput }>) | null> = { +export const LOCALE_MAPPINGS: Record Promise<{ default: LocaleInput }>) | null> = { de: () => import("@fullcalendar/core/locales/de"), es: () => import("@fullcalendar/core/locales/es"), fr: () => import("@fullcalendar/core/locales/fr"), @@ -75,6 +72,15 @@ export default function CalendarView({ note, noteIds }: ViewModeProps { + 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 &&
{ } } - async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) { - // Refresh note IDs if they got changed. - if (loadResults.getBranchRows().some((branch) => branch.parentNoteId === this.parentNote.noteId)) { - this.noteIds = this.parentNote.getChildNoteIds(); - } - - // Refresh calendar on attribute change. - if (loadResults.getAttributeRows().some((attribute) => attribute.noteId === this.parentNote.noteId && attribute.name?.startsWith("calendar:") && attribute.name !== "calendar:view")) { - return true; - } - - // Refresh on note title change. - if (loadResults.getNoteIds().some(noteId => this.noteIds.includes(noteId))) { - this.calendar?.refetchEvents(); - } - - // Refresh dataset on subnote change. - if (loadResults.getAttributeRows().some((a) => this.noteIds.includes(a.noteId ?? ""))) { - this.calendar?.refetchEvents(); - } - } - buildTouchBarCommand({ TouchBar, buildIcon }: CommandListenerData<"buildTouchBar">) { if (!this.calendar) { return; From 49c80f0e0bf28d87435c12c5622677fad0312dde Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 6 Sep 2025 11:28:19 +0300 Subject: [PATCH 073/233] fix(client): sql result taking unnecessary space when inactive --- apps/client/src/widgets/sql_result.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/client/src/widgets/sql_result.tsx b/apps/client/src/widgets/sql_result.tsx index 7606517745..e4fde650b9 100644 --- a/apps/client/src/widgets/sql_result.tsx +++ b/apps/client/src/widgets/sql_result.tsx @@ -10,13 +10,14 @@ export default function SqlResults() { const [ results, setResults ] = useState(); useTriliumEvent("sqlQueryResults", ({ ntxId: eventNtxId, results }) => { - if (eventNtxId !== ntxId) return; + if (eventNtxId !== ntxId) return; setResults(results); }) + const isEnabled = note?.mime === "text/x-sqlite;schema=trilium"; return ( -
- {note?.mime === "text/x-sqlite;schema=trilium" && ( +
+ {isEnabled && ( results?.length === 1 && Array.isArray(results[0]) && results[0].length === 0 ? ( {t("sql_result.no_rows")} @@ -26,9 +27,9 @@ export default function SqlResults() { {results?.map(rows => { // inserts, updates if (typeof rows === "object" && !Array.isArray(rows)) { - return
{JSON.stringify(rows, null, "\t")}
+ return
{JSON.stringify(rows, null, "\t")}
} - + // selects return })} @@ -59,4 +60,4 @@ function SqlResultTable({ rows }: { rows: object[] }) { ) -} \ No newline at end of file +} From afc17f41f6022a0699883efb62334c0732744b87 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 6 Sep 2025 12:26:42 +0300 Subject: [PATCH 074/233] feat(collections/calendar): use own UI for header --- apps/client/src/stylesheets/style.css | 5 ++ .../src/translations/en/translation.json | 12 ++- .../widgets/collections/calendar/index.css | 19 ++++- .../widgets/collections/calendar/index.tsx | 75 ++++++++++++++++--- .../client/src/widgets/react/ActionButton.tsx | 13 ++-- apps/client/src/widgets/react/Button.tsx | 12 ++- 6 files changed, 116 insertions(+), 20 deletions(-) diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css index 2aefbbc015..a3e08ff591 100644 --- a/apps/client/src/stylesheets/style.css +++ b/apps/client/src/stylesheets/style.css @@ -293,6 +293,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; } diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 81c0cacc74..d10669b46a 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -587,7 +587,17 @@ "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" }, "close_pane_button": { "close_this_pane": "Close this pane" diff --git a/apps/client/src/widgets/collections/calendar/index.css b/apps/client/src/widgets/collections/calendar/index.css index 69b116a182..2f4103106b 100644 --- a/apps/client/src/widgets/collections/calendar/index.css +++ b/apps/client/src/widgets/collections/calendar/index.css @@ -59,4 +59,21 @@ body.desktop:not(.zen) .calendar-container .fc-toolbar.fc-header-toolbar { font-size: 0.85em; opacity: 0.85; overflow: hidden; -} \ No newline at end of file +} + +/* #region Header */ +.calendar-header { + margin-bottom: 10px; + display: flex; + align-items: center; + gap: 10px; +} + +.calendar-header .btn { + min-width: unset !important; +} + +.calendar-header > .title { + flex-grow: 1; +} +/* #endregion */ \ No newline at end of file diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index 19ff79040c..a135fa9047 100644 --- a/apps/client/src/widgets/collections/calendar/index.tsx +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -16,18 +16,50 @@ 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"; interface CalendarViewData { } +interface CalendarViewData { + type: string; + name: string; + previousText: string; + nextText: string; +} + const CALENDAR_VIEWS = [ - "timeGridWeek", - "dayGridMonth", - "multiMonthYear", - "listMonth" + { + 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 Promise<{ default: LocaleInput }>) | null> = { de: () => import("@fullcalendar/core/locales/de"), @@ -83,20 +115,18 @@ export default function CalendarView({ note, noteIds }: ViewModeProps + }) { + const currentViewType = calendarRef.current?.view?.type; + const currentViewData = CALENDAR_VIEWS.find(v => calendarRef.current && v.type === currentViewType); + + return ( +
+ {calendarRef.current?.view.title} + + {CALENDAR_VIEWS.map(viewData => ( +
+ ) +} + function usePlugins(isEditable: boolean, isCalendarRoot: boolean) { const [ plugins, setPlugins ] = useState(); diff --git a/apps/client/src/widgets/react/ActionButton.tsx b/apps/client/src/widgets/react/ActionButton.tsx index 5e6f3266b5..2eb69bab81 100644 --- a/apps/client/src/widgets/react/ActionButton.tsx +++ b/apps/client/src/widgets/react/ActionButton.tsx @@ -11,18 +11,19 @@ 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(null); const [ keyboardShortcut, setKeyboardShortcut ] = useState(); - + useStaticTooltip(buttonRef, { title: keyboardShortcut?.length ? `${text} (${keyboardShortcut?.join(",")})` : text, placement: titlePosition ?? "bottom", fallbackPlacements: [ titlePosition ?? "bottom" ] }); - + useEffect(() => { if (triggerCommand) { keyboard_actions.getAction(triggerCommand, true).then(action => setKeyboardShortcut(action?.effectiveShortcuts)); @@ -31,8 +32,8 @@ export default function ActionButton({ text, icon, className, onClick, triggerCo return
) } diff --git a/apps/client/src/widgets/collections/table/tabulator.tsx b/apps/client/src/widgets/collections/table/tabulator.tsx new file mode 100644 index 0000000000..ad6eaf7415 --- /dev/null +++ b/apps/client/src/widgets/collections/table/tabulator.tsx @@ -0,0 +1,32 @@ +import { useEffect, useRef } from "preact/hooks"; +import { ColumnDefinition, Tabulator as VanillaTabulator } from "tabulator-tables"; +import "tabulator-tables/dist/css/tabulator.css"; +import "../../../../src/stylesheets/table.css"; + +interface TableProps { + className?: string; + columns: ColumnDefinition[]; + data?: T[]; +} + +export default function Tabulator({ className, columns, data }: TableProps) { + const containerRef = useRef(null); + const tabulatorRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + + const tabulator = new VanillaTabulator(containerRef.current, { + columns, + data + }); + + tabulatorRef.current = tabulator; + + return () => tabulator.destroy(); + }, []); + + return ( +
+ ); +} diff --git a/apps/client/src/widgets/view_widgets/table_view/index.ts b/apps/client/src/widgets/view_widgets/table_view/index.ts index 0b3ac20af7..fc6008e00c 100644 --- a/apps/client/src/widgets/view_widgets/table_view/index.ts +++ b/apps/client/src/widgets/view_widgets/table_view/index.ts @@ -66,8 +66,6 @@ export default class TableView extends ViewMode { let opts: Options = { layout: "fitDataFill", index: "branchId", - columns: columnDefs, - data: rowData, persistence: true, movableColumns: true, movableRows, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a04bf989d6..1034bb8b32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,7 +131,7 @@ importers: version: 9.34.0 '@excalidraw/excalidraw': specifier: 0.18.0 - version: 0.18.0(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react@16.14.0) + version: 0.18.0(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) '@fullcalendar/core': specifier: 6.1.19 version: 6.1.19 @@ -254,10 +254,7 @@ importers: version: 10.27.1 react-i18next: specifier: 15.7.3 - version: 15.7.3(i18next@25.4.2(typescript@5.9.2))(react@16.14.0)(typescript@5.9.2) - react-tabulator: - specifier: 0.21.0 - version: 0.21.0(prop-types@15.8.1)(react@16.14.0) + version: 15.7.3(i18next@25.4.2(typescript@5.9.2))(react-dom@16.14.0(react@16.14.0))(react@16.14.0)(typescript@5.9.2) split.js: specifier: 1.6.5 version: 1.6.5 @@ -297,7 +294,7 @@ importers: version: 6.2.10 copy-webpack-plugin: specifier: 13.0.1 - version: 13.0.1(webpack@5.100.2(esbuild@0.25.9)) + version: 13.0.1(webpack@5.100.2) happy-dom: specifier: 18.0.1 version: 18.0.1 @@ -1654,10 +1651,6 @@ packages: resolution: {integrity: sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==} engines: {node: '>=6.9.0'} - '@babel/types@7.28.4': - resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} - engines: {node: '>=6.9.0'} - '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} @@ -4675,9 +4668,6 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} @@ -5007,9 +4997,6 @@ packages: peerDependencies: '@types/react': ^19.0.0 - '@types/react-tag-autocomplete@5.12.6': - resolution: {integrity: sha512-7TH9bghG+O3fwiyUrpriUs9wrOs744ilay+omshoH9ZzzDOSrNDoGmE0SfFtsxxOe5si93riFc3KHCptzzNQFQ==} - '@types/react@19.1.7': resolution: {integrity: sha512-BnsPLV43ddr05N71gaGzyZ5hzkCmGwhMvYc8zmvI8Ci1bRkkDSzDDVfAXfN2tk748OwI7ediiPX6PfT9p0QGVg==} @@ -7152,10 +7139,6 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} - dotenv@16.6.1: - resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} - engines: {node: '>=12'} - dotenv@17.2.2: resolution: {integrity: sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==} engines: {node: '>=12'} @@ -8299,10 +8282,6 @@ packages: hpack.js@2.1.6: resolution: {integrity: sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==} - html-attributes@1.1.0: - resolution: {integrity: sha512-reT/KK6Ju+DZqAbAn3sIkpMH+658kEsaEjpNrej2O5XSUsH5SzVHX7NGZk5RiZcVi7l+RsV+5q3C6TqM5vxsVA==} - engines: {node: '>= 0.10.26', npm: '>=1.4.3'} - html-encoding-sniffer@2.0.1: resolution: {integrity: sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==} engines: {node: '>=10'} @@ -10444,9 +10423,6 @@ packages: pica@7.1.1: resolution: {integrity: sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ==} - pick-react-known-prop@0.1.5: - resolution: {integrity: sha512-SnDf64AVdvqoAFpHeZUKT9kdn40Ellj84CPALRxYWqNJ6r6f44eAAT+Jtkb0Suhiw7yg5BdOFAQ25OJnjG+afw==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -11588,19 +11564,6 @@ packages: '@types/react': optional: true - react-tabulator@0.21.0: - resolution: {integrity: sha512-CMsxG3hRDay+sXt1RBdmeslQnXLtrHN8PtM0TiN6I15pJ/xIt+x8Vt9nWPm76BD22hucMuw/RHmaoRaoA2xOMQ==} - peerDependencies: - react: '>=15.6.2 || ^16.0.0 || ^17.0.0' - react-dom: '>=15.6.2 || ^16.0.0 || ^17.0.0' - - react-tag-autocomplete@5.13.1: - resolution: {integrity: sha512-ECcQnizAxw8VnEDUfCKuA2ZDQ0Fyxds3kVtE4NVAhJvBYOMMgkRNAM3UwyEXAQ0h7nnCwmIA+czJiwso07Mrqw==} - peerDependencies: - prop-types: ^15.0.0 - react: ^15.0.0 || ^16.0.0 - react-dom: ^15.0.0 || ^16.0.0 - react@16.14.0: resolution: {integrity: sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==} engines: {node: '>=0.10.0'} @@ -12721,10 +12684,6 @@ packages: resolution: {integrity: sha512-ltBPlkvqk3bgCK7/N323atUpP3O3Y+DrGV4dcULrsSn4fZaaNnOmdplNznwfdWclAgvSr5rxjtzn/zJhRm6TKg==} engines: {node: '>=18'} - svg-attributes@1.0.0: - resolution: {integrity: sha512-opDCROGf0kXDKJFg0so8Ydg2jewgaWRuF/35Xxuqox54Rg12rR7pLnRaru06NfJ5WCxjUSRjT5AGcikxMmzG6g==} - engines: {node: '>= 0.10.26', npm: '>=1.4.3'} - svg-pan-zoom@3.6.2: resolution: {integrity: sha512-JwnvRWfVKw/Xzfe6jriFyfey/lWJLq4bUh2jwoR5ChWQuQoOH8FEh1l/bEp46iHHKHEJWIyFJETbazraxNWECg==} @@ -12779,9 +12738,6 @@ packages: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} - tabulator-tables@5.6.1: - resolution: {integrity: sha512-DsmaZqEmlQS/NL5ZJbVtoaeYjJgofEFp+2er7+uwKerGwd/E2rZbeQgux4+Ab1dxNJcbptiX7oUiTwogOnUdgQ==} - tabulator-tables@6.3.1: resolution: {integrity: sha512-qFW7kfadtcaISQIibKAIy0f3eeIXUVi8242Vly1iJfMD79kfEGzfczNuPBN/80hDxHzQJXYbmJ8VipI40hQtfA==} @@ -14453,7 +14409,7 @@ snapshots: '@babel/traverse': 7.28.0 '@babel/types': 7.28.1 convert-source-map: 2.0.0 - debug: 4.4.1(supports-color@6.0.0) + debug: 4.4.1 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -14584,7 +14540,7 @@ snapshots: '@babel/parser': 7.28.0 '@babel/template': 7.27.2 '@babel/types': 7.28.1 - debug: 4.4.1(supports-color@6.0.0) + debug: 4.4.1 transitivePeerDependencies: - supports-color @@ -14598,11 +14554,6 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 - '@babel/types@7.28.4': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 - '@bcoe/v8-coverage@1.0.2': {} '@braintree/sanitize-url@6.0.2': {} @@ -16564,14 +16515,14 @@ snapshots: '@eslint/core': 0.15.2 levn: 0.4.1 - '@excalidraw/excalidraw@0.18.0(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react@16.14.0)': + '@excalidraw/excalidraw@0.18.0(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)': dependencies: '@braintree/sanitize-url': 6.0.2 '@excalidraw/laser-pointer': 1.3.1 '@excalidraw/mermaid-to-excalidraw': 1.1.2 '@excalidraw/random-username': 1.1.0 - '@radix-ui/react-popover': 1.1.6(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react@16.14.0) - '@radix-ui/react-tabs': 1.0.2(react@16.14.0) + '@radix-ui/react-popover': 1.1.6(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) + '@radix-ui/react-tabs': 1.0.2(react-dom@16.14.0(react@16.14.0))(react@16.14.0) browser-fs-access: 0.29.1 canvas-roundrect-polyfill: 0.0.1 clsx: 1.1.1 @@ -16595,6 +16546,7 @@ snapshots: points-on-curve: 1.0.1 pwacompat: 2.0.17 react: 16.14.0 + react-dom: 16.14.0(react@16.14.0) roughjs: 4.6.6 sass: 1.51.0 tunnel-rat: 0.1.2(@types/react@19.1.7)(react@16.14.0) @@ -16634,10 +16586,11 @@ snapshots: '@floating-ui/core': 1.6.9 '@floating-ui/utils': 0.2.9 - '@floating-ui/react-dom@2.1.2(react@16.14.0)': + '@floating-ui/react-dom@2.1.2(react-dom@16.14.0(react@16.14.0))(react@16.14.0)': dependencies: '@floating-ui/dom': 1.6.13 react: 16.14.0 + react-dom: 16.14.0(react@16.14.0) '@floating-ui/utils@0.2.9': {} @@ -16863,7 +16816,7 @@ snapshots: '@antfu/install-pkg': 1.0.0 '@antfu/utils': 8.1.1 '@iconify/types': 2.0.0 - debug: 4.4.1(supports-color@6.0.0) + debug: 4.4.1 globals: 15.15.0 kolorist: 1.8.0 local-pkg: 1.1.1 @@ -17165,7 +17118,7 @@ snapshots: '@jridgewell/source-map@0.3.10': dependencies: '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/trace-mapping': 0.3.30 '@jridgewell/source-map@0.3.6': dependencies: @@ -17646,7 +17599,7 @@ snapshots: '@prefresh/vite': 2.4.8(preact@10.27.1)(vite@7.1.4(@types/node@24.3.0)(jiti@2.5.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)) '@rollup/pluginutils': 4.2.1 babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.28.0) - debug: 4.4.1(supports-color@6.0.0) + debug: 4.4.1 picocolors: 1.1.1 vite: 7.1.4(@types/node@24.3.0)(jiti@2.5.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1) vite-prerender-plugin: 0.5.11(vite@7.1.4(@types/node@24.3.0)(jiti@2.5.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)) @@ -17732,22 +17685,24 @@ snapshots: '@radix-ui/primitive@1.1.1': {} - '@radix-ui/react-arrow@1.1.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react@16.14.0)': + '@radix-ui/react-arrow@1.1.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)': dependencies: - '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react@16.14.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) react: 16.14.0 + react-dom: 16.14.0(react@16.14.0) optionalDependencies: '@types/react': 19.1.7 '@types/react-dom': 19.1.6(@types/react@19.1.7) - '@radix-ui/react-collection@1.0.1(react@16.14.0)': + '@radix-ui/react-collection@1.0.1(react-dom@16.14.0(react@16.14.0))(react@16.14.0)': dependencies: '@babel/runtime': 7.27.6 '@radix-ui/react-compose-refs': 1.0.0(react@16.14.0) '@radix-ui/react-context': 1.0.0(react@16.14.0) - '@radix-ui/react-primitive': 1.0.1(react@16.14.0) + '@radix-ui/react-primitive': 1.0.1(react-dom@16.14.0(react@16.14.0))(react@16.14.0) '@radix-ui/react-slot': 1.0.1(react@16.14.0) react: 16.14.0 + react-dom: 16.14.0(react@16.14.0) '@radix-ui/react-compose-refs@1.0.0(react@16.14.0)': dependencies: @@ -17776,14 +17731,15 @@ snapshots: '@babel/runtime': 7.27.6 react: 16.14.0 - '@radix-ui/react-dismissable-layer@1.1.5(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react@16.14.0)': + '@radix-ui/react-dismissable-layer@1.1.5(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)': dependencies: '@radix-ui/primitive': 1.1.1 '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.7)(react@16.14.0) - '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react@16.14.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.1.7)(react@16.14.0) '@radix-ui/react-use-escape-keydown': 1.1.0(@types/react@19.1.7)(react@16.14.0) react: 16.14.0 + react-dom: 16.14.0(react@16.14.0) optionalDependencies: '@types/react': 19.1.7 '@types/react-dom': 19.1.6(@types/react@19.1.7) @@ -17794,12 +17750,13 @@ snapshots: optionalDependencies: '@types/react': 19.1.7 - '@radix-ui/react-focus-scope@1.1.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react@16.14.0)': + '@radix-ui/react-focus-scope@1.1.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.7)(react@16.14.0) - '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react@16.14.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.1.7)(react@16.14.0) react: 16.14.0 + react-dom: 16.14.0(react@16.14.0) optionalDependencies: '@types/react': 19.1.7 '@types/react-dom': 19.1.6(@types/react@19.1.7) @@ -17817,97 +17774,105 @@ snapshots: optionalDependencies: '@types/react': 19.1.7 - '@radix-ui/react-popover@1.1.6(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react@16.14.0)': + '@radix-ui/react-popover@1.1.6(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)': dependencies: '@radix-ui/primitive': 1.1.1 '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.7)(react@16.14.0) '@radix-ui/react-context': 1.1.1(@types/react@19.1.7)(react@16.14.0) - '@radix-ui/react-dismissable-layer': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react@16.14.0) + '@radix-ui/react-dismissable-layer': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) '@radix-ui/react-focus-guards': 1.1.1(@types/react@19.1.7)(react@16.14.0) - '@radix-ui/react-focus-scope': 1.1.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react@16.14.0) + '@radix-ui/react-focus-scope': 1.1.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) '@radix-ui/react-id': 1.1.0(@types/react@19.1.7)(react@16.14.0) - '@radix-ui/react-popper': 1.2.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react@16.14.0) - '@radix-ui/react-portal': 1.1.4(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react@16.14.0) - '@radix-ui/react-presence': 1.1.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react@16.14.0) - '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react@16.14.0) + '@radix-ui/react-popper': 1.2.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) + '@radix-ui/react-portal': 1.1.4(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) '@radix-ui/react-slot': 1.1.2(@types/react@19.1.7)(react@16.14.0) '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.1.7)(react@16.14.0) aria-hidden: 1.2.4 react: 16.14.0 + react-dom: 16.14.0(react@16.14.0) react-remove-scroll: 2.6.3(@types/react@19.1.7)(react@16.14.0) optionalDependencies: '@types/react': 19.1.7 '@types/react-dom': 19.1.6(@types/react@19.1.7) - '@radix-ui/react-popper@1.2.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react@16.14.0)': + '@radix-ui/react-popper@1.2.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)': dependencies: - '@floating-ui/react-dom': 2.1.2(react@16.14.0) - '@radix-ui/react-arrow': 1.1.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react@16.14.0) + '@floating-ui/react-dom': 2.1.2(react-dom@16.14.0(react@16.14.0))(react@16.14.0) + '@radix-ui/react-arrow': 1.1.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.7)(react@16.14.0) '@radix-ui/react-context': 1.1.1(@types/react@19.1.7)(react@16.14.0) - '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react@16.14.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.1.7)(react@16.14.0) '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.1.7)(react@16.14.0) '@radix-ui/react-use-rect': 1.1.0(@types/react@19.1.7)(react@16.14.0) '@radix-ui/react-use-size': 1.1.0(@types/react@19.1.7)(react@16.14.0) '@radix-ui/rect': 1.1.0 react: 16.14.0 + react-dom: 16.14.0(react@16.14.0) optionalDependencies: '@types/react': 19.1.7 '@types/react-dom': 19.1.6(@types/react@19.1.7) - '@radix-ui/react-portal@1.1.4(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react@16.14.0)': + '@radix-ui/react-portal@1.1.4(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)': dependencies: - '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react@16.14.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.1.7)(react@16.14.0) react: 16.14.0 + react-dom: 16.14.0(react@16.14.0) optionalDependencies: '@types/react': 19.1.7 '@types/react-dom': 19.1.6(@types/react@19.1.7) - '@radix-ui/react-presence@1.0.0(react@16.14.0)': + '@radix-ui/react-presence@1.0.0(react-dom@16.14.0(react@16.14.0))(react@16.14.0)': dependencies: '@babel/runtime': 7.27.6 '@radix-ui/react-compose-refs': 1.0.0(react@16.14.0) '@radix-ui/react-use-layout-effect': 1.0.0(react@16.14.0) react: 16.14.0 + react-dom: 16.14.0(react@16.14.0) - '@radix-ui/react-presence@1.1.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react@16.14.0)': + '@radix-ui/react-presence@1.1.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.7)(react@16.14.0) '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.1.7)(react@16.14.0) react: 16.14.0 + react-dom: 16.14.0(react@16.14.0) optionalDependencies: '@types/react': 19.1.7 '@types/react-dom': 19.1.6(@types/react@19.1.7) - '@radix-ui/react-primitive@1.0.1(react@16.14.0)': + '@radix-ui/react-primitive@1.0.1(react-dom@16.14.0(react@16.14.0))(react@16.14.0)': dependencies: '@babel/runtime': 7.27.6 '@radix-ui/react-slot': 1.0.1(react@16.14.0) react: 16.14.0 + react-dom: 16.14.0(react@16.14.0) - '@radix-ui/react-primitive@2.0.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react@16.14.0)': + '@radix-ui/react-primitive@2.0.2(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)': dependencies: '@radix-ui/react-slot': 1.1.2(@types/react@19.1.7)(react@16.14.0) react: 16.14.0 + react-dom: 16.14.0(react@16.14.0) optionalDependencies: '@types/react': 19.1.7 '@types/react-dom': 19.1.6(@types/react@19.1.7) - '@radix-ui/react-roving-focus@1.0.2(react@16.14.0)': + '@radix-ui/react-roving-focus@1.0.2(react-dom@16.14.0(react@16.14.0))(react@16.14.0)': dependencies: '@babel/runtime': 7.27.6 '@radix-ui/primitive': 1.0.0 - '@radix-ui/react-collection': 1.0.1(react@16.14.0) + '@radix-ui/react-collection': 1.0.1(react-dom@16.14.0(react@16.14.0))(react@16.14.0) '@radix-ui/react-compose-refs': 1.0.0(react@16.14.0) '@radix-ui/react-context': 1.0.0(react@16.14.0) '@radix-ui/react-direction': 1.0.0(react@16.14.0) '@radix-ui/react-id': 1.0.0(react@16.14.0) - '@radix-ui/react-primitive': 1.0.1(react@16.14.0) + '@radix-ui/react-primitive': 1.0.1(react-dom@16.14.0(react@16.14.0))(react@16.14.0) '@radix-ui/react-use-callback-ref': 1.0.0(react@16.14.0) '@radix-ui/react-use-controllable-state': 1.0.0(react@16.14.0) react: 16.14.0 + react-dom: 16.14.0(react@16.14.0) '@radix-ui/react-slot@1.0.1(react@16.14.0)': dependencies: @@ -17922,18 +17887,19 @@ snapshots: optionalDependencies: '@types/react': 19.1.7 - '@radix-ui/react-tabs@1.0.2(react@16.14.0)': + '@radix-ui/react-tabs@1.0.2(react-dom@16.14.0(react@16.14.0))(react@16.14.0)': dependencies: '@babel/runtime': 7.27.6 '@radix-ui/primitive': 1.0.0 '@radix-ui/react-context': 1.0.0(react@16.14.0) '@radix-ui/react-direction': 1.0.0(react@16.14.0) '@radix-ui/react-id': 1.0.0(react@16.14.0) - '@radix-ui/react-presence': 1.0.0(react@16.14.0) - '@radix-ui/react-primitive': 1.0.1(react@16.14.0) - '@radix-ui/react-roving-focus': 1.0.2(react@16.14.0) + '@radix-ui/react-presence': 1.0.0(react-dom@16.14.0(react@16.14.0))(react@16.14.0) + '@radix-ui/react-primitive': 1.0.1(react-dom@16.14.0(react@16.14.0))(react@16.14.0) + '@radix-ui/react-roving-focus': 1.0.2(react-dom@16.14.0(react@16.14.0))(react@16.14.0) '@radix-ui/react-use-controllable-state': 1.0.0(react@16.14.0) react: 16.14.0 + react-dom: 16.14.0(react@16.14.0) '@radix-ui/react-use-callback-ref@1.0.0(react@16.14.0)': dependencies: @@ -18982,10 +18948,6 @@ snapshots: '@types/aria-query@5.0.4': {} - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.28.4 - '@types/better-sqlite3@7.6.13': dependencies: '@types/node': 22.15.21 @@ -19359,7 +19321,6 @@ snapshots: '@types/node@24.3.0': dependencies: undici-types: 7.10.0 - optional: true '@types/parse-json@4.0.2': {} @@ -19378,13 +19339,10 @@ snapshots: '@types/react': 19.1.7 optional: true - '@types/react-tag-autocomplete@5.12.6': - dependencies: - '@types/react': 19.1.7 - '@types/react@19.1.7': dependencies: csstype: 3.1.3 + optional: true '@types/readdir-glob@1.1.5': dependencies: @@ -21199,6 +21157,15 @@ snapshots: tinyglobby: 0.2.14 webpack: 5.100.2(esbuild@0.25.9) + copy-webpack-plugin@13.0.1(webpack@5.100.2): + dependencies: + glob-parent: 6.0.2 + normalize-path: 3.0.0 + schema-utils: 4.3.2 + serialize-javascript: 6.0.2 + tinyglobby: 0.2.14 + webpack: 5.100.2 + core-util-is@1.0.3: {} corser@2.0.1: {} @@ -21587,7 +21554,8 @@ snapshots: '@asamuzakjp/css-color': 3.1.4 rrweb-cssom: 0.8.0 - csstype@3.1.3: {} + csstype@3.1.3: + optional: true cytoscape-cose-bilkent@4.1.0(cytoscape@3.31.2): dependencies: @@ -21838,6 +21806,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.1: + dependencies: + ms: 2.1.3 + debug@4.4.1(supports-color@6.0.0): dependencies: ms: 2.1.3 @@ -22058,8 +22030,6 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 - dotenv@16.6.1: {} - dotenv@17.2.2: {} dotignore@0.1.2: @@ -23622,8 +23592,6 @@ snapshots: readable-stream: 2.3.8 wbuf: 1.7.3 - html-attributes@1.1.0: {} - html-encoding-sniffer@2.0.1: dependencies: whatwg-encoding: 1.0.5 @@ -24218,7 +24186,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 22.18.0 + '@types/node': 24.3.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -26152,12 +26120,6 @@ snapshots: object-assign: 4.1.1 webworkify: 1.5.0 - pick-react-known-prop@0.1.5: - dependencies: - html-attributes: 1.1.0 - lodash.isplainobject: 4.0.6 - svg-attributes: 1.0.0 - picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -27232,13 +27194,14 @@ snapshots: react: 16.14.0 scheduler: 0.19.1 - react-i18next@15.7.3(i18next@25.4.2(typescript@5.9.2))(react@16.14.0)(typescript@5.9.2): + react-i18next@15.7.3(i18next@25.4.2(typescript@5.9.2))(react-dom@16.14.0(react@16.14.0))(react@16.14.0)(typescript@5.9.2): dependencies: '@babel/runtime': 7.27.6 html-parse-stringify: 3.0.1 i18next: 25.4.2(typescript@5.9.2) react: 16.14.0 optionalDependencies: + react-dom: 16.14.0(react@16.14.0) typescript: 5.9.2 react-interactive@0.8.3(react@16.14.0): @@ -27302,23 +27265,6 @@ snapshots: optionalDependencies: '@types/react': 19.1.7 - react-tabulator@0.21.0(prop-types@15.8.1)(react@16.14.0): - dependencies: - '@types/babel__traverse': 7.28.0 - '@types/react-tag-autocomplete': 5.12.6 - dotenv: 16.6.1 - pick-react-known-prop: 0.1.5 - react: 16.14.0 - react-tag-autocomplete: 5.13.1(prop-types@15.8.1)(react@16.14.0) - tabulator-tables: 5.6.1 - transitivePeerDependencies: - - prop-types - - react-tag-autocomplete@5.13.1(prop-types@15.8.1)(react@16.14.0): - dependencies: - prop-types: 15.8.1 - react: 16.14.0 - react@16.14.0: dependencies: loose-envify: 1.4.0 @@ -28775,8 +28721,6 @@ snapshots: magic-string: 0.30.18 zimmerframe: 1.1.2 - svg-attributes@1.0.0: {} - svg-pan-zoom@3.6.2: {} svg-tags@1.0.0: {} @@ -28855,8 +28799,6 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - tabulator-tables@5.6.1: {} - tabulator-tables@6.3.1: {} tailwindcss@4.1.12: {} @@ -28960,6 +28902,15 @@ snapshots: optionalDependencies: esbuild: 0.25.9 + terser-webpack-plugin@5.3.14(webpack@5.100.2): + dependencies: + '@jridgewell/trace-mapping': 0.3.30 + jest-worker: 27.5.1 + schema-utils: 4.3.2 + serialize-javascript: 6.0.2 + terser: 5.43.1 + webpack: 5.100.2 + terser@5.39.0: dependencies: '@jridgewell/source-map': 0.3.6 @@ -29336,8 +29287,7 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.10.0: - optional: true + undici-types@7.10.0: {} undici@6.21.3: {} @@ -29961,6 +29911,38 @@ snapshots: webpack-virtual-modules@0.6.2: {} + webpack@5.100.2: + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.15.0 + acorn-import-phases: 1.0.4(acorn@8.15.0) + browserslist: 4.25.1 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.18.3 + es-module-lexer: 1.7.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.0 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.2 + tapable: 2.2.2 + terser-webpack-plugin: 5.3.14(webpack@5.100.2) + watchpack: 2.4.4 + webpack-sources: 3.3.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + webpack@5.100.2(@swc/core@1.11.29(@swc/helpers@0.5.17))(esbuild@0.25.9): dependencies: '@types/eslint-scope': 3.7.7 From 9d877ec97a59757b5f14b9be237d787968ab8ee1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 6 Sep 2025 19:19:52 +0300 Subject: [PATCH 096/233] chore(react/collections/table): enable modules --- apps/client/src/widgets/collections/table/index.tsx | 3 ++- .../src/widgets/collections/table/tabulator.tsx | 12 ++++++++++-- .../src/widgets/view_widgets/table_view/index.ts | 2 -- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/client/src/widgets/collections/table/index.tsx b/apps/client/src/widgets/collections/table/index.tsx index 5ad6ac3f2d..dbf5055d0f 100644 --- a/apps/client/src/widgets/collections/table/index.tsx +++ b/apps/client/src/widgets/collections/table/index.tsx @@ -1,12 +1,12 @@ import { useEffect, useState } from "preact/hooks"; import { ViewModeProps } from "../interface"; import "./index.css"; -import { ColumnDefinition } from "tabulator-tables"; import { buildColumnDefinitions } from "./columns"; import getAttributeDefinitionInformation, { buildRowDefinitions, TableData } from "./rows"; import { useNoteLabelInt } from "../../react/hooks"; import { canReorderRows } from "../../view_widgets/table_view/dragging"; import Tabulator from "./tabulator"; +import {SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, ColumnDefinition, DataTreeModule} from 'tabulator-tables'; interface TableConfig { tableData?: { @@ -41,6 +41,7 @@ export default function TableView({ note, viewConfig }: ViewModeProps )}
diff --git a/apps/client/src/widgets/collections/table/tabulator.tsx b/apps/client/src/widgets/collections/table/tabulator.tsx index ad6eaf7415..a04838c848 100644 --- a/apps/client/src/widgets/collections/table/tabulator.tsx +++ b/apps/client/src/widgets/collections/table/tabulator.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from "preact/hooks"; -import { ColumnDefinition, Tabulator as VanillaTabulator } from "tabulator-tables"; +import { ColumnDefinition, Module, Tabulator as VanillaTabulator } from "tabulator-tables"; import "tabulator-tables/dist/css/tabulator.css"; import "../../../../src/stylesheets/table.css"; @@ -7,12 +7,20 @@ interface TableProps { className?: string; columns: ColumnDefinition[]; data?: T[]; + modules?: (new (table: VanillaTabulator) => Module)[]; } -export default function Tabulator({ className, columns, data }: TableProps) { +export default function Tabulator({ className, columns, data, modules }: TableProps) { const containerRef = useRef(null); const tabulatorRef = useRef(null); + useEffect(() => { + if (!modules) return; + for (const module of modules) { + VanillaTabulator.registerModule(module); + } + }, [modules]); + useEffect(() => { if (!containerRef.current) return; diff --git a/apps/client/src/widgets/view_widgets/table_view/index.ts b/apps/client/src/widgets/view_widgets/table_view/index.ts index fc6008e00c..2d030f4cd7 100644 --- a/apps/client/src/widgets/view_widgets/table_view/index.ts +++ b/apps/client/src/widgets/view_widgets/table_view/index.ts @@ -2,7 +2,6 @@ import ViewMode, { type ViewModeArgs } from "../view_mode.js"; import attributes from "../../../services/attributes.js"; import SpacedUpdate from "../../../services/spaced_update.js"; import type { EventData } from "../../../components/app_context.js"; -import {Tabulator, SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, ColumnDefinition, DataTreeModule, Options, RowComponent, ColumnComponent} from 'tabulator-tables'; import { canReorderRows, configureReorderingRows } from "./dragging.js"; import buildFooter from "./footer.js"; @@ -51,7 +50,6 @@ export default class TableView extends ViewMode { } private async renderTable(el: HTMLElement) { - const modules = [ SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, DataTreeModule ]; for (const module of modules) { Tabulator.registerModule(module); } From 76e903a78266cfca9f857da42679c3e4c5288cd2 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 6 Sep 2025 20:25:50 +0300 Subject: [PATCH 097/233] chore(react/collections/table): set up context menu partially --- .../table}/context_menu.ts | 84 +++++++++---------- .../src/widgets/collections/table/index.tsx | 13 ++- .../widgets/collections/table/tabulator.tsx | 30 +++++-- 3 files changed, 72 insertions(+), 55 deletions(-) rename apps/client/src/widgets/{view_widgets/table_view => collections/table}/context_menu.ts (74%) diff --git a/apps/client/src/widgets/view_widgets/table_view/context_menu.ts b/apps/client/src/widgets/collections/table/context_menu.ts similarity index 74% rename from apps/client/src/widgets/view_widgets/table_view/context_menu.ts rename to apps/client/src/widgets/collections/table/context_menu.ts index 21f434d7d1..37caf16635 100644 --- a/apps/client/src/widgets/view_widgets/table_view/context_menu.ts +++ b/apps/client/src/widgets/collections/table/context_menu.ts @@ -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): Partial { + const events: Partial = {}; + 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,8 +158,7 @@ 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; let parentNoteId: string = parentNote.noteId; @@ -175,7 +177,7 @@ 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", { + handler: () => parentComponent?.triggerCommand("addNewRow", { parentNotePath: parentNoteId, customOpts: { target: "before", @@ -189,7 +191,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 +203,7 @@ 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", { + handler: () => parentComponent?.triggerCommand("addNewRow", { parentNotePath: parentNoteId, customOpts: { target: "after", @@ -223,16 +225,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[] = []; for (const column of tabulator.getColumns()) { @@ -249,13 +241,13 @@ function buildColumnItems(tabulator: Tabulator) { return items; } -function buildInsertSubmenu(e: MouseEvent, referenceColumn?: ColumnComponent, direction?: "before" | "after"): MenuItem[] { +function buildInsertSubmenu(parentComponent: Component, referenceColumn?: ColumnComponent, direction?: "before" | "after"): MenuItem[] { 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 +258,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 diff --git a/apps/client/src/widgets/collections/table/index.tsx b/apps/client/src/widgets/collections/table/index.tsx index dbf5055d0f..c887b4fec1 100644 --- a/apps/client/src/widgets/collections/table/index.tsx +++ b/apps/client/src/widgets/collections/table/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "preact/hooks"; +import { useContext, useEffect, useRef, useState } from "preact/hooks"; import { ViewModeProps } from "../interface"; import "./index.css"; import { buildColumnDefinitions } from "./columns"; @@ -6,8 +6,9 @@ import getAttributeDefinitionInformation, { buildRowDefinitions, TableData } fro import { useNoteLabelInt } from "../../react/hooks"; import { canReorderRows } from "../../view_widgets/table_view/dragging"; import Tabulator from "./tabulator"; -import {SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, ColumnDefinition, DataTreeModule} from 'tabulator-tables'; - +import { Tabulator as VanillaTabulator, SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, ColumnDefinition, DataTreeModule} from 'tabulator-tables'; +import { useContextMenu } from "./context_menu"; +import { ParentComponent } from "../../react/react_utils"; interface TableConfig { tableData?: { columns?: ColumnDefinition[]; @@ -18,6 +19,8 @@ export default function TableView({ note, viewConfig }: ViewModeProps(); const [ rowData, setRowData ] = useState(); + const tabulatorRef = useRef(null); + const parentComponent = useContext(ParentComponent); useEffect(() => { const info = getAttributeDefinitionInformation(note); @@ -34,14 +37,18 @@ export default function TableView({ note, viewConfig }: ViewModeProps {columnDefs && ( )}
diff --git a/apps/client/src/widgets/collections/table/tabulator.tsx b/apps/client/src/widgets/collections/table/tabulator.tsx index a04838c848..8deb1f9e3b 100644 --- a/apps/client/src/widgets/collections/table/tabulator.tsx +++ b/apps/client/src/widgets/collections/table/tabulator.tsx @@ -1,27 +1,29 @@ -import { useEffect, useRef } from "preact/hooks"; -import { ColumnDefinition, Module, Tabulator as VanillaTabulator } from "tabulator-tables"; +import { useEffect, useLayoutEffect, useRef } from "preact/hooks"; +import { ColumnDefinition, EventCallBackMethods, Module, Tabulator as VanillaTabulator } from "tabulator-tables"; import "tabulator-tables/dist/css/tabulator.css"; import "../../../../src/stylesheets/table.css"; +import { RefObject } from "preact"; -interface TableProps { +interface TableProps extends Partial { + tabulatorRef: RefObject; className?: string; columns: ColumnDefinition[]; data?: T[]; modules?: (new (table: VanillaTabulator) => Module)[]; } -export default function Tabulator({ className, columns, data, modules }: TableProps) { +export default function Tabulator({ className, columns, data, modules, tabulatorRef: externalTabulatorRef, ...events }: TableProps) { const containerRef = useRef(null); const tabulatorRef = useRef(null); - useEffect(() => { + useLayoutEffect(() => { if (!modules) return; for (const module of modules) { VanillaTabulator.registerModule(module); } }, [modules]); - useEffect(() => { + useLayoutEffect(() => { if (!containerRef.current) return; const tabulator = new VanillaTabulator(containerRef.current, { @@ -30,10 +32,26 @@ export default function Tabulator({ className, columns, data, modules }: Tabl }); tabulatorRef.current = tabulator; + externalTabulatorRef.current = tabulator; return () => tabulator.destroy(); }, []); + useEffect(() => { + const tabulator = tabulatorRef.current; + if (!tabulator) 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)); + return (
); From ff3800820757ca9a37054cd59d04a3c168ba6a90 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 6 Sep 2025 20:31:44 +0300 Subject: [PATCH 098/233] chore(react/collections/table): react to note changes --- apps/client/src/widgets/collections/table/tabulator.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/client/src/widgets/collections/table/tabulator.tsx b/apps/client/src/widgets/collections/table/tabulator.tsx index 8deb1f9e3b..ff1d6556fd 100644 --- a/apps/client/src/widgets/collections/table/tabulator.tsx +++ b/apps/client/src/widgets/collections/table/tabulator.tsx @@ -52,6 +52,9 @@ export default function Tabulator({ className, columns, data, modules, tabula } }, Object.values(events)); + // Change in data. + useEffect(() => { tabulatorRef.current?.setData(data) }, [ data ]); + return (
); From cd67299b1d7f95a8b46a57db69057de9a7072c02 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 6 Sep 2025 21:08:32 +0300 Subject: [PATCH 099/233] chore(react/collections/table): bring back footer --- .../src/widgets/collections/table/index.tsx | 38 ++++++++++++++----- .../widgets/collections/table/tabulator.tsx | 12 ++++-- .../widgets/view_widgets/table_view/footer.ts | 22 ----------- 3 files changed, 37 insertions(+), 35 deletions(-) delete mode 100644 apps/client/src/widgets/view_widgets/table_view/footer.ts diff --git a/apps/client/src/widgets/collections/table/index.tsx b/apps/client/src/widgets/collections/table/index.tsx index c887b4fec1..b162cbfa41 100644 --- a/apps/client/src/widgets/collections/table/index.tsx +++ b/apps/client/src/widgets/collections/table/index.tsx @@ -1,6 +1,5 @@ import { useContext, useEffect, useRef, useState } from "preact/hooks"; import { ViewModeProps } from "../interface"; -import "./index.css"; import { buildColumnDefinitions } from "./columns"; import getAttributeDefinitionInformation, { buildRowDefinitions, TableData } from "./rows"; import { useNoteLabelInt } from "../../react/hooks"; @@ -9,6 +8,11 @@ import Tabulator from "./tabulator"; import { Tabulator as VanillaTabulator, SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, ColumnDefinition, DataTreeModule} 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"; + interface TableConfig { tableData?: { columns?: ColumnDefinition[]; @@ -42,15 +46,31 @@ export default function TableView({ note, viewConfig }: ViewModeProps {columnDefs && ( - + <> + } + {...contextMenuEvents} + /> + + )}
) } + +function TableFooter({ note }: { note: FNote }) { + return (note.type !== "search" && +
+
+
+
+ ) +} diff --git a/apps/client/src/widgets/collections/table/tabulator.tsx b/apps/client/src/widgets/collections/table/tabulator.tsx index ff1d6556fd..22b4531103 100644 --- a/apps/client/src/widgets/collections/table/tabulator.tsx +++ b/apps/client/src/widgets/collections/table/tabulator.tsx @@ -1,8 +1,9 @@ -import { useEffect, useLayoutEffect, useRef } from "preact/hooks"; +import { useContext, useEffect, useLayoutEffect, useRef } from "preact/hooks"; import { ColumnDefinition, EventCallBackMethods, Module, Tabulator as VanillaTabulator } from "tabulator-tables"; import "tabulator-tables/dist/css/tabulator.css"; import "../../../../src/stylesheets/table.css"; -import { RefObject } from "preact"; +import { ComponentChildren, RefObject } from "preact"; +import { ParentComponent, renderReactWidget } from "../../react/react_utils"; interface TableProps extends Partial { tabulatorRef: RefObject; @@ -10,9 +11,11 @@ interface TableProps extends Partial { columns: ColumnDefinition[]; data?: T[]; modules?: (new (table: VanillaTabulator) => Module)[]; + footerElement?: ComponentChildren; } -export default function Tabulator({ className, columns, data, modules, tabulatorRef: externalTabulatorRef, ...events }: TableProps) { +export default function Tabulator({ className, columns, data, modules, tabulatorRef: externalTabulatorRef, footerElement, ...events }: TableProps) { + const parentComponent = useContext(ParentComponent); const containerRef = useRef(null); const tabulatorRef = useRef(null); @@ -28,7 +31,8 @@ export default function Tabulator({ className, columns, data, modules, tabula const tabulator = new VanillaTabulator(containerRef.current, { columns, - data + data, + footerElement: (parentComponent && footerElement ? renderReactWidget(parentComponent, footerElement)[0] : undefined) }); tabulatorRef.current = tabulator; diff --git a/apps/client/src/widgets/view_widgets/table_view/footer.ts b/apps/client/src/widgets/view_widgets/table_view/footer.ts deleted file mode 100644 index 858b792c41..0000000000 --- a/apps/client/src/widgets/view_widgets/table_view/footer.ts +++ /dev/null @@ -1,22 +0,0 @@ -import FNote from "../../../entities/fnote.js"; -import { t } from "../../../services/i18n.js"; - -function shouldDisplayFooter(parentNote: FNote) { - return (parentNote.type !== "search"); -} - -export default function buildFooter(parentNote: FNote) { - if (!shouldDisplayFooter(parentNote)) { - return undefined; - } - - return /*html*/`\ - - - - `.trimStart(); -} From e761cd7c277bf9a6d768fc524243992a597d4183 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 7 Sep 2025 19:03:16 +0300 Subject: [PATCH 100/233] chore(react/collections/table): set up writing to attachment --- .../src/widgets/collections/table/index.tsx | 30 ++++++++++++++++--- .../widgets/collections/table/tabulator.tsx | 9 +++--- .../widgets/view_widgets/table_view/index.ts | 12 -------- 3 files changed, 31 insertions(+), 20 deletions(-) diff --git a/apps/client/src/widgets/collections/table/index.tsx b/apps/client/src/widgets/collections/table/index.tsx index b162cbfa41..b9f6a8d7aa 100644 --- a/apps/client/src/widgets/collections/table/index.tsx +++ b/apps/client/src/widgets/collections/table/index.tsx @@ -1,8 +1,8 @@ -import { useContext, useEffect, useRef, useState } from "preact/hooks"; +import { useCallback, useContext, useEffect, useRef, useState } from "preact/hooks"; import { ViewModeProps } from "../interface"; import { buildColumnDefinitions } from "./columns"; import getAttributeDefinitionInformation, { buildRowDefinitions, TableData } from "./rows"; -import { useNoteLabelInt } from "../../react/hooks"; +import { useNoteLabelInt, useSpacedUpdate } from "../../react/hooks"; import { canReorderRows } from "../../view_widgets/table_view/dragging"; import Tabulator from "./tabulator"; import { Tabulator as VanillaTabulator, SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, ColumnDefinition, DataTreeModule} from 'tabulator-tables'; @@ -19,7 +19,7 @@ interface TableConfig { }; } -export default function TableView({ note, viewConfig }: ViewModeProps) { +export default function TableView({ note, viewConfig, saveConfig }: ViewModeProps) { const [ maxDepth ] = useNoteLabelInt(note, "maxNestingDepth") ?? -1; const [ columnDefs, setColumnDefs ] = useState(); const [ rowData, setRowData ] = useState(); @@ -42,10 +42,12 @@ export default function TableView({ note, viewConfig }: ViewModeProps - {columnDefs && ( + {viewConfig && columnDefs && ( <> } {...contextMenuEvents} + persistence {...persistenceProps} /> @@ -74,3 +77,22 @@ function TableFooter({ note }: { note: FNote }) {
) } + +function usePersistence(initialConfig: TableConfig | null | undefined, saveConfig: (newConfig: TableConfig) => void) { + const config = useRef(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)[type] = data; + spacedUpdate.scheduleUpdate(); + }, []); + const persistenceReaderFunc = useCallback((_id, type: string) => { + return config.current?.tableData?.[type]; + }, []); + return { persistenceReaderFunc, persistenceWriterFunc }; +} diff --git a/apps/client/src/widgets/collections/table/tabulator.tsx b/apps/client/src/widgets/collections/table/tabulator.tsx index 22b4531103..90dee5efba 100644 --- a/apps/client/src/widgets/collections/table/tabulator.tsx +++ b/apps/client/src/widgets/collections/table/tabulator.tsx @@ -1,11 +1,11 @@ import { useContext, useEffect, useLayoutEffect, useRef } from "preact/hooks"; -import { ColumnDefinition, EventCallBackMethods, Module, Tabulator as VanillaTabulator } from "tabulator-tables"; +import { ColumnDefinition, EventCallBackMethods, Module, Options, Tabulator as VanillaTabulator } from "tabulator-tables"; import "tabulator-tables/dist/css/tabulator.css"; import "../../../../src/stylesheets/table.css"; import { ComponentChildren, RefObject } from "preact"; import { ParentComponent, renderReactWidget } from "../../react/react_utils"; -interface TableProps extends Partial { +interface TableProps extends Partial, Pick { tabulatorRef: RefObject; className?: string; columns: ColumnDefinition[]; @@ -14,7 +14,7 @@ interface TableProps extends Partial { footerElement?: ComponentChildren; } -export default function Tabulator({ className, columns, data, modules, tabulatorRef: externalTabulatorRef, footerElement, ...events }: TableProps) { +export default function Tabulator({ className, columns, data, modules, tabulatorRef: externalTabulatorRef, footerElement, persistence, persistenceReaderFunc, persistenceWriterFunc, ...events }: TableProps) { const parentComponent = useContext(ParentComponent); const containerRef = useRef(null); const tabulatorRef = useRef(null); @@ -32,7 +32,8 @@ export default function Tabulator({ className, columns, data, modules, tabula const tabulator = new VanillaTabulator(containerRef.current, { columns, data, - footerElement: (parentComponent && footerElement ? renderReactWidget(parentComponent, footerElement)[0] : undefined) + footerElement: (parentComponent && footerElement ? renderReactWidget(parentComponent, footerElement)[0] : undefined), + persistence, persistenceReaderFunc, persistenceWriterFunc }); tabulatorRef.current = tabulator; diff --git a/apps/client/src/widgets/view_widgets/table_view/index.ts b/apps/client/src/widgets/view_widgets/table_view/index.ts index 2d030f4cd7..4383eada3a 100644 --- a/apps/client/src/widgets/view_widgets/table_view/index.ts +++ b/apps/client/src/widgets/view_widgets/table_view/index.ts @@ -64,15 +64,9 @@ export default class TableView extends ViewMode { let opts: Options = { layout: "fitDataFill", index: "branchId", - persistence: true, movableColumns: true, movableRows, footerElement: buildFooter(this.parentNote), - persistenceWriterFunc: (_id, type: string, data: object) => { - (this.persistentData as Record)[type] = data; - this.spacedUpdate.scheduleUpdate(); - }, - persistenceReaderFunc: (_id, type: string) => this.persistentData?.[type], }; if (hasChildren) { @@ -99,12 +93,6 @@ export default class TableView extends ViewMode { setupContextMenu(this.api, this.parentNote); } - private onSave() { - this.viewStorage.store({ - tableData: this.persistentData, - }); - } - async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) { if (!this.api) { return; From e25c5cc6c7326d1d08c9e51fffb10ae3ea737ef9 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 7 Sep 2025 19:19:09 +0300 Subject: [PATCH 101/233] refactor(react/collections/table): move events to dedicated prop --- apps/client/src/widgets/collections/table/index.tsx | 2 +- .../src/widgets/collections/table/tabulator.tsx | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/client/src/widgets/collections/table/index.tsx b/apps/client/src/widgets/collections/table/index.tsx index b9f6a8d7aa..e9c8b88021 100644 --- a/apps/client/src/widgets/collections/table/index.tsx +++ b/apps/client/src/widgets/collections/table/index.tsx @@ -56,7 +56,7 @@ export default function TableView({ note, viewConfig, saveConfig }: ViewModeProp data={rowData} modules={[ SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, DataTreeModule ]} footerElement={} - {...contextMenuEvents} + events={contextMenuEvents} persistence {...persistenceProps} /> diff --git a/apps/client/src/widgets/collections/table/tabulator.tsx b/apps/client/src/widgets/collections/table/tabulator.tsx index 90dee5efba..4ed890aa9e 100644 --- a/apps/client/src/widgets/collections/table/tabulator.tsx +++ b/apps/client/src/widgets/collections/table/tabulator.tsx @@ -5,16 +5,17 @@ import "../../../../src/stylesheets/table.css"; import { ComponentChildren, RefObject } from "preact"; import { ParentComponent, renderReactWidget } from "../../react/react_utils"; -interface TableProps extends Partial, Pick { +interface TableProps extends Pick { tabulatorRef: RefObject; className?: string; columns: ColumnDefinition[]; data?: T[]; modules?: (new (table: VanillaTabulator) => Module)[]; footerElement?: ComponentChildren; + events?: Partial; } -export default function Tabulator({ className, columns, data, modules, tabulatorRef: externalTabulatorRef, footerElement, persistence, persistenceReaderFunc, persistenceWriterFunc, ...events }: TableProps) { +export default function Tabulator({ className, columns, data, modules, tabulatorRef: externalTabulatorRef, footerElement, events, ...restProps }: TableProps) { const parentComponent = useContext(ParentComponent); const containerRef = useRef(null); const tabulatorRef = useRef(null); @@ -33,7 +34,7 @@ export default function Tabulator({ className, columns, data, modules, tabula columns, data, footerElement: (parentComponent && footerElement ? renderReactWidget(parentComponent, footerElement)[0] : undefined), - persistence, persistenceReaderFunc, persistenceWriterFunc + ...restProps }); tabulatorRef.current = tabulator; @@ -44,7 +45,7 @@ export default function Tabulator({ className, columns, data, modules, tabula useEffect(() => { const tabulator = tabulatorRef.current; - if (!tabulator) return; + if (!tabulator || !events) return; for (const [ eventName, handler ] of Object.entries(events)) { tabulator.on(eventName as keyof EventCallBackMethods, handler); @@ -55,7 +56,7 @@ export default function Tabulator({ className, columns, data, modules, tabula tabulator.off(eventName as keyof EventCallBackMethods, handler); } } - }, Object.values(events)); + }, Object.values(events ?? {})); // Change in data. useEffect(() => { tabulatorRef.current?.setData(data) }, [ data ]); From b62d1a303c3bd4fa0b9b71ca5eacd0e9f63aabcf Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 7 Sep 2025 19:41:25 +0300 Subject: [PATCH 102/233] chore(react/collections/table): add more properties --- apps/client/src/widgets/collections/table/index.tsx | 6 ++++++ apps/client/src/widgets/collections/table/tabulator.tsx | 9 ++++----- apps/client/src/widgets/view_widgets/table_view/index.ts | 8 -------- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/apps/client/src/widgets/collections/table/index.tsx b/apps/client/src/widgets/collections/table/index.tsx index e9c8b88021..0fac94210c 100644 --- a/apps/client/src/widgets/collections/table/index.tsx +++ b/apps/client/src/widgets/collections/table/index.tsx @@ -23,6 +23,7 @@ export default function TableView({ note, viewConfig, saveConfig }: ViewModeProp const [ maxDepth ] = useNoteLabelInt(note, "maxNestingDepth") ?? -1; const [ columnDefs, setColumnDefs ] = useState(); const [ rowData, setRowData ] = useState(); + const [ movableRows, setMovableRows ] = useState(); const tabulatorRef = useRef(null); const parentComponent = useContext(ParentComponent); @@ -38,6 +39,7 @@ export default function TableView({ note, viewConfig, saveConfig }: ViewModeProp }); setColumnDefs(columnDefs); setRowData(rowData); + setMovableRows(movableRows); }); }, [ note ]); @@ -58,6 +60,10 @@ export default function TableView({ note, viewConfig, saveConfig }: ViewModeProp footerElement={} events={contextMenuEvents} persistence {...persistenceProps} + layout="fitDataFill" + index="branchId" + movableColumns + movableRows={movableRows} /> diff --git a/apps/client/src/widgets/collections/table/tabulator.tsx b/apps/client/src/widgets/collections/table/tabulator.tsx index 4ed890aa9e..2f8a005504 100644 --- a/apps/client/src/widgets/collections/table/tabulator.tsx +++ b/apps/client/src/widgets/collections/table/tabulator.tsx @@ -5,14 +5,13 @@ import "../../../../src/stylesheets/table.css"; import { ComponentChildren, RefObject } from "preact"; import { ParentComponent, renderReactWidget } from "../../react/react_utils"; -interface TableProps extends Pick { +interface TableProps extends Omit { tabulatorRef: RefObject; className?: string; - columns: ColumnDefinition[]; data?: T[]; modules?: (new (table: VanillaTabulator) => Module)[]; - footerElement?: ComponentChildren; events?: Partial; + index: keyof T; } export default function Tabulator({ className, columns, data, modules, tabulatorRef: externalTabulatorRef, footerElement, events, ...restProps }: TableProps) { @@ -34,7 +33,7 @@ export default function Tabulator({ className, columns, data, modules, tabula columns, data, footerElement: (parentComponent && footerElement ? renderReactWidget(parentComponent, footerElement)[0] : undefined), - ...restProps + ...restProps, }); tabulatorRef.current = tabulator; @@ -59,7 +58,7 @@ export default function Tabulator({ className, columns, data, modules, tabula }, Object.values(events ?? {})); // Change in data. - useEffect(() => { tabulatorRef.current?.setData(data) }, [ data ]); + useEffect(() => { console.log("Got data ", data); tabulatorRef.current?.setData(data) }, [ data ]); return (
diff --git a/apps/client/src/widgets/view_widgets/table_view/index.ts b/apps/client/src/widgets/view_widgets/table_view/index.ts index 4383eada3a..c93700740a 100644 --- a/apps/client/src/widgets/view_widgets/table_view/index.ts +++ b/apps/client/src/widgets/view_widgets/table_view/index.ts @@ -61,14 +61,6 @@ export default class TableView extends ViewMode { const viewStorage = await this.viewStorage.restore(); this.persistentData = viewStorage?.tableData || {}; - let opts: Options = { - layout: "fitDataFill", - index: "branchId", - movableColumns: true, - movableRows, - footerElement: buildFooter(this.parentNote), - }; - if (hasChildren) { opts = { ...opts, From 0526445d3c796e0aad90a0f4a3ed7e78c9d9f3e3 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 7 Sep 2025 19:49:01 +0300 Subject: [PATCH 103/233] chore(react/collections/table): add datatree props --- .../src/widgets/collections/table/index.tsx | 19 +++++++++++++++++-- .../widgets/view_widgets/table_view/index.ts | 13 ------------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/apps/client/src/widgets/collections/table/index.tsx b/apps/client/src/widgets/collections/table/index.tsx index 0fac94210c..589af3639f 100644 --- a/apps/client/src/widgets/collections/table/index.tsx +++ b/apps/client/src/widgets/collections/table/index.tsx @@ -1,11 +1,11 @@ -import { useCallback, useContext, useEffect, useRef, useState } from "preact/hooks"; +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 { useNoteLabelInt, useSpacedUpdate } from "../../react/hooks"; import { canReorderRows } from "../../view_widgets/table_view/dragging"; import Tabulator from "./tabulator"; -import { Tabulator as VanillaTabulator, SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, ColumnDefinition, DataTreeModule} from 'tabulator-tables'; +import { Tabulator as VanillaTabulator, SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, ColumnDefinition, DataTreeModule, Options} from 'tabulator-tables'; import { useContextMenu } from "./context_menu"; import { ParentComponent } from "../../react/react_utils"; import FNote from "../../../entities/fnote"; @@ -24,6 +24,7 @@ export default function TableView({ note, viewConfig, saveConfig }: ViewModeProp const [ columnDefs, setColumnDefs ] = useState(); const [ rowData, setRowData ] = useState(); const [ movableRows, setMovableRows ] = useState(); + const [ hasChildren, setHasChildren ] = useState(); const tabulatorRef = useRef(null); const parentComponent = useContext(ParentComponent); @@ -40,11 +41,24 @@ export default function TableView({ note, viewConfig, saveConfig }: ViewModeProp setColumnDefs(columnDefs); setRowData(rowData); setMovableRows(movableRows); + setHasChildren(hasChildren); }); }, [ note ]); const contextMenuEvents = useContextMenu(note, parentComponent, tabulatorRef); const persistenceProps = usePersistence(viewConfig, saveConfig); + const dataTreeProps = useMemo(() => { + if (!hasChildren) return {}; + return { + dataTree: true, + dataTreeStartExpanded: true, + dataTreeBranchElement: false, + dataTreeElementColumn: "title", + dataTreeChildIndent: 20, + dataTreeExpandElement: ``, + dataTreeCollapseElement: `` + } + }, [ hasChildren ]); console.log("Render with viewconfig", viewConfig); return ( @@ -64,6 +78,7 @@ export default function TableView({ note, viewConfig, saveConfig }: ViewModeProp index="branchId" movableColumns movableRows={movableRows} + {...dataTreeProps} /> diff --git a/apps/client/src/widgets/view_widgets/table_view/index.ts b/apps/client/src/widgets/view_widgets/table_view/index.ts index c93700740a..a454f77f62 100644 --- a/apps/client/src/widgets/view_widgets/table_view/index.ts +++ b/apps/client/src/widgets/view_widgets/table_view/index.ts @@ -61,19 +61,6 @@ export default class TableView extends ViewMode { const viewStorage = await this.viewStorage.restore(); this.persistentData = viewStorage?.tableData || {}; - if (hasChildren) { - opts = { - ...opts, - dataTree: hasChildren, - dataTreeStartExpanded: true, - dataTreeBranchElement: false, - dataTreeElementColumn: "title", - dataTreeChildIndent: 20, - dataTreeExpandElement: ``, - dataTreeCollapseElement: `` - } - } - this.api = new Tabulator(el, opts); this.colEditing = new TableColumnEditing(this.args.$parent, this.args.parentNote, this.api); From 57046d714b0959aebfc8c798f14c3110e30678bc Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 7 Sep 2025 20:38:16 +0300 Subject: [PATCH 104/233] chore(react/collections/table): bring back adding new rows --- .../src/widgets/collections/NoteList.tsx | 8 +-- .../src/widgets/collections/interface.ts | 1 + .../src/widgets/collections/table/editing.ts | 57 +++++++++++++++++++ .../src/widgets/collections/table/index.tsx | 7 ++- .../view_widgets/table_view/row_editing.ts | 50 ---------------- 5 files changed, 66 insertions(+), 57 deletions(-) create mode 100644 apps/client/src/widgets/collections/table/editing.ts diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index 0c17fd1f37..b0dd946224 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -19,7 +19,7 @@ interface NoteListProps { export default function NoteList({ note: providedNote, highlightedTokens, displayOnlyCollections }: NoteListProps) { const widgetRef = useRef(null); - const { note: contextNote, noteContext } = useNoteContext(); + const { note: contextNote, noteContext, notePath } = useNoteContext(); const note = providedNote ?? contextNote; const viewType = useNoteViewType(note); const noteIds = useNoteIds(note, viewType); @@ -56,9 +56,9 @@ export default function NoteList({ note: providedNote, highlig // Preload the configuration. let props: ViewModeProps | undefined | null = null; const viewModeConfig = useViewModeConfig(note, viewType); - if (note && viewModeConfig) { + if (note && notePath && viewModeConfig) { props = { - note, noteIds, + note, noteIds, notePath, highlightedTokens, viewConfig: viewModeConfig[0], saveConfig: viewModeConfig[1] @@ -66,7 +66,7 @@ export default function NoteList({ note: providedNote, highlig } return ( -
+
{props && isEnabled && (
{getComponentByViewType(viewType, props)} diff --git a/apps/client/src/widgets/collections/interface.ts b/apps/client/src/widgets/collections/interface.ts index 4f89a871d5..a162be81e1 100644 --- a/apps/client/src/widgets/collections/interface.ts +++ b/apps/client/src/widgets/collections/interface.ts @@ -7,6 +7,7 @@ export type ViewTypeOptions = typeof allViewTypes[number]; export interface ViewModeProps { note: FNote; + notePath: string; /** * We're using noteIds so that it's not necessary to load all notes at once when paging. */ diff --git a/apps/client/src/widgets/collections/table/editing.ts b/apps/client/src/widgets/collections/table/editing.ts new file mode 100644 index 0000000000..c6e1114e19 --- /dev/null +++ b/apps/client/src/widgets/collections/table/editing.ts @@ -0,0 +1,57 @@ +import { 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"; + +export default function useTableEditing(api: RefObject, parentNotePath: string) { + 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); + } + }) + } + } + }); + +} + +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; +} diff --git a/apps/client/src/widgets/collections/table/index.tsx b/apps/client/src/widgets/collections/table/index.tsx index 589af3639f..4b4db6da85 100644 --- a/apps/client/src/widgets/collections/table/index.tsx +++ b/apps/client/src/widgets/collections/table/index.tsx @@ -12,6 +12,7 @@ import FNote from "../../../entities/fnote"; import { t } from "../../../services/i18n"; import Button from "../../react/Button"; import "./index.css"; +import useTableEditing from "./editing"; interface TableConfig { tableData?: { @@ -19,7 +20,7 @@ interface TableConfig { }; } -export default function TableView({ note, viewConfig, saveConfig }: ViewModeProps) { +export default function TableView({ note, noteIds, notePath, viewConfig, saveConfig }: ViewModeProps) { const [ maxDepth ] = useNoteLabelInt(note, "maxNestingDepth") ?? -1; const [ columnDefs, setColumnDefs ] = useState(); const [ rowData, setRowData ] = useState(); @@ -43,10 +44,11 @@ export default function TableView({ note, viewConfig, saveConfig }: ViewModeProp setMovableRows(movableRows); setHasChildren(hasChildren); }); - }, [ note ]); + }, [ note, noteIds ]); const contextMenuEvents = useContextMenu(note, parentComponent, tabulatorRef); const persistenceProps = usePersistence(viewConfig, saveConfig); + useTableEditing(tabulatorRef, notePath); const dataTreeProps = useMemo(() => { if (!hasChildren) return {}; return { @@ -59,7 +61,6 @@ export default function TableView({ note, viewConfig, saveConfig }: ViewModeProp dataTreeCollapseElement: `` } }, [ hasChildren ]); - console.log("Render with viewconfig", viewConfig); return (
diff --git a/apps/client/src/widgets/view_widgets/table_view/row_editing.ts b/apps/client/src/widgets/view_widgets/table_view/row_editing.ts index 92b0eeea47..99029a54f4 100644 --- a/apps/client/src/widgets/view_widgets/table_view/row_editing.ts +++ b/apps/client/src/widgets/view_widgets/table_view/row_editing.ts @@ -42,56 +42,6 @@ export default class TableRowEditing extends Component { }); } - addNewRowCommand({ customOpts, parentNotePath: customNotePath }: CommandListenerData<"addNewRow">) { - const parentNotePath = customNotePath ?? this.parentNotePath; - if (parentNotePath) { - const opts: CreateNoteOpts = { - activate: false, - ...customOpts - } - note_create.createNote(parentNotePath, opts).then(({ branch }) => { - if (branch) { - setTimeout(() => { - this.focusOnBranch(branch?.branchId); - }); - } - }) - } - } - - focusOnBranch(branchId: string) { - if (!this.api) { - return; - } - - const row = findRowDataById(this.api.getRows(), branchId); - if (!row) { - return; - } - - // Expand the parent tree if any. - if (this.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; -} From 7ba24968d8cd876e1b1e8c347dc79eccdced51aa Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 7 Sep 2025 21:07:55 +0300 Subject: [PATCH 105/233] chore(react/collections/table): bring editing cells --- .../src/widgets/collections/table/editing.ts | 36 +++++++++++++- .../src/widgets/collections/table/index.tsx | 8 +++- .../view_widgets/table_view/row_editing.ts | 47 ------------------- 3 files changed, 40 insertions(+), 51 deletions(-) delete mode 100644 apps/client/src/widgets/view_widgets/table_view/row_editing.ts diff --git a/apps/client/src/widgets/collections/table/editing.ts b/apps/client/src/widgets/collections/table/editing.ts index c6e1114e19..e71aa7bb4d 100644 --- a/apps/client/src/widgets/collections/table/editing.ts +++ b/apps/client/src/widgets/collections/table/editing.ts @@ -1,10 +1,14 @@ -import { RowComponent, Tabulator } from "tabulator-tables"; +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"; -export default function useTableEditing(api: RefObject, parentNotePath: string) { +export default function useTableEditing(api: RefObject, parentNotePath: string): Partial { + // Adding new rows useLegacyImperativeHandlers({ addNewRowCommand({ customOpts, parentNotePath: customNotePath }: CommandListenerData<"addNewRow">) { const notePath = customNotePath ?? parentNotePath; @@ -25,6 +29,34 @@ export default function useTableEditing(api: RefObject, parentNotePat } }); + // 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); + } + } + } + } + }; } function focusOnBranch(api: Tabulator, branchId: string) { diff --git a/apps/client/src/widgets/collections/table/index.tsx b/apps/client/src/widgets/collections/table/index.tsx index 4b4db6da85..88c50f2f59 100644 --- a/apps/client/src/widgets/collections/table/index.tsx +++ b/apps/client/src/widgets/collections/table/index.tsx @@ -48,7 +48,7 @@ export default function TableView({ note, noteIds, notePath, viewConfig, saveCon const contextMenuEvents = useContextMenu(note, parentComponent, tabulatorRef); const persistenceProps = usePersistence(viewConfig, saveConfig); - useTableEditing(tabulatorRef, notePath); + const editingEvents = useTableEditing(tabulatorRef, notePath); const dataTreeProps = useMemo(() => { if (!hasChildren) return {}; return { @@ -73,12 +73,16 @@ export default function TableView({ note, noteIds, notePath, viewConfig, saveCon data={rowData} modules={[ SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, DataTreeModule ]} footerElement={} - events={contextMenuEvents} + events={{ + ...contextMenuEvents, + ...editingEvents + }} persistence {...persistenceProps} layout="fitDataFill" index="branchId" movableColumns movableRows={movableRows} + {...dataTreeProps} /> diff --git a/apps/client/src/widgets/view_widgets/table_view/row_editing.ts b/apps/client/src/widgets/view_widgets/table_view/row_editing.ts deleted file mode 100644 index 99029a54f4..0000000000 --- a/apps/client/src/widgets/view_widgets/table_view/row_editing.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { RowComponent, Tabulator } from "tabulator-tables"; -import Component from "../../../components/component.js"; -import { setAttribute, setLabel } from "../../../services/attributes.js"; -import server from "../../../services/server.js"; -import froca from "../../../services/froca.js"; -import note_create, { CreateNoteOpts } from "../../../services/note_create.js"; -import { CommandListenerData } from "../../../components/app_context.js"; - -export default class TableRowEditing extends Component { - - private parentNotePath: string; - private api: Tabulator; - - constructor(api: Tabulator, parentNotePath: string) { - super(); - this.api = api; - this.parentNotePath = parentNotePath; - api.on("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); - } - } - } - }); - } - -} - - From 3d97b317f219a03cf15eeb428679d392bb9cdd43 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 7 Sep 2025 21:13:29 +0300 Subject: [PATCH 106/233] chore(react/collections/table): fix when empty --- apps/client/src/widgets/collections/table/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/widgets/collections/table/index.tsx b/apps/client/src/widgets/collections/table/index.tsx index 88c50f2f59..f880d4873f 100644 --- a/apps/client/src/widgets/collections/table/index.tsx +++ b/apps/client/src/widgets/collections/table/index.tsx @@ -64,7 +64,7 @@ export default function TableView({ note, noteIds, notePath, viewConfig, saveCon return (
- {viewConfig && columnDefs && ( + {columnDefs && ( <> Date: Sun, 7 Sep 2025 21:23:04 +0300 Subject: [PATCH 107/233] chore(react/collections/table): bring back dragging rows --- .../src/widgets/collections/table/editing.ts | 21 ++++++++++++++++ .../src/widgets/collections/table/index.tsx | 3 +-- .../view_widgets/table_view/dragging.ts | 25 ------------------- 3 files changed, 22 insertions(+), 27 deletions(-) delete mode 100644 apps/client/src/widgets/view_widgets/table_view/dragging.ts diff --git a/apps/client/src/widgets/collections/table/editing.ts b/apps/client/src/widgets/collections/table/editing.ts index e71aa7bb4d..b8d66ea7f7 100644 --- a/apps/client/src/widgets/collections/table/editing.ts +++ b/apps/client/src/widgets/collections/table/editing.ts @@ -6,6 +6,8 @@ import { RefObject } from "preact"; import { setAttribute, setLabel } from "../../../services/attributes"; import froca from "../../../services/froca"; import server from "../../../services/server"; +import FNote from "../../../entities/fnote"; +import branches from "../../../services/branches"; export default function useTableEditing(api: RefObject, parentNotePath: string): Partial { // Adding new rows @@ -55,6 +57,20 @@ export default function useTableEditing(api: RefObject, parentNotePat } } } + }, + 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); + } } }; } @@ -87,3 +103,8 @@ function findRowDataById(rows: RowComponent[], branchId: string): RowComponent | } return null; } + +export function canReorderRows(parentNote: FNote) { + return !parentNote.hasLabel("sorted") + && parentNote.type !== "search"; +} diff --git a/apps/client/src/widgets/collections/table/index.tsx b/apps/client/src/widgets/collections/table/index.tsx index f880d4873f..5326229829 100644 --- a/apps/client/src/widgets/collections/table/index.tsx +++ b/apps/client/src/widgets/collections/table/index.tsx @@ -3,7 +3,6 @@ import { ViewModeProps } from "../interface"; import { buildColumnDefinitions } from "./columns"; import getAttributeDefinitionInformation, { buildRowDefinitions, TableData } from "./rows"; import { useNoteLabelInt, useSpacedUpdate } from "../../react/hooks"; -import { canReorderRows } from "../../view_widgets/table_view/dragging"; import Tabulator from "./tabulator"; import { Tabulator as VanillaTabulator, SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, ColumnDefinition, DataTreeModule, Options} from 'tabulator-tables'; import { useContextMenu } from "./context_menu"; @@ -12,7 +11,7 @@ import FNote from "../../../entities/fnote"; import { t } from "../../../services/i18n"; import Button from "../../react/Button"; import "./index.css"; -import useTableEditing from "./editing"; +import useTableEditing, { canReorderRows } from "./editing"; interface TableConfig { tableData?: { diff --git a/apps/client/src/widgets/view_widgets/table_view/dragging.ts b/apps/client/src/widgets/view_widgets/table_view/dragging.ts deleted file mode 100644 index 39d5a0178f..0000000000 --- a/apps/client/src/widgets/view_widgets/table_view/dragging.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Tabulator } from "tabulator-tables"; -import type FNote from "../../../entities/fnote.js"; -import branches from "../../../services/branches.js"; - -export function canReorderRows(parentNote: FNote) { - return !parentNote.hasLabel("sorted") - && parentNote.type !== "search"; -} - -export function configureReorderingRows(tabulator: Tabulator) { - tabulator.on("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); - } - }); -} From 41c4bc69cc89e9d5fad551b4a1028e5b4b4f3d08 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 7 Sep 2025 22:08:26 +0300 Subject: [PATCH 108/233] chore(react/collections/table): get attribute detail to show --- .../widgets/collections/table/col_editing.ts | 64 +++++++++++++++++++ .../src/widgets/collections/table/index.tsx | 14 ++-- .../table/{editing.ts => row_editing.ts} | 3 +- .../view_widgets/table_view/col_editing.ts | 48 -------------- 4 files changed, 76 insertions(+), 53 deletions(-) create mode 100644 apps/client/src/widgets/collections/table/col_editing.ts rename apps/client/src/widgets/collections/table/{editing.ts => row_editing.ts} (93%) diff --git a/apps/client/src/widgets/collections/table/col_editing.ts b/apps/client/src/widgets/collections/table/col_editing.ts new file mode 100644 index 0000000000..6aa415a5ae --- /dev/null +++ b/apps/client/src/widgets/collections/table/col_editing.ts @@ -0,0 +1,64 @@ +import { useLegacyImperativeHandlers } from "../../react/hooks"; +import { Attribute } from "../../../services/attribute_parser"; +import { RefObject } from "preact"; +import { Tabulator } from "tabulator-tables"; +import { useEffect, useState } from "preact/hooks"; +import { EventData } from "../../../components/app_context"; +import AttributeDetailWidget from "../../attribute_widgets/attribute_detail"; + +export default function useColTableEditing(api: RefObject, attributeDetailWidget: AttributeDetailWidget) { + + const [ existingAttributeToEdit, setExistingAttributeToEdit ] = useState(); + const [ newAttributePosition, setNewAttributePosition ] = useState(); + + useEffect(() => { + + }, []); + + useLegacyImperativeHandlers({ + addNewTableColumnCommand({ referenceColumn, columnToEdit, direction, type }: EventData<"addNewTableColumn">) { + console.log("Ding"); + let attr: Attribute | undefined; + + setExistingAttributeToEdit(undefined); + if (columnToEdit) { + attr = this.getAttributeFromField(columnToEdit.getField()); + if (attr) { + setExistingAttributeToEdit({ ...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++; + } + + setNewAttributePosition(newPosition); + } else { + setNewAttributePosition(undefined); + } + + attributeDetailWidget.showAttributeDetail({ + attribute: attr, + allAttributes: [ attr ], + isOwned: true, + x: 0, + y: 150, + focus: "name", + hideMultiplicity: true + }); + } + }); + + return {}; +} diff --git a/apps/client/src/widgets/collections/table/index.tsx b/apps/client/src/widgets/collections/table/index.tsx index 5326229829..82e99ead9c 100644 --- a/apps/client/src/widgets/collections/table/index.tsx +++ b/apps/client/src/widgets/collections/table/index.tsx @@ -2,7 +2,7 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "p import { ViewModeProps } from "../interface"; import { buildColumnDefinitions } from "./columns"; import getAttributeDefinitionInformation, { buildRowDefinitions, TableData } from "./rows"; -import { useNoteLabelInt, useSpacedUpdate } from "../../react/hooks"; +import { useLegacyWidget, useNoteLabelInt, useSpacedUpdate } from "../../react/hooks"; import Tabulator from "./tabulator"; import { Tabulator as VanillaTabulator, SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, ColumnDefinition, DataTreeModule, Options} from 'tabulator-tables'; import { useContextMenu } from "./context_menu"; @@ -11,7 +11,9 @@ import FNote from "../../../entities/fnote"; import { t } from "../../../services/i18n"; import Button from "../../react/Button"; import "./index.css"; -import useTableEditing, { canReorderRows } from "./editing"; +import useRowTableEditing, { canReorderRows } from "./row_editing"; +import useColTableEditing from "./col_editing"; +import AttributeDetailWidget from "../../attribute_widgets/attribute_detail"; interface TableConfig { tableData?: { @@ -45,9 +47,11 @@ export default function TableView({ note, noteIds, notePath, viewConfig, saveCon }); }, [ note, noteIds ]); + const [ attributeDetailWidgetEl, attributeDetailWidget ] = useLegacyWidget(() => new AttributeDetailWidget().contentSized()); const contextMenuEvents = useContextMenu(note, parentComponent, tabulatorRef); const persistenceProps = usePersistence(viewConfig, saveConfig); - const editingEvents = useTableEditing(tabulatorRef, notePath); + const rowEditingEvents = useRowTableEditing(tabulatorRef, attributeDetailWidget, notePath); + const colEditingEvents = useColTableEditing(tabulatorRef, attributeDetailWidget); const dataTreeProps = useMemo(() => { if (!hasChildren) return {}; return { @@ -74,7 +78,8 @@ export default function TableView({ note, noteIds, notePath, viewConfig, saveCon footerElement={} events={{ ...contextMenuEvents, - ...editingEvents + ...rowEditingEvents, + ...colEditingEvents }} persistence {...persistenceProps} layout="fitDataFill" @@ -87,6 +92,7 @@ export default function TableView({ note, noteIds, notePath, viewConfig, saveCon )} + {attributeDetailWidgetEl}
) } diff --git a/apps/client/src/widgets/collections/table/editing.ts b/apps/client/src/widgets/collections/table/row_editing.ts similarity index 93% rename from apps/client/src/widgets/collections/table/editing.ts rename to apps/client/src/widgets/collections/table/row_editing.ts index b8d66ea7f7..2e5ecca144 100644 --- a/apps/client/src/widgets/collections/table/editing.ts +++ b/apps/client/src/widgets/collections/table/row_editing.ts @@ -8,8 +8,9 @@ import froca from "../../../services/froca"; import server from "../../../services/server"; import FNote from "../../../entities/fnote"; import branches from "../../../services/branches"; +import AttributeDetailWidget from "../../attribute_widgets/attribute_detail"; -export default function useTableEditing(api: RefObject, parentNotePath: string): Partial { +export default function useRowTableEditing(api: RefObject, attributeDetailWidget: AttributeDetailWidget, parentNotePath: string): Partial { // Adding new rows useLegacyImperativeHandlers({ addNewRowCommand({ customOpts, parentNotePath: customNotePath }: CommandListenerData<"addNewRow">) { diff --git a/apps/client/src/widgets/view_widgets/table_view/col_editing.ts b/apps/client/src/widgets/view_widgets/table_view/col_editing.ts index b5568ca347..306a9d6fb9 100644 --- a/apps/client/src/widgets/view_widgets/table_view/col_editing.ts +++ b/apps/client/src/widgets/view_widgets/table_view/col_editing.ts @@ -1,6 +1,5 @@ import { Tabulator } from "tabulator-tables"; import AttributeDetailWidget from "../../attribute_widgets/attribute_detail"; -import { Attribute } from "../../../services/attribute_parser"; import Component from "../../../components/component"; import { CommandListenerData, EventData } from "../../../components/app_context"; import attributes from "../../../services/attributes"; @@ -16,61 +15,14 @@ export default class TableColumnEditing extends Component { private parentNote: FNote; private newAttribute?: Attribute; - private newAttributePosition?: number; - private existingAttributeToEdit?: Attribute; constructor($parent: JQuery, parentNote: FNote, api: Tabulator) { super(); const parentComponent = glob.getComponentByEl($parent[0]); - this.attributeDetailWidget = new AttributeDetailWidget() - .contentSized() - .setParent(parentComponent); - $parent.append(this.attributeDetailWidget.render()); this.api = api; this.parentNote = parentNote; } - addNewTableColumnCommand({ referenceColumn, columnToEdit, direction, type }: EventData<"addNewTableColumn">) { - let attr: Attribute | undefined; - - this.existingAttributeToEdit = undefined; - if (columnToEdit) { - attr = this.getAttributeFromField(columnToEdit.getField()); - if (attr) { - this.existingAttributeToEdit = { ...attr }; - } - } - - if (!attr) { - attr = { - type: "label", - name: `${type ?? "label"}:myLabel`, - value: "promoted,single,text", - isInheritable: true - }; - } - - if (referenceColumn && this.api) { - this.newAttributePosition = this.api.getColumns().indexOf(referenceColumn); - - if (direction === "after") { - this.newAttributePosition++; - } - } else { - this.newAttributePosition = undefined; - } - - this.attributeDetailWidget!.showAttributeDetail({ - attribute: attr, - allAttributes: [ attr ], - isOwned: true, - x: 0, - y: 150, - focus: "name", - hideMultiplicity: true - }); - } - async updateAttributeListCommand({ attributes }: CommandListenerData<"updateAttributeList">) { this.newAttribute = attributes[0]; } From 49c4776dbd0d2540f5ef71c538d4bd01f197dc13 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 7 Sep 2025 22:16:21 +0300 Subject: [PATCH 109/233] chore(react/collections/table): reintroduce column creation --- .../widgets/collections/table/col_editing.ts | 35 +++++++++++++++++-- .../src/widgets/collections/table/index.tsx | 2 +- .../view_widgets/table_view/col_editing.ts | 30 ---------------- 3 files changed, 34 insertions(+), 33 deletions(-) diff --git a/apps/client/src/widgets/collections/table/col_editing.ts b/apps/client/src/widgets/collections/table/col_editing.ts index 6aa415a5ae..54c513d615 100644 --- a/apps/client/src/widgets/collections/table/col_editing.ts +++ b/apps/client/src/widgets/collections/table/col_editing.ts @@ -3,12 +3,16 @@ import { Attribute } from "../../../services/attribute_parser"; import { RefObject } from "preact"; import { Tabulator } from "tabulator-tables"; import { useEffect, useState } from "preact/hooks"; -import { EventData } from "../../../components/app_context"; +import { CommandListenerData, EventData } from "../../../components/app_context"; import AttributeDetailWidget from "../../attribute_widgets/attribute_detail"; +import attributes from "../../../services/attributes"; +import { renameColumn } from "../../view_widgets/table_view/bulk_actions"; +import FNote from "../../../entities/fnote"; -export default function useColTableEditing(api: RefObject, attributeDetailWidget: AttributeDetailWidget) { +export default function useColTableEditing(api: RefObject, attributeDetailWidget: AttributeDetailWidget, parentNote: FNote) { const [ existingAttributeToEdit, setExistingAttributeToEdit ] = useState(); + const [ newAttribute, setNewAttribute ] = useState(); const [ newAttributePosition, setNewAttributePosition ] = useState(); useEffect(() => { @@ -57,6 +61,33 @@ export default function useColTableEditing(api: RefObject, attributeD focus: "name", hideMultiplicity: true }); + }, + async updateAttributeListCommand({ attributes }: CommandListenerData<"updateAttributeList">) { + setNewAttribute(attributes[0]); + }, + async saveAttributesCommand() { + if (!newAttribute || !api.current) { + return; + } + + const { name, value, isInheritable } = newAttribute; + + api.current.blockRedraw(); + const isRename = (this.existingAttributeToEdit && this.existingAttributeToEdit.name !== name); + try { + if (isRename) { + const oldName = this.existingAttributeToEdit!.name.split(":")[1]; + const [ type, newName ] = name.split(":"); + await renameColumn(parentNote.noteId, type as "label" | "relation", oldName, newName); + } + + if (existingAttributeToEdit && (isRename || existingAttributeToEdit.isInheritable !== isInheritable)) { + attributes.removeOwnedLabelByName(parentNote, this.existingAttributeToEdit.name); + } + attributes.setLabel(parentNote.noteId, name, value, isInheritable); + } finally { + api.current.restoreRedraw(); + } } }); diff --git a/apps/client/src/widgets/collections/table/index.tsx b/apps/client/src/widgets/collections/table/index.tsx index 82e99ead9c..d372ce12c6 100644 --- a/apps/client/src/widgets/collections/table/index.tsx +++ b/apps/client/src/widgets/collections/table/index.tsx @@ -51,7 +51,7 @@ export default function TableView({ note, noteIds, notePath, viewConfig, saveCon const contextMenuEvents = useContextMenu(note, parentComponent, tabulatorRef); const persistenceProps = usePersistence(viewConfig, saveConfig); const rowEditingEvents = useRowTableEditing(tabulatorRef, attributeDetailWidget, notePath); - const colEditingEvents = useColTableEditing(tabulatorRef, attributeDetailWidget); + const colEditingEvents = useColTableEditing(tabulatorRef, attributeDetailWidget, note); const dataTreeProps = useMemo(() => { if (!hasChildren) return {}; return { diff --git a/apps/client/src/widgets/view_widgets/table_view/col_editing.ts b/apps/client/src/widgets/view_widgets/table_view/col_editing.ts index 306a9d6fb9..4114fdf5c7 100644 --- a/apps/client/src/widgets/view_widgets/table_view/col_editing.ts +++ b/apps/client/src/widgets/view_widgets/table_view/col_editing.ts @@ -10,7 +10,6 @@ import { t } from "../../../services/i18n"; export default class TableColumnEditing extends Component { - private attributeDetailWidget: AttributeDetailWidget; private api: Tabulator; private parentNote: FNote; @@ -23,35 +22,6 @@ export default class TableColumnEditing extends Component { this.parentNote = parentNote; } - async updateAttributeListCommand({ attributes }: CommandListenerData<"updateAttributeList">) { - this.newAttribute = attributes[0]; - } - - async saveAttributesCommand() { - if (!this.newAttribute) { - return; - } - - const { name, value, isInheritable } = this.newAttribute; - - this.api.blockRedraw(); - const isRename = (this.existingAttributeToEdit && this.existingAttributeToEdit.name !== name); - try { - if (isRename) { - const oldName = this.existingAttributeToEdit!.name.split(":")[1]; - const [ type, newName ] = name.split(":"); - await renameColumn(this.parentNote.noteId, type as "label" | "relation", oldName, newName); - } - - if (this.existingAttributeToEdit && (isRename || this.existingAttributeToEdit.isInheritable !== isInheritable)) { - attributes.removeOwnedLabelByName(this.parentNote, this.existingAttributeToEdit.name); - } - attributes.setLabel(this.parentNote.noteId, name, value, isInheritable); - } finally { - this.api.restoreRedraw(); - } - } - async deleteTableColumnCommand({ columnToDelete }: CommandListenerData<"deleteTableColumn">) { if (!columnToDelete || !await dialog.confirm(t("table_view.delete_column_confirmation"))) { return; From 1e654fbcd610d23e802676fadb129f2fd47f4bb8 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 7 Sep 2025 22:29:01 +0300 Subject: [PATCH 110/233] chore(react/collections/table): refresh columns --- .../src/widgets/collections/table/index.tsx | 20 ++++++++++++++++--- .../widgets/collections/table/tabulator.tsx | 3 ++- .../widgets/view_widgets/table_view/index.ts | 2 +- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/client/src/widgets/collections/table/index.tsx b/apps/client/src/widgets/collections/table/index.tsx index d372ce12c6..dc6062baf3 100644 --- a/apps/client/src/widgets/collections/table/index.tsx +++ b/apps/client/src/widgets/collections/table/index.tsx @@ -2,7 +2,7 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "p import { ViewModeProps } from "../interface"; import { buildColumnDefinitions } from "./columns"; import getAttributeDefinitionInformation, { buildRowDefinitions, TableData } from "./rows"; -import { useLegacyWidget, useNoteLabelInt, useSpacedUpdate } from "../../react/hooks"; +import { useLegacyWidget, 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} from 'tabulator-tables'; import { useContextMenu } from "./context_menu"; @@ -14,6 +14,8 @@ import "./index.css"; import useRowTableEditing, { canReorderRows } from "./row_editing"; import useColTableEditing from "./col_editing"; import AttributeDetailWidget from "../../attribute_widgets/attribute_detail"; +import attributes from "../../../services/attributes"; +import { refreshTextDimensions } from "@excalidraw/excalidraw/element/newElement"; interface TableConfig { tableData?: { @@ -30,7 +32,7 @@ export default function TableView({ note, noteIds, notePath, viewConfig, saveCon const tabulatorRef = useRef(null); const parentComponent = useContext(ParentComponent); - useEffect(() => { + function refresh() { const info = getAttributeDefinitionInformation(note); buildRowDefinitions(note, info, maxDepth).then(({ definitions: rowData, hasSubtree: hasChildren, rowNumber }) => { const movableRows = canReorderRows(note) && !hasChildren; @@ -45,7 +47,19 @@ export default function TableView({ note, noteIds, notePath, viewConfig, saveCon setMovableRows(movableRows); setHasChildren(hasChildren); }); - }, [ note, noteIds ]); + } + + useEffect(refresh, [ note, noteIds ]); + + // React to column changes. + useTriliumEvent("entitiesReloaded", ({ loadResults}) => { + if (loadResults.getAttributeRows().find(attr => + attr.type === "label" && + (attr.name?.startsWith("label:") || attr.name?.startsWith("relation:")) && + attributes.isAffecting(attr, note))) { + refresh(); + } + }); const [ attributeDetailWidgetEl, attributeDetailWidget ] = useLegacyWidget(() => new AttributeDetailWidget().contentSized()); const contextMenuEvents = useContextMenu(note, parentComponent, tabulatorRef); diff --git a/apps/client/src/widgets/collections/table/tabulator.tsx b/apps/client/src/widgets/collections/table/tabulator.tsx index 2f8a005504..3481c471f7 100644 --- a/apps/client/src/widgets/collections/table/tabulator.tsx +++ b/apps/client/src/widgets/collections/table/tabulator.tsx @@ -58,7 +58,8 @@ export default function Tabulator({ className, columns, data, modules, tabula }, Object.values(events ?? {})); // Change in data. - useEffect(() => { console.log("Got data ", data); tabulatorRef.current?.setData(data) }, [ data ]); + useEffect(() => { tabulatorRef.current?.setData(data) }, [ data ]); + useEffect(() => { columns && tabulatorRef.current?.setColumns(columns)}, [ data]); return (
diff --git a/apps/client/src/widgets/view_widgets/table_view/index.ts b/apps/client/src/widgets/view_widgets/table_view/index.ts index a454f77f62..ef80dab80d 100644 --- a/apps/client/src/widgets/view_widgets/table_view/index.ts +++ b/apps/client/src/widgets/view_widgets/table_view/index.ts @@ -88,7 +88,7 @@ export default class TableView extends ViewMode { (attr.name?.startsWith("label:") || attr.name?.startsWith("relation:")) && attributes.isAffecting(attr, this.parentNote))) { this.#manageColumnUpdate(); - return await this.#manageRowsUpdate(); + //return await this.#manageRowsUpdate(); } // Refresh max depth From 4e37a5f08eee2ddf5af88b2288fdd33b989968c8 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 9 Sep 2025 18:48:09 +0300 Subject: [PATCH 111/233] chore(react/collections/table): fix some issues with col editing --- .../widgets/collections/table/col_editing.ts | 32 ++++--- .../src/widgets/collections/table/index.tsx | 83 ++++++++++--------- .../src/widgets/collections/table/utils.ts | 21 +++++ .../view_widgets/table_view/col_editing.ts | 17 ---- .../widgets/view_widgets/table_view/index.ts | 4 - 5 files changed, 80 insertions(+), 77 deletions(-) create mode 100644 apps/client/src/widgets/collections/table/utils.ts diff --git a/apps/client/src/widgets/collections/table/col_editing.ts b/apps/client/src/widgets/collections/table/col_editing.ts index 54c513d615..ca5636040f 100644 --- a/apps/client/src/widgets/collections/table/col_editing.ts +++ b/apps/client/src/widgets/collections/table/col_editing.ts @@ -2,31 +2,27 @@ import { useLegacyImperativeHandlers } from "../../react/hooks"; import { Attribute } from "../../../services/attribute_parser"; import { RefObject } from "preact"; import { Tabulator } from "tabulator-tables"; -import { useEffect, useState } from "preact/hooks"; +import { useRef, useState } from "preact/hooks"; import { CommandListenerData, EventData } from "../../../components/app_context"; import AttributeDetailWidget from "../../attribute_widgets/attribute_detail"; import attributes from "../../../services/attributes"; import { renameColumn } from "../../view_widgets/table_view/bulk_actions"; import FNote from "../../../entities/fnote"; +import { getAttributeFromField } from "./utils"; export default function useColTableEditing(api: RefObject, attributeDetailWidget: AttributeDetailWidget, parentNote: FNote) { const [ existingAttributeToEdit, setExistingAttributeToEdit ] = useState(); - const [ newAttribute, setNewAttribute ] = useState(); - const [ newAttributePosition, setNewAttributePosition ] = useState(); - - useEffect(() => { - - }, []); + const newAttribute = useRef(); + const newAttributePosition = useRef(); useLegacyImperativeHandlers({ addNewTableColumnCommand({ referenceColumn, columnToEdit, direction, type }: EventData<"addNewTableColumn">) { - console.log("Ding"); let attr: Attribute | undefined; setExistingAttributeToEdit(undefined); if (columnToEdit) { - attr = this.getAttributeFromField(columnToEdit.getField()); + attr = getAttributeFromField(parentNote, columnToEdit.getField()); if (attr) { setExistingAttributeToEdit({ ...attr }); } @@ -47,9 +43,9 @@ export default function useColTableEditing(api: RefObject, attributeD newPosition++; } - setNewAttributePosition(newPosition); + newAttributePosition.current = newPosition; } else { - setNewAttributePosition(undefined); + newAttributePosition.current = undefined; } attributeDetailWidget.showAttributeDetail({ @@ -63,26 +59,26 @@ export default function useColTableEditing(api: RefObject, attributeD }); }, async updateAttributeListCommand({ attributes }: CommandListenerData<"updateAttributeList">) { - setNewAttribute(attributes[0]); + newAttribute.current = attributes[0]; }, async saveAttributesCommand() { - if (!newAttribute || !api.current) { + if (!newAttribute.current || !api.current) { return; } - const { name, value, isInheritable } = newAttribute; + const { name, value, isInheritable } = newAttribute.current; api.current.blockRedraw(); - const isRename = (this.existingAttributeToEdit && this.existingAttributeToEdit.name !== name); + const isRename = (existingAttributeToEdit && existingAttributeToEdit.name !== name); try { if (isRename) { - const oldName = this.existingAttributeToEdit!.name.split(":")[1]; + const oldName = existingAttributeToEdit!.name.split(":")[1]; const [ type, newName ] = name.split(":"); await renameColumn(parentNote.noteId, type as "label" | "relation", oldName, newName); } if (existingAttributeToEdit && (isRename || existingAttributeToEdit.isInheritable !== isInheritable)) { - attributes.removeOwnedLabelByName(parentNote, this.existingAttributeToEdit.name); + attributes.removeOwnedLabelByName(parentNote, existingAttributeToEdit.name); } attributes.setLabel(parentNote.noteId, name, value, isInheritable); } finally { @@ -91,5 +87,5 @@ export default function useColTableEditing(api: RefObject, attributeD } }); - return {}; + return { newAttributePosition }; } diff --git a/apps/client/src/widgets/collections/table/index.tsx b/apps/client/src/widgets/collections/table/index.tsx index dc6062baf3..72dfeeb912 100644 --- a/apps/client/src/widgets/collections/table/index.tsx +++ b/apps/client/src/widgets/collections/table/index.tsx @@ -15,7 +15,7 @@ import useRowTableEditing, { canReorderRows } from "./row_editing"; import useColTableEditing from "./col_editing"; import AttributeDetailWidget from "../../attribute_widgets/attribute_detail"; import attributes from "../../../services/attributes"; -import { refreshTextDimensions } from "@excalidraw/excalidraw/element/newElement"; +import { RefObject } from "preact"; interface TableConfig { tableData?: { @@ -24,48 +24,15 @@ interface TableConfig { } export default function TableView({ note, noteIds, notePath, viewConfig, saveConfig }: ViewModeProps) { - const [ maxDepth ] = useNoteLabelInt(note, "maxNestingDepth") ?? -1; - const [ columnDefs, setColumnDefs ] = useState(); - const [ rowData, setRowData ] = useState(); - const [ movableRows, setMovableRows ] = useState(); - const [ hasChildren, setHasChildren ] = useState(); const tabulatorRef = useRef(null); const parentComponent = useContext(ParentComponent); - function refresh() { - const info = getAttributeDefinitionInformation(note); - buildRowDefinitions(note, info, maxDepth).then(({ definitions: rowData, hasSubtree: hasChildren, rowNumber }) => { - const movableRows = canReorderRows(note) && !hasChildren; - const columnDefs = buildColumnDefinitions({ - info, - movableRows, - existingColumnData: viewConfig?.tableData?.columns, - rowNumberHint: rowNumber - }); - setColumnDefs(columnDefs); - setRowData(rowData); - setMovableRows(movableRows); - setHasChildren(hasChildren); - }); - } - - useEffect(refresh, [ note, noteIds ]); - - // React to column changes. - useTriliumEvent("entitiesReloaded", ({ loadResults}) => { - if (loadResults.getAttributeRows().find(attr => - attr.type === "label" && - (attr.name?.startsWith("label:") || attr.name?.startsWith("relation:")) && - attributes.isAffecting(attr, note))) { - refresh(); - } - }); - 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 colEditingEvents = useColTableEditing(tabulatorRef, attributeDetailWidget, note); + const { newAttributePosition } = useColTableEditing(tabulatorRef, attributeDetailWidget, note); + const { columnDefs, rowData, movableRows, hasChildren } = useData(note, noteIds, viewConfig, newAttributePosition); const dataTreeProps = useMemo(() => { if (!hasChildren) return {}; return { @@ -92,8 +59,7 @@ export default function TableView({ note, noteIds, notePath, viewConfig, saveCon footerElement={} events={{ ...contextMenuEvents, - ...rowEditingEvents, - ...colEditingEvents + ...rowEditingEvents }} persistence {...persistenceProps} layout="fitDataFill" @@ -141,3 +107,44 @@ function usePersistence(initialConfig: TableConfig | null | undefined, saveConfi }, []); return { persistenceReaderFunc, persistenceWriterFunc }; } + +function useData(note: FNote, noteIds: string[], viewConfig: TableConfig | undefined, newAttributePosition: RefObject) { + const [ maxDepth ] = useNoteLabelInt(note, "maxNestingDepth") ?? -1; + + const [ columnDefs, setColumnDefs ] = useState(); + const [ rowData, setRowData ] = useState(); + const [ movableRows, setMovableRows ] = useState(); + const [ hasChildren, setHasChildren ] = useState(); + + function refresh() { + const info = getAttributeDefinitionInformation(note); + buildRowDefinitions(note, info, maxDepth).then(({ definitions: rowData, hasSubtree: hasChildren, rowNumber }) => { + const movableRows = canReorderRows(note) && !hasChildren; + const columnDefs = buildColumnDefinitions({ + info, + movableRows, + existingColumnData: viewConfig?.tableData?.columns, + rowNumberHint: rowNumber, + position: newAttributePosition.current ?? undefined + }); + setColumnDefs(columnDefs); + setRowData(rowData); + setMovableRows(movableRows); + setHasChildren(hasChildren); + }); + } + + useEffect(refresh, [ note, noteIds ]); + + // React to column changes. + useTriliumEvent("entitiesReloaded", ({ loadResults}) => { + if (loadResults.getAttributeRows().find(attr => + attr.type === "label" && + (attr.name?.startsWith("label:") || attr.name?.startsWith("relation:")) && + attributes.isAffecting(attr, note))) { + refresh(); + } + }); + + return { columnDefs, rowData, movableRows, hasChildren }; +} diff --git a/apps/client/src/widgets/collections/table/utils.ts b/apps/client/src/widgets/collections/table/utils.ts new file mode 100644 index 0000000000..3daae04da0 --- /dev/null +++ b/apps/client/src/widgets/collections/table/utils.ts @@ -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; +} diff --git a/apps/client/src/widgets/view_widgets/table_view/col_editing.ts b/apps/client/src/widgets/view_widgets/table_view/col_editing.ts index 4114fdf5c7..cf520303d0 100644 --- a/apps/client/src/widgets/view_widgets/table_view/col_editing.ts +++ b/apps/client/src/widgets/view_widgets/table_view/col_editing.ts @@ -52,23 +52,6 @@ export default class TableColumnEditing extends Component { this.existingAttributeToEdit = undefined; } - getFAttributeFromField(field: string) { - const [ type, name ] = field.split(".", 2); - const attrName = `${type.replace("s", "")}:${name}`; - return this.parentNote.getLabel(attrName); - } - getAttributeFromField(field: string): Attribute | undefined { - const fAttribute = this.getFAttributeFromField(field); - if (fAttribute) { - return { - name: fAttribute.name, - value: fAttribute.value, - type: fAttribute.type, - isInheritable: fAttribute.isInheritable - }; - } - return undefined; - } } diff --git a/apps/client/src/widgets/view_widgets/table_view/index.ts b/apps/client/src/widgets/view_widgets/table_view/index.ts index ef80dab80d..479e6f90b6 100644 --- a/apps/client/src/widgets/view_widgets/table_view/index.ts +++ b/apps/client/src/widgets/view_widgets/table_view/index.ts @@ -123,11 +123,7 @@ export default class TableView extends ViewMode { this.colEditing?.resetNewAttributePosition(); } - addNewRowCommand(e) { this.rowEditing?.addNewRowCommand(e); } - addNewTableColumnCommand(e) { this.colEditing?.addNewTableColumnCommand(e); } deleteTableColumnCommand(e) { this.colEditing?.deleteTableColumnCommand(e); } - updateAttributeListCommand(e) { this.colEditing?.updateAttributeListCommand(e); } - saveAttributesCommand() { this.colEditing?.saveAttributesCommand(); } async #manageRowsUpdate() { if (!this.api) { From ab6fc9303bdf46774ad33c17c44e80dfbb53b730 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 9 Sep 2025 18:56:53 +0300 Subject: [PATCH 112/233] chore(react/collections/table) reintroduce delete/rename --- .../widgets/collections/table/col_editing.ts | 53 ++++++++++++++++++- .../widgets/collections/table/tabulator.tsx | 4 +- .../view_widgets/table_view/bulk_actions.ts | 31 ----------- .../view_widgets/table_view/col_editing.ts | 20 ------- 4 files changed, 54 insertions(+), 54 deletions(-) delete mode 100644 apps/client/src/widgets/view_widgets/table_view/bulk_actions.ts diff --git a/apps/client/src/widgets/collections/table/col_editing.ts b/apps/client/src/widgets/collections/table/col_editing.ts index ca5636040f..8e15677d9f 100644 --- a/apps/client/src/widgets/collections/table/col_editing.ts +++ b/apps/client/src/widgets/collections/table/col_editing.ts @@ -6,9 +6,11 @@ import { useRef, useState } from "preact/hooks"; import { CommandListenerData, EventData } from "../../../components/app_context"; import AttributeDetailWidget from "../../attribute_widgets/attribute_detail"; import attributes from "../../../services/attributes"; -import { renameColumn } from "../../view_widgets/table_view/bulk_actions"; 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, attributeDetailWidget: AttributeDetailWidget, parentNote: FNote) { @@ -84,8 +86,57 @@ export default function useColTableEditing(api: RefObject, attributeD } 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(); + } } }); return { newAttributePosition }; } + +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); + } +} diff --git a/apps/client/src/widgets/collections/table/tabulator.tsx b/apps/client/src/widgets/collections/table/tabulator.tsx index 3481c471f7..9191f22da7 100644 --- a/apps/client/src/widgets/collections/table/tabulator.tsx +++ b/apps/client/src/widgets/collections/table/tabulator.tsx @@ -1,8 +1,8 @@ import { useContext, useEffect, useLayoutEffect, useRef } from "preact/hooks"; -import { ColumnDefinition, EventCallBackMethods, Module, Options, Tabulator as VanillaTabulator } from "tabulator-tables"; +import { EventCallBackMethods, Module, Options, Tabulator as VanillaTabulator } from "tabulator-tables"; import "tabulator-tables/dist/css/tabulator.css"; import "../../../../src/stylesheets/table.css"; -import { ComponentChildren, RefObject } from "preact"; +import { RefObject } from "preact"; import { ParentComponent, renderReactWidget } from "../../react/react_utils"; interface TableProps extends Omit { diff --git a/apps/client/src/widgets/view_widgets/table_view/bulk_actions.ts b/apps/client/src/widgets/view_widgets/table_view/bulk_actions.ts deleted file mode 100644 index 010bd1c488..0000000000 --- a/apps/client/src/widgets/view_widgets/table_view/bulk_actions.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { executeBulkActions } from "../../../services/bulk_action.js"; - -export 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); - } -} - -export 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); - } -} diff --git a/apps/client/src/widgets/view_widgets/table_view/col_editing.ts b/apps/client/src/widgets/view_widgets/table_view/col_editing.ts index cf520303d0..1a6939b3cf 100644 --- a/apps/client/src/widgets/view_widgets/table_view/col_editing.ts +++ b/apps/client/src/widgets/view_widgets/table_view/col_editing.ts @@ -22,26 +22,6 @@ export default class TableColumnEditing extends Component { this.parentNote = parentNote; } - async deleteTableColumnCommand({ columnToDelete }: CommandListenerData<"deleteTableColumn">) { - if (!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", ""); - - this.api.blockRedraw(); - try { - await deleteColumn(this.parentNote.noteId, type as "label" | "relation", name); - attributes.removeOwnedLabelByName(this.parentNote, `${type}:${name}`); - } finally { - this.api.restoreRedraw(); - } - } - getNewAttributePosition() { return this.newAttributePosition; } From 0c7f9264217e4e92505a40c6115fff323927d110 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 9 Sep 2025 19:00:08 +0300 Subject: [PATCH 113/233] chore(react/collections/table): react to nesting depth change --- .../src/widgets/collections/table/index.tsx | 2 +- .../widgets/view_widgets/table_view/index.ts | 27 ------------------- 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/apps/client/src/widgets/collections/table/index.tsx b/apps/client/src/widgets/collections/table/index.tsx index 72dfeeb912..4b60a4abd6 100644 --- a/apps/client/src/widgets/collections/table/index.tsx +++ b/apps/client/src/widgets/collections/table/index.tsx @@ -134,7 +134,7 @@ function useData(note: FNote, noteIds: string[], viewConfig: TableConfig | undef }); } - useEffect(refresh, [ note, noteIds ]); + useEffect(refresh, [ note, noteIds, maxDepth ]); // React to column changes. useTriliumEvent("entitiesReloaded", ({ loadResults}) => { diff --git a/apps/client/src/widgets/view_widgets/table_view/index.ts b/apps/client/src/widgets/view_widgets/table_view/index.ts index 479e6f90b6..f25430f852 100644 --- a/apps/client/src/widgets/view_widgets/table_view/index.ts +++ b/apps/client/src/widgets/view_widgets/table_view/index.ts @@ -3,7 +3,6 @@ import attributes from "../../../services/attributes.js"; import SpacedUpdate from "../../../services/spaced_update.js"; import type { EventData } from "../../../components/app_context.js"; -import { canReorderRows, configureReorderingRows } from "./dragging.js"; import buildFooter from "./footer.js"; import getAttributeDefinitionInformation, { buildRowDefinitions } from "./rows.js"; import { AttributeDefinitionInformation, buildColumnDefinitions } from "./columns.js"; @@ -49,14 +48,6 @@ export default class TableView extends ViewMode { return this.$root; } - private async renderTable(el: HTMLElement) { - for (const module of modules) { - Tabulator.registerModule(module); - } - - this.initialize(el, info); - } - private async initialize(el: HTMLElement, info: AttributeDefinitionInformation[]) { const viewStorage = await this.viewStorage.restore(); this.persistentData = viewStorage?.tableData || {}; @@ -66,9 +57,6 @@ export default class TableView extends ViewMode { this.colEditing = new TableColumnEditing(this.args.$parent, this.args.parentNote, this.api); this.rowEditing = new TableRowEditing(this.api, this.args.parentNotePath!); - if (movableRows) { - configureReorderingRows(this.api); - } setupContextMenu(this.api, this.parentNote); } @@ -82,21 +70,6 @@ export default class TableView extends ViewMode { return true; } - // Refresh if promoted attributes get changed. - if (loadResults.getAttributeRows().find(attr => - attr.type === "label" && - (attr.name?.startsWith("label:") || attr.name?.startsWith("relation:")) && - attributes.isAffecting(attr, this.parentNote))) { - this.#manageColumnUpdate(); - //return await this.#manageRowsUpdate(); - } - - // Refresh max depth - if (loadResults.getAttributeRows().find(attr => attr.type === "label" && attr.name === "maxNestingDepth" && attributes.isAffecting(attr, this.parentNote))) { - this.maxDepth = parseInt(this.parentNote.getLabelValue("maxNestingDepth") ?? "-1", 10); - return await this.#manageRowsUpdate(); - } - if (loadResults.getBranchRows().some(branch => branch.parentNoteId === this.parentNote.noteId || this.noteIds.includes(branch.parentNoteId ?? "")) || loadResults.getNoteIds().some(noteId => this.noteIds.includes(noteId)) || loadResults.getAttributeRows().some(attr => this.noteIds.includes(attr.noteId!))) { From 9758632bf0ff9ebc9b3249ed3f5f9321ac1e9728 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 9 Sep 2025 19:17:34 +0300 Subject: [PATCH 114/233] chore(react/collections/table): react to sorted change --- .../src/widgets/collections/table/index.tsx | 16 ++++++++++------ .../src/widgets/collections/table/row_editing.ts | 5 ----- .../src/widgets/view_widgets/table_view/index.ts | 9 --------- 3 files changed, 10 insertions(+), 20 deletions(-) diff --git a/apps/client/src/widgets/collections/table/index.tsx b/apps/client/src/widgets/collections/table/index.tsx index 4b60a4abd6..7d7048a553 100644 --- a/apps/client/src/widgets/collections/table/index.tsx +++ b/apps/client/src/widgets/collections/table/index.tsx @@ -2,7 +2,7 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "p import { ViewModeProps } from "../interface"; import { buildColumnDefinitions } from "./columns"; import getAttributeDefinitionInformation, { buildRowDefinitions, TableData } from "./rows"; -import { useLegacyWidget, useNoteLabelInt, useSpacedUpdate, useTriliumEvent } from "../../react/hooks"; +import { useLegacyWidget, useNoteLabel, 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} from 'tabulator-tables'; import { useContextMenu } from "./context_menu"; @@ -11,7 +11,7 @@ import FNote from "../../../entities/fnote"; import { t } from "../../../services/i18n"; import Button from "../../react/Button"; import "./index.css"; -import useRowTableEditing, { canReorderRows } from "./row_editing"; +import useRowTableEditing from "./row_editing"; import useColTableEditing from "./col_editing"; import AttributeDetailWidget from "../../attribute_widgets/attribute_detail"; import attributes from "../../../services/attributes"; @@ -113,13 +113,13 @@ function useData(note: FNote, noteIds: string[], viewConfig: TableConfig | undef const [ columnDefs, setColumnDefs ] = useState(); const [ rowData, setRowData ] = useState(); - const [ movableRows, setMovableRows ] = useState(); const [ hasChildren, setHasChildren ] = useState(); + const [ isSorted ] = useNoteLabelBoolean(note, "sorted"); + const [ movableRows, setMovableRows ] = useState(false); function refresh() { const info = getAttributeDefinitionInformation(note); buildRowDefinitions(note, info, maxDepth).then(({ definitions: rowData, hasSubtree: hasChildren, rowNumber }) => { - const movableRows = canReorderRows(note) && !hasChildren; const columnDefs = buildColumnDefinitions({ info, movableRows, @@ -129,12 +129,11 @@ function useData(note: FNote, noteIds: string[], viewConfig: TableConfig | undef }); setColumnDefs(columnDefs); setRowData(rowData); - setMovableRows(movableRows); setHasChildren(hasChildren); }); } - useEffect(refresh, [ note, noteIds, maxDepth ]); + useEffect(refresh, [ note, noteIds, maxDepth, movableRows ]); // React to column changes. useTriliumEvent("entitiesReloaded", ({ loadResults}) => { @@ -146,5 +145,10 @@ function useData(note: FNote, noteIds: string[], viewConfig: TableConfig | undef } }); + // Identify if movable rows. + useEffect(() => { + setMovableRows(!isSorted && note.type !== "search" && !hasChildren); + }, [ isSorted, note, hasChildren ]); + return { columnDefs, rowData, movableRows, hasChildren }; } diff --git a/apps/client/src/widgets/collections/table/row_editing.ts b/apps/client/src/widgets/collections/table/row_editing.ts index 2e5ecca144..af92b86d31 100644 --- a/apps/client/src/widgets/collections/table/row_editing.ts +++ b/apps/client/src/widgets/collections/table/row_editing.ts @@ -104,8 +104,3 @@ function findRowDataById(rows: RowComponent[], branchId: string): RowComponent | } return null; } - -export function canReorderRows(parentNote: FNote) { - return !parentNote.hasLabel("sorted") - && parentNote.type !== "search"; -} diff --git a/apps/client/src/widgets/view_widgets/table_view/index.ts b/apps/client/src/widgets/view_widgets/table_view/index.ts index f25430f852..2011a7456e 100644 --- a/apps/client/src/widgets/view_widgets/table_view/index.ts +++ b/apps/client/src/widgets/view_widgets/table_view/index.ts @@ -61,15 +61,6 @@ export default class TableView extends ViewMode { } async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) { - if (!this.api) { - return; - } - - // Force a refresh if sorted is changed since we need to disable reordering. - if (loadResults.getAttributeRows().find(a => a.name === "sorted" && attributes.isAffecting(a, this.parentNote))) { - return true; - } - if (loadResults.getBranchRows().some(branch => branch.parentNoteId === this.parentNote.noteId || this.noteIds.includes(branch.parentNoteId ?? "")) || loadResults.getNoteIds().some(noteId => this.noteIds.includes(noteId)) || loadResults.getAttributeRows().some(attr => this.noteIds.includes(attr.noteId!))) { From 3046cfd6eea9cc11b0b4c07c76cc9ef961929eae Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 9 Sep 2025 19:34:22 +0300 Subject: [PATCH 115/233] chore(react/collections/table): react to external data changes --- apps/client/src/widgets/collections/table/index.tsx | 11 ++++++++++- .../src/widgets/collections/table/row_editing.ts | 1 - .../src/widgets/view_widgets/table_view/index.ts | 10 ---------- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/apps/client/src/widgets/collections/table/index.tsx b/apps/client/src/widgets/collections/table/index.tsx index 7d7048a553..5ab96ee6e3 100644 --- a/apps/client/src/widgets/collections/table/index.tsx +++ b/apps/client/src/widgets/collections/table/index.tsx @@ -135,13 +135,22 @@ function useData(note: FNote, noteIds: string[], viewConfig: TableConfig | undef useEffect(refresh, [ note, noteIds, maxDepth, movableRows ]); - // React to column changes. 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!))) { + refresh(); + return; } }); diff --git a/apps/client/src/widgets/collections/table/row_editing.ts b/apps/client/src/widgets/collections/table/row_editing.ts index af92b86d31..22ef0e7e48 100644 --- a/apps/client/src/widgets/collections/table/row_editing.ts +++ b/apps/client/src/widgets/collections/table/row_editing.ts @@ -6,7 +6,6 @@ import { RefObject } from "preact"; import { setAttribute, setLabel } from "../../../services/attributes"; import froca from "../../../services/froca"; import server from "../../../services/server"; -import FNote from "../../../entities/fnote"; import branches from "../../../services/branches"; import AttributeDetailWidget from "../../attribute_widgets/attribute_detail"; diff --git a/apps/client/src/widgets/view_widgets/table_view/index.ts b/apps/client/src/widgets/view_widgets/table_view/index.ts index 2011a7456e..d0684b3172 100644 --- a/apps/client/src/widgets/view_widgets/table_view/index.ts +++ b/apps/client/src/widgets/view_widgets/table_view/index.ts @@ -60,16 +60,6 @@ export default class TableView extends ViewMode { setupContextMenu(this.api, this.parentNote); } - async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) { - if (loadResults.getBranchRows().some(branch => branch.parentNoteId === this.parentNote.noteId || this.noteIds.includes(branch.parentNoteId ?? "")) - || loadResults.getNoteIds().some(noteId => this.noteIds.includes(noteId)) - || loadResults.getAttributeRows().some(attr => this.noteIds.includes(attr.noteId!))) { - return await this.#manageRowsUpdate(); - } - - return false; - } - #manageColumnUpdate() { if (!this.api) { return; From 32ce6e7a081a9ee579a4c625b7ce34ef8d35b488 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 9 Sep 2025 19:41:38 +0300 Subject: [PATCH 116/233] chore(react/collections/table): integrate cleanup --- .../widgets/collections/table/col_editing.ts | 24 +++-- .../src/widgets/collections/table/index.tsx | 7 +- .../view_widgets/table_view/col_editing.ts | 37 ------- .../widgets/view_widgets/table_view/index.ts | 101 ------------------ 4 files changed, 19 insertions(+), 150 deletions(-) delete mode 100644 apps/client/src/widgets/view_widgets/table_view/col_editing.ts delete mode 100644 apps/client/src/widgets/view_widgets/table_view/index.ts diff --git a/apps/client/src/widgets/collections/table/col_editing.ts b/apps/client/src/widgets/collections/table/col_editing.ts index 8e15677d9f..53f63a152b 100644 --- a/apps/client/src/widgets/collections/table/col_editing.ts +++ b/apps/client/src/widgets/collections/table/col_editing.ts @@ -2,7 +2,7 @@ import { useLegacyImperativeHandlers } from "../../react/hooks"; import { Attribute } from "../../../services/attribute_parser"; import { RefObject } from "preact"; import { Tabulator } from "tabulator-tables"; -import { useRef, useState } from "preact/hooks"; +import { useRef } from "preact/hooks"; import { CommandListenerData, EventData } from "../../../components/app_context"; import AttributeDetailWidget from "../../attribute_widgets/attribute_detail"; import attributes from "../../../services/attributes"; @@ -14,7 +14,7 @@ import { executeBulkActions } from "../../../services/bulk_action"; export default function useColTableEditing(api: RefObject, attributeDetailWidget: AttributeDetailWidget, parentNote: FNote) { - const [ existingAttributeToEdit, setExistingAttributeToEdit ] = useState(); + const existingAttributeToEdit = useRef(); const newAttribute = useRef(); const newAttributePosition = useRef(); @@ -22,11 +22,11 @@ export default function useColTableEditing(api: RefObject, attributeD addNewTableColumnCommand({ referenceColumn, columnToEdit, direction, type }: EventData<"addNewTableColumn">) { let attr: Attribute | undefined; - setExistingAttributeToEdit(undefined); + existingAttributeToEdit.current = undefined; if (columnToEdit) { attr = getAttributeFromField(parentNote, columnToEdit.getField()); if (attr) { - setExistingAttributeToEdit({ ...attr }); + existingAttributeToEdit.current = { ...attr }; } } @@ -71,16 +71,16 @@ export default function useColTableEditing(api: RefObject, attributeD const { name, value, isInheritable } = newAttribute.current; api.current.blockRedraw(); - const isRename = (existingAttributeToEdit && existingAttributeToEdit.name !== name); + const isRename = (existingAttributeToEdit.current && existingAttributeToEdit.current.name !== name); try { if (isRename) { - const oldName = existingAttributeToEdit!.name.split(":")[1]; + const oldName = existingAttributeToEdit.current!.name.split(":")[1]; const [ type, newName ] = name.split(":"); await renameColumn(parentNote.noteId, type as "label" | "relation", oldName, newName); } - if (existingAttributeToEdit && (isRename || existingAttributeToEdit.isInheritable !== isInheritable)) { - attributes.removeOwnedLabelByName(parentNote, existingAttributeToEdit.name); + if (existingAttributeToEdit.current && (isRename || existingAttributeToEdit.current.isInheritable !== isInheritable)) { + attributes.removeOwnedLabelByName(parentNote, existingAttributeToEdit.current.name); } attributes.setLabel(parentNote.noteId, name, value, isInheritable); } finally { @@ -108,7 +108,13 @@ export default function useColTableEditing(api: RefObject, attributeD } }); - return { newAttributePosition }; + function resetNewAttributePosition() { + newAttribute.current = undefined; + newAttributePosition.current = undefined; + existingAttributeToEdit.current = undefined; + } + + return { newAttributePosition, resetNewAttributePosition }; } async function deleteColumn(parentNoteId: string, type: "label" | "relation", columnName: string) { diff --git a/apps/client/src/widgets/collections/table/index.tsx b/apps/client/src/widgets/collections/table/index.tsx index 5ab96ee6e3..fc2e71a7bd 100644 --- a/apps/client/src/widgets/collections/table/index.tsx +++ b/apps/client/src/widgets/collections/table/index.tsx @@ -31,8 +31,8 @@ export default function TableView({ note, noteIds, notePath, viewConfig, saveCon const contextMenuEvents = useContextMenu(note, parentComponent, tabulatorRef); const persistenceProps = usePersistence(viewConfig, saveConfig); const rowEditingEvents = useRowTableEditing(tabulatorRef, attributeDetailWidget, notePath); - const { newAttributePosition } = useColTableEditing(tabulatorRef, attributeDetailWidget, note); - const { columnDefs, rowData, movableRows, hasChildren } = useData(note, noteIds, viewConfig, newAttributePosition); + const { newAttributePosition, resetNewAttributePosition } = useColTableEditing(tabulatorRef, attributeDetailWidget, note); + const { columnDefs, rowData, movableRows, hasChildren } = useData(note, noteIds, viewConfig, newAttributePosition, resetNewAttributePosition); const dataTreeProps = useMemo(() => { if (!hasChildren) return {}; return { @@ -108,7 +108,7 @@ function usePersistence(initialConfig: TableConfig | null | undefined, saveConfi return { persistenceReaderFunc, persistenceWriterFunc }; } -function useData(note: FNote, noteIds: string[], viewConfig: TableConfig | undefined, newAttributePosition: RefObject) { +function useData(note: FNote, noteIds: string[], viewConfig: TableConfig | undefined, newAttributePosition: RefObject, resetNewAttributePosition: () => void) { const [ maxDepth ] = useNoteLabelInt(note, "maxNestingDepth") ?? -1; const [ columnDefs, setColumnDefs ] = useState(); @@ -130,6 +130,7 @@ function useData(note: FNote, noteIds: string[], viewConfig: TableConfig | undef setColumnDefs(columnDefs); setRowData(rowData); setHasChildren(hasChildren); + resetNewAttributePosition(); }); } diff --git a/apps/client/src/widgets/view_widgets/table_view/col_editing.ts b/apps/client/src/widgets/view_widgets/table_view/col_editing.ts deleted file mode 100644 index 1a6939b3cf..0000000000 --- a/apps/client/src/widgets/view_widgets/table_view/col_editing.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Tabulator } from "tabulator-tables"; -import AttributeDetailWidget from "../../attribute_widgets/attribute_detail"; -import Component from "../../../components/component"; -import { CommandListenerData, EventData } from "../../../components/app_context"; -import attributes from "../../../services/attributes"; -import FNote from "../../../entities/fnote"; -import { deleteColumn, renameColumn } from "./bulk_actions"; -import dialog from "../../../services/dialog"; -import { t } from "../../../services/i18n"; - -export default class TableColumnEditing extends Component { - - private api: Tabulator; - private parentNote: FNote; - - private newAttribute?: Attribute; - - constructor($parent: JQuery, parentNote: FNote, api: Tabulator) { - super(); - const parentComponent = glob.getComponentByEl($parent[0]); - this.api = api; - this.parentNote = parentNote; - } - - getNewAttributePosition() { - return this.newAttributePosition; - } - - resetNewAttributePosition() { - this.newAttribute = undefined; - this.newAttributePosition = undefined; - this.existingAttributeToEdit = undefined; - } - - - -} diff --git a/apps/client/src/widgets/view_widgets/table_view/index.ts b/apps/client/src/widgets/view_widgets/table_view/index.ts deleted file mode 100644 index d0684b3172..0000000000 --- a/apps/client/src/widgets/view_widgets/table_view/index.ts +++ /dev/null @@ -1,101 +0,0 @@ -import ViewMode, { type ViewModeArgs } from "../view_mode.js"; -import attributes from "../../../services/attributes.js"; -import SpacedUpdate from "../../../services/spaced_update.js"; -import type { EventData } from "../../../components/app_context.js"; - -import buildFooter from "./footer.js"; -import getAttributeDefinitionInformation, { buildRowDefinitions } from "./rows.js"; -import { AttributeDefinitionInformation, buildColumnDefinitions } from "./columns.js"; -import { setupContextMenu } from "./context_menu.js"; -import TableColumnEditing from "./col_editing.js"; -import TableRowEditing from "./row_editing.js"; - -const TPL = /*html*/` - -`; - -export interface StateInfo { - -} - -export default class TableView extends ViewMode { - - private $root: JQuery; - private $container: JQuery; - private spacedUpdate: SpacedUpdate; - private api?: Tabulator; - private persistentData: StateInfo["tableData"]; - private colEditing?: TableColumnEditing; - private rowEditing?: TableRowEditing; - private maxDepth: number = -1; - private rowNumberHint: number = 1; - - constructor(args: ViewModeArgs) { - super(args, "table"); - - this.$root = $(TPL); - this.$container = this.$root.find(".table-view-container"); - this.spacedUpdate = new SpacedUpdate(() => this.onSave(), 5_000); - this.persistentData = {}; - args.$parent.append(this.$root); - } - - async renderList() { - this.$container.empty(); - this.renderTable(this.$container[0]); - return this.$root; - } - - private async initialize(el: HTMLElement, info: AttributeDefinitionInformation[]) { - const viewStorage = await this.viewStorage.restore(); - this.persistentData = viewStorage?.tableData || {}; - - this.api = new Tabulator(el, opts); - - this.colEditing = new TableColumnEditing(this.args.$parent, this.args.parentNote, this.api); - this.rowEditing = new TableRowEditing(this.api, this.args.parentNotePath!); - - setupContextMenu(this.api, this.parentNote); - } - - #manageColumnUpdate() { - if (!this.api) { - return; - } - - const info = getAttributeDefinitionInformation(this.parentNote); - const columnDefs = buildColumnDefinitions({ - info, - movableRows: !!this.api.options.movableRows, - existingColumnData: this.persistentData?.columns, - rowNumberHint: this.rowNumberHint, - position: this.colEditing?.getNewAttributePosition() - }); - this.api.setColumns(columnDefs); - this.colEditing?.resetNewAttributePosition(); - } - - deleteTableColumnCommand(e) { this.colEditing?.deleteTableColumnCommand(e); } - - async #manageRowsUpdate() { - if (!this.api) { - return; - } - - const info = getAttributeDefinitionInformation(this.parentNote); - const { definitions, hasSubtree, rowNumber } = await buildRowDefinitions(this.parentNote, info, this.maxDepth); - this.rowNumberHint = rowNumber; - - // Force a refresh if the data tree needs enabling/disabling. - if (this.api.options.dataTree !== hasSubtree) { - return true; - } - - await this.api.replaceData(definitions); - return false; - } - -} - From 33a37be378200fa5a2d7de78ea8a08db899a6494 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 9 Sep 2025 19:49:57 +0300 Subject: [PATCH 117/233] chore(react/collections/table): fix occasional error when initializing --- apps/client/src/widgets/collections/table/tabulator.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/client/src/widgets/collections/table/tabulator.tsx b/apps/client/src/widgets/collections/table/tabulator.tsx index 9191f22da7..fdcbeb532f 100644 --- a/apps/client/src/widgets/collections/table/tabulator.tsx +++ b/apps/client/src/widgets/collections/table/tabulator.tsx @@ -33,11 +33,13 @@ export default function Tabulator({ className, columns, data, modules, tabula columns, data, footerElement: (parentComponent && footerElement ? renderReactWidget(parentComponent, footerElement)[0] : undefined), - ...restProps, + ...restProps }); - tabulatorRef.current = tabulator; - externalTabulatorRef.current = tabulator; + tabulator.on("tableBuilt", () => { + tabulatorRef.current = tabulator; + externalTabulatorRef.current = tabulator; + }); return () => tabulator.destroy(); }, []); From 043791fc910910ee25d42a98b51087d584eeaea3 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 9 Sep 2025 20:35:57 +0300 Subject: [PATCH 118/233] chore(react/collections/table): port note ID formatter --- .../table/{columns.ts => columns.tsx} | 20 ++++++++++++++++--- .../widgets/collections/table/formatters.ts | 4 ---- apps/client/src/widgets/react/react_utils.tsx | 4 ++-- 3 files changed, 19 insertions(+), 9 deletions(-) rename apps/client/src/widgets/collections/table/{columns.ts => columns.tsx} (85%) diff --git a/apps/client/src/widgets/collections/table/columns.ts b/apps/client/src/widgets/collections/table/columns.tsx similarity index 85% rename from apps/client/src/widgets/collections/table/columns.ts rename to apps/client/src/widgets/collections/table/columns.tsx index 294d1f2c16..197ccba541 100644 --- a/apps/client/src/widgets/collections/table/columns.ts +++ b/apps/client/src/widgets/collections/table/columns.tsx @@ -1,7 +1,9 @@ -import { MonospaceFormatter, NoteFormatter, NoteTitleFormatter, RowNumberFormatter } from "./formatters.js"; -import type { ColumnDefinition } from "tabulator-tables"; +import { NoteFormatter, NoteTitleFormatter, RowNumberFormatter } from "./formatters.js"; +import type { CellComponent, ColumnDefinition, EmptyCallback } from "tabulator-tables"; import { LabelType } from "../../../services/promoted_attribute_definition_parser.js"; import { RelationEditor } from "./relation_editor.js"; +import { JSX } from "preact"; +import { renderReactWidget } from "../../react/react_utils.jsx"; type ColumnType = LabelType | "relation"; @@ -73,7 +75,7 @@ export function buildColumnDefinitions({ info, movableRows, existingColumnData, { field: "noteId", title: "Note ID", - formatter: MonospaceFormatter, + formatter: wrapFormatter(({ cell }) => {cell.getValue()}), visible: false }, { @@ -154,3 +156,15 @@ function calculateIndexColumnWidth(rowNumberHint: number, movableRows: boolean): } return columnWidth; } + +interface FormatterOpts { + cell: CellComponent +} + +function wrapFormatter(Component: (opts: FormatterOpts) => JSX.Element): ((cell: CellComponent, formatterParams: {}, onRendered: EmptyCallback) => string | HTMLElement) { + return (cell, formatterParams, onRendered) => { + const elWithParams = ; + return renderReactWidget(null, elWithParams)[0]; + }; +} + diff --git a/apps/client/src/widgets/collections/table/formatters.ts b/apps/client/src/widgets/collections/table/formatters.ts index a333742e9a..85aee60252 100644 --- a/apps/client/src/widgets/collections/table/formatters.ts +++ b/apps/client/src/widgets/collections/table/formatters.ts @@ -71,10 +71,6 @@ export function RowNumberFormatter(draggableRows: boolean) { }; } -export function MonospaceFormatter(cell: CellComponent) { - return `${cell.getValue()}`; -} - function buildNoteLink(noteId: string, title: string, iconClass: string, colorClass?: string) { const $noteRef = $(""); const href = `#root/${noteId}`; diff --git a/apps/client/src/widgets/react/react_utils.tsx b/apps/client/src/widgets/react/react_utils.tsx index 5e436bf143..d752662f55 100644 --- a/apps/client/src/widgets/react/react_utils.tsx +++ b/apps/client/src/widgets/react/react_utils.tsx @@ -24,11 +24,11 @@ export function refToJQuerySelector(ref: RefObject | 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(( {el} From e3d9a120cbc78eb7abd58a68e6741d2f24d953eb Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 9 Sep 2025 21:03:55 +0300 Subject: [PATCH 119/233] chore(react/collections/table): port row number formatter --- .../src/widgets/collections/table/columns.tsx | 15 ++++++++++++--- .../src/widgets/collections/table/formatters.ts | 11 ----------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/apps/client/src/widgets/collections/table/columns.tsx b/apps/client/src/widgets/collections/table/columns.tsx index 197ccba541..1e04822f40 100644 --- a/apps/client/src/widgets/collections/table/columns.tsx +++ b/apps/client/src/widgets/collections/table/columns.tsx @@ -1,5 +1,5 @@ import { NoteFormatter, NoteTitleFormatter, RowNumberFormatter } from "./formatters.js"; -import type { CellComponent, ColumnDefinition, EmptyCallback } from "tabulator-tables"; +import type { CellComponent, ColumnDefinition, EmptyCallback, FormatterParams } from "tabulator-tables"; import { LabelType } from "../../../services/promoted_attribute_definition_parser.js"; import { RelationEditor } from "./relation_editor.js"; import { JSX } from "preact"; @@ -60,6 +60,10 @@ interface BuildColumnArgs { position?: number; } +interface RowNumberFormatterParams { + movableRows?: boolean; +} + export function buildColumnDefinitions({ info, movableRows, existingColumnData, rowNumberHint, position }: BuildColumnArgs) { let columnDefs: ColumnDefinition[] = [ { @@ -70,7 +74,11 @@ export function buildColumnDefinitions({ info, movableRows, existingColumnData, frozen: true, rowHandle: movableRows, width: calculateIndexColumnWidth(rowNumberHint, movableRows), - formatter: RowNumberFormatter(movableRows) + formatter: wrapFormatter(({ cell, formatterParams }) =>
+ {(formatterParams as RowNumberFormatterParams).movableRows && <>{" "}} + {cell.getRow().getPosition(true)} +
), + formatterParams: { movableRows } satisfies RowNumberFormatterParams }, { field: "noteId", @@ -159,11 +167,12 @@ function calculateIndexColumnWidth(rowNumberHint: number, movableRows: boolean): interface FormatterOpts { cell: CellComponent + formatterParams: FormatterParams; } function wrapFormatter(Component: (opts: FormatterOpts) => JSX.Element): ((cell: CellComponent, formatterParams: {}, onRendered: EmptyCallback) => string | HTMLElement) { return (cell, formatterParams, onRendered) => { - const elWithParams = ; + const elWithParams = ; return renderReactWidget(null, elWithParams)[0]; }; } diff --git a/apps/client/src/widgets/collections/table/formatters.ts b/apps/client/src/widgets/collections/table/formatters.ts index 85aee60252..8732313813 100644 --- a/apps/client/src/widgets/collections/table/formatters.ts +++ b/apps/client/src/widgets/collections/table/formatters.ts @@ -60,17 +60,6 @@ export function NoteTitleFormatter(cell: CellComponent) { return $noteRef[0].outerHTML; } -export function RowNumberFormatter(draggableRows: boolean) { - return (cell: CellComponent) => { - let html = ""; - if (draggableRows) { - html += ` `; - } - html += cell.getRow().getPosition(true); - return html; - }; -} - function buildNoteLink(noteId: string, title: string, iconClass: string, colorClass?: string) { const $noteRef = $(""); const href = `#root/${noteId}`; From 4d57134aa2cc8e93ac03a261f3847035b85042ec Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 9 Sep 2025 21:11:06 +0300 Subject: [PATCH 120/233] chore(react/collections/table): port note title formatter --- .../src/widgets/collections/table/columns.tsx | 13 +++++++++++- .../widgets/collections/table/formatters.ts | 20 ------------------- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/apps/client/src/widgets/collections/table/columns.tsx b/apps/client/src/widgets/collections/table/columns.tsx index 1e04822f40..8a6e319af3 100644 --- a/apps/client/src/widgets/collections/table/columns.tsx +++ b/apps/client/src/widgets/collections/table/columns.tsx @@ -4,6 +4,8 @@ import { LabelType } from "../../../services/promoted_attribute_definition_parse import { RelationEditor } from "./relation_editor.js"; import { JSX } from "preact"; import { renderReactWidget } from "../../react/react_utils.jsx"; +import NoteTitleWidget from "../../note_title.jsx"; +import Icon from "../../react/Icon.jsx"; type ColumnType = LabelType | "relation"; @@ -90,7 +92,16 @@ export function buildColumnDefinitions({ info, movableRows, existingColumnData, field: "title", title: "Title", editor: "input", - formatter: NoteTitleFormatter, + formatter: wrapFormatter(({ cell }) => { + const { noteId, iconClass, colorClass } = cell.getRow().getData(); + if (!noteId) { + return ""; + } + + return + {" "}{cell.getValue()} + ; + }), width: 400 } ]; diff --git a/apps/client/src/widgets/collections/table/formatters.ts b/apps/client/src/widgets/collections/table/formatters.ts index 8732313813..88a7b2cb1a 100644 --- a/apps/client/src/widgets/collections/table/formatters.ts +++ b/apps/client/src/widgets/collections/table/formatters.ts @@ -47,28 +47,8 @@ export function NoteFormatter(cell: CellComponent, _formatterParams, onRendered) } } -/** - * Custom formatter for the note title that is quite similar to {@link NoteFormatter}, but where the title and icons are read from separate fields. - */ -export function NoteTitleFormatter(cell: CellComponent) { - const { noteId, iconClass, colorClass } = cell.getRow().getData(); - if (!noteId) { - return ""; - } - - const { $noteRef } = buildNoteLink(noteId, cell.getValue(), iconClass, colorClass); - return $noteRef[0].outerHTML; -} - function buildNoteLink(noteId: string, title: string, iconClass: string, colorClass?: string) { const $noteRef = $(""); const href = `#root/${noteId}`; - $noteRef.addClass("reference-link"); - $noteRef.attr("data-href", href); - $noteRef.text(title); - $noteRef.prepend($("").addClass(iconClass)); - if (colorClass) { - $noteRef.addClass(colorClass); - } return { $noteRef, href }; } From 3789edf53ac46f2d7ecef05153d1599554e344dc Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 9 Sep 2025 21:20:52 +0300 Subject: [PATCH 121/233] chore(react/collections/table): port note relation formatter --- .../src/widgets/collections/table/columns.tsx | 21 ++++++-- .../widgets/collections/table/formatters.ts | 54 ------------------- 2 files changed, 18 insertions(+), 57 deletions(-) delete mode 100644 apps/client/src/widgets/collections/table/formatters.ts diff --git a/apps/client/src/widgets/collections/table/columns.tsx b/apps/client/src/widgets/collections/table/columns.tsx index 8a6e319af3..6646d643eb 100644 --- a/apps/client/src/widgets/collections/table/columns.tsx +++ b/apps/client/src/widgets/collections/table/columns.tsx @@ -1,11 +1,12 @@ -import { NoteFormatter, NoteTitleFormatter, RowNumberFormatter } from "./formatters.js"; +import { NoteTitleFormatter, RowNumberFormatter } from "./formatters.js"; import type { CellComponent, ColumnDefinition, EmptyCallback, FormatterParams } from "tabulator-tables"; import { LabelType } from "../../../services/promoted_attribute_definition_parser.js"; import { RelationEditor } from "./relation_editor.js"; import { JSX } from "preact"; import { renderReactWidget } from "../../react/react_utils.jsx"; -import NoteTitleWidget from "../../note_title.jsx"; import Icon from "../../react/Icon.jsx"; +import { useEffect, useState } from "preact/hooks"; +import froca from "../../../services/froca.js"; type ColumnType = LabelType | "relation"; @@ -50,7 +51,7 @@ const labelTypeMappings: Record> = { }, relation: { editor: RelationEditor, - formatter: NoteFormatter + formatter: wrapFormatter(NoteFormatter) } }; @@ -188,3 +189,17 @@ function wrapFormatter(Component: (opts: FormatterOpts) => JSX.Element): ((cell: }; } +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 + {note && <>{" "}{note.title}} + ; +} + diff --git a/apps/client/src/widgets/collections/table/formatters.ts b/apps/client/src/widgets/collections/table/formatters.ts deleted file mode 100644 index 88a7b2cb1a..0000000000 --- a/apps/client/src/widgets/collections/table/formatters.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { CellComponent } from "tabulator-tables"; -import froca from "../../../services/froca.js"; -import FNote from "../../../entities/fnote.js"; - -/** - * Custom formatter to represent a note, with the icon and note title being rendered. - * - * The value of the cell must be the note ID. - */ -export function NoteFormatter(cell: CellComponent, _formatterParams, onRendered): string { - let noteId = cell.getValue(); - if (!noteId) { - return ""; - } - - function buildLink(note: FNote | undefined) { - if (!note) { - return; - } - - const iconClass = note.getIcon(); - const title = note.title; - const { $noteRef } = buildNoteLink(noteId, title, iconClass, note.getColorClass()); - return $noteRef[0]; - } - - const cachedNote = froca.getNoteFromCache(noteId); - if (cachedNote) { - // Cache hit, build the link immediately - const el = buildLink(cachedNote); - return el?.outerHTML ?? ""; - } else { - // Cache miss, load the note asynchronously - onRendered(async () => { - const note = await froca.getNote(noteId); - if (!note) { - return; - } - - const el = buildLink(note); - if (el) { - cell.getElement().appendChild(el); - } - }); - - return ""; - } -} - -function buildNoteLink(noteId: string, title: string, iconClass: string, colorClass?: string) { - const $noteRef = $(""); - const href = `#root/${noteId}`; - return { $noteRef, href }; -} From cb959e93f27fdc932267c34c3d073cb8d9c13e54 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 10 Sep 2025 18:48:42 +0300 Subject: [PATCH 122/233] chore(react/collections/table): fix type error --- apps/client/src/widgets/collections/table/columns.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/client/src/widgets/collections/table/columns.tsx b/apps/client/src/widgets/collections/table/columns.tsx index 6646d643eb..2edcccaf89 100644 --- a/apps/client/src/widgets/collections/table/columns.tsx +++ b/apps/client/src/widgets/collections/table/columns.tsx @@ -95,10 +95,6 @@ export function buildColumnDefinitions({ info, movableRows, existingColumnData, editor: "input", formatter: wrapFormatter(({ cell }) => { const { noteId, iconClass, colorClass } = cell.getRow().getData(); - if (!noteId) { - return ""; - } - return {" "}{cell.getValue()} ; From 7777cd5238b013f7bf6b3ab00a6301a1145993c8 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 10 Sep 2025 19:05:01 +0300 Subject: [PATCH 123/233] chore(react/collections/table): integrate relation editor --- .../src/widgets/collections/table/columns.tsx | 43 ++++++++++++-- .../collections/table/relation_editor.ts | 56 ------------------- 2 files changed, 38 insertions(+), 61 deletions(-) delete mode 100644 apps/client/src/widgets/collections/table/relation_editor.ts diff --git a/apps/client/src/widgets/collections/table/columns.tsx b/apps/client/src/widgets/collections/table/columns.tsx index 2edcccaf89..43390f04b2 100644 --- a/apps/client/src/widgets/collections/table/columns.tsx +++ b/apps/client/src/widgets/collections/table/columns.tsx @@ -1,12 +1,11 @@ -import { NoteTitleFormatter, RowNumberFormatter } from "./formatters.js"; -import type { CellComponent, ColumnDefinition, EmptyCallback, FormatterParams } from "tabulator-tables"; +import type { CellComponent, ColumnDefinition, EmptyCallback, FormatterParams, ValueBooleanCallback, ValueVoidCallback } from "tabulator-tables"; import { LabelType } from "../../../services/promoted_attribute_definition_parser.js"; -import { RelationEditor } from "./relation_editor.js"; import { JSX } from "preact"; import { renderReactWidget } from "../../react/react_utils.jsx"; import Icon from "../../react/Icon.jsx"; -import { useEffect, useState } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; import froca from "../../../services/froca.js"; +import NoteAutocomplete from "../../react/NoteAutocomplete.jsx"; type ColumnType = LabelType | "relation"; @@ -50,7 +49,7 @@ const labelTypeMappings: Record> = { } }, relation: { - editor: RelationEditor, + editor: wrapEditor(RelationEditor), formatter: wrapFormatter(NoteFormatter) } }; @@ -178,6 +177,14 @@ interface FormatterOpts { formatterParams: FormatterParams; } +interface EditorOpts { + cell: CellComponent, + onRendered: EmptyCallback, + 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 = ; @@ -185,6 +192,18 @@ function wrapFormatter(Component: (opts: FormatterOpts) => JSX.Element): ((cell: }; } +function wrapEditor(Component: (opts: EditorOpts) => JSX.Element): (( + cell: CellComponent, + success: ValueBooleanCallback, + cancel: ValueVoidCallback, + editorParams: {}, +) => HTMLElement | false) { + return (cell, _, success, cancel, editorParams) => { + const elWithParams = + return renderReactWidget(null, elWithParams)[0]; + }; +} + function NoteFormatter({ cell }: FormatterOpts) { const noteId = cell.getValue(); const [ note, setNote ] = useState(noteId ? froca.getNoteFromCache(noteId) : null) @@ -199,3 +218,17 @@ function NoteFormatter({ cell }: FormatterOpts) { ; } +function RelationEditor({ cell, success }: EditorOpts) { + const inputRef = useRef(null); + useEffect(() => inputRef.current?.focus()); + + return +} diff --git a/apps/client/src/widgets/collections/table/relation_editor.ts b/apps/client/src/widgets/collections/table/relation_editor.ts deleted file mode 100644 index 8e948cc2ab..0000000000 --- a/apps/client/src/widgets/collections/table/relation_editor.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { CellComponent } from "tabulator-tables"; -import note_autocomplete from "../../../services/note_autocomplete"; -import froca from "../../../services/froca"; - -export function RelationEditor(cell: CellComponent, onRendered, success, cancel, editorParams){ - //cell - the cell component for the editable cell - //onRendered - function to call when the editor has been rendered - //success - function to call to pass thesuccessfully updated value to Tabulator - //cancel - function to call to abort the edit and return to a normal cell - //editorParams - params object passed into the editorParams column definition property - - //create and style editor - const editor = document.createElement("input"); - - const $editor = $(editor); - editor.classList.add("form-control"); - - //create and style input - editor.style.padding = "3px"; - editor.style.width = "100%"; - editor.style.boxSizing = "border-box"; - - //Set value of editor to the current value of the cell - const originalNoteId = cell.getValue(); - if (originalNoteId) { - const note = froca.getNoteFromCache(originalNoteId); - editor.value = note.title; - } else { - editor.value = ""; - } - - //set focus on the select box when the editor is selected - onRendered(function(){ - let newNoteId = originalNoteId; - - note_autocomplete.initNoteAutocomplete($editor, { - allowCreatingNotes: true, - hideAllButtons: true - }).on("autocomplete:noteselected", (event, suggestion, dataset) => { - const notePath = suggestion.notePath; - newNoteId = (notePath ?? "").split("/").at(-1); - }).on("blur", () => { - if (!editor.value) { - newNoteId = ""; - } - success(newNoteId); - }); - editor.focus(); - }); - - const container = document.createElement("div"); - container.classList.add("input-group"); - container.classList.add("autocomplete"); - container.appendChild(editor); - return container; -}; From 4247c8fdc672ad2fec9407e7481ee76af23a74f6 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 10 Sep 2025 20:18:17 +0300 Subject: [PATCH 124/233] chore(react/collections/board): render empty columns --- .../src/widgets/collections/NoteList.tsx | 3 + .../board_view => collections/board}/data.ts | 6 +- .../src/widgets/collections/board/index.css | 264 +++++++++++++++++ .../src/widgets/collections/board/index.tsx | 59 ++++ .../widgets/view_widgets/board_view/api.ts | 19 +- .../widgets/view_widgets/board_view/config.ts | 7 - .../board_view/differential_renderer.ts | 18 -- .../widgets/view_widgets/board_view/index.ts | 273 ------------------ 8 files changed, 331 insertions(+), 318 deletions(-) rename apps/client/src/widgets/{view_widgets/board_view => collections/board}/data.ts (95%) create mode 100644 apps/client/src/widgets/collections/board/index.css create mode 100644 apps/client/src/widgets/collections/board/index.tsx delete mode 100644 apps/client/src/widgets/view_widgets/board_view/config.ts diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index b0dd946224..8e462e44b2 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -8,6 +8,7 @@ import GeoView from "./geomap"; import ViewModeStorage from "../view_widgets/view_mode_storage"; import CalendarView from "./calendar"; import TableView from "./table"; +import BoardView from "./board"; interface NoteListProps { note?: FNote | null; @@ -88,6 +89,8 @@ function getComponentByViewType(viewType: ViewTypeOptions, props: ViewModeProps< return case "table": return + case "board": + return } } diff --git a/apps/client/src/widgets/view_widgets/board_view/data.ts b/apps/client/src/widgets/collections/board/data.ts similarity index 95% rename from apps/client/src/widgets/view_widgets/board_view/data.ts rename to apps/client/src/widgets/collections/board/data.ts index f468f22926..2a59e82b78 100644 --- a/apps/client/src/widgets/view_widgets/board_view/data.ts +++ b/apps/client/src/widgets/collections/board/data.ts @@ -1,13 +1,13 @@ import FBranch from "../../../entities/fbranch"; import FNote from "../../../entities/fnote"; -import { BoardData } from "./config"; +import { BoardViewData } from "./index"; export type ColumnMap = Map; -export async function getBoardData(parentNote: FNote, groupByColumn: string, persistedData: BoardData) { +export async function getBoardData(parentNote: FNote, groupByColumn: string, persistedData: BoardViewData) { const byColumn: ColumnMap = new Map(); // First, scan all notes to find what columns actually exist @@ -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); diff --git a/apps/client/src/widgets/collections/board/index.css b/apps/client/src/widgets/collections/board/index.css new file mode 100644 index 0000000000..bc941b54ea --- /dev/null +++ b/apps/client/src/widgets/collections/board/index.css @@ -0,0 +1,264 @@ +.board-view { + overflow-x: auto; + position: relative; + height: 100%; + user-select: none; +} + +.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; + display: flex; + align-items: center; + justify-content: space-between; + box-sizing: border-box; + background-color: transparent; +} + +.board-view-container .board-column h3:active { + cursor: grabbing; +} + +.board-view-container .board-column h3.editing { + cursor: default; +} + +.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.column-dragging { + opacity: 0.6; + transform: scale(0.98); + transition: opacity 0.2s ease, transform 0.2s ease; +} + +.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 h3 .edit-icon { + opacity: 0; + margin-left: 0.5em; + transition: opacity 0.2s ease; + color: var(--muted-text-color); +} + +.board-view-container .board-column h3:hover .edit-icon { + opacity: 1; +} + +.board-view-container .board-column h3.editing .edit-icon { + display: none; +} + +.board-view-container .board-note { + box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.25); + margin: 0.65em 0; + padding: 0.5em; + border-radius: 5px; + cursor: move; + position: relative; + background-color: var(--main-background-color); + border: 1px solid var(--main-border-color); + transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.15s ease; + opacity: 1; +} + +.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.card-updated { + animation: cardUpdate 0.3s ease-in-out; +} + +@keyframes cardUpdate { + 0% { transform: scale(1); } + 50% { transform: scale(1.02); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); } + 100% { transform: scale(1); } +} + +.board-view-container .board-note:hover { + transform: translateY(-2px); + box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.35); +} + +.board-view-container .board-note.dragging { + opacity: 0.8; + transform: rotate(5deg); + z-index: 1000; + box-shadow: 4px 8px 16px rgba(0, 0, 0, 0.5); +} + +.board-view-container .board-note.editing { + box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.35); + border-color: var(--main-text-color); +} + +.board-view-container .board-note.editing input { + background: transparent; + border: none; + outline: none; + font-family: inherit; + font-size: inherit; + color: inherit; + width: 100%; + padding: 0; +} + +.board-view-container .board-note .icon { + margin-right: 0.25em; +} + +.board-drop-indicator { + height: 3px; + background-color: var(--main-text-color); + border-radius: 2px; + margin: 0.25em 0; + opacity: 0; + transition: opacity 0.2s ease; +} + +.board-drop-indicator.show { + opacity: 1; +} + +.column-drop-indicator { + width: 4px; + background-color: var(--main-text-color); + border-radius: 2px; + opacity: 0; + transition: opacity 0.2s ease; + height: 100%; + z-index: 1000; + box-shadow: 0 0 8px rgba(0, 0, 0, 0.3); + flex-shrink: 0; +} + +.column-drop-indicator.show { + opacity: 1; +} + +.board-new-item { + margin-top: 0.5em; + padding: 0.5em; + border-radius: 5px; + color: var(--muted-text-color); + cursor: pointer; + transition: all 0.2s ease; + background-color: transparent; +} + +.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-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; +} \ No newline at end of file diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx new file mode 100644 index 0000000000..a84e934b83 --- /dev/null +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from "preact/hooks"; +import { ViewModeProps } from "../interface"; +import "./index.css"; +import { ColumnMap, getBoardData } from "./data"; +import { useNoteLabel } from "../../react/hooks"; + +export interface BoardViewData { + columns?: BoardColumnData[]; +} + +export interface BoardColumnData { + value: string; +} + +export default function BoardView({ note: parentNote, noteIds, viewConfig, saveConfig }: ViewModeProps) { + const [ statusAttribute ] = useNoteLabel(parentNote, "board:groupBy"); + const [ byColumn, setByColumn ] = useState(); + const [ columns, setColumns ] = useState(); + + useEffect(() => { + getBoardData(parentNote, statusAttribute ?? "status", viewConfig ?? {}).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]); + }); + }, [ parentNote ]); + + return ( +
+
+ {columns?.map(column => ( + + ))} +
+
+ ) +} + +function Column({ column }: { column: string }) { + return ( +
+

+ {column} + +

+
+ ) +} diff --git a/apps/client/src/widgets/view_widgets/board_view/api.ts b/apps/client/src/widgets/view_widgets/board_view/api.ts index 20c51141ae..df354ace4f 100644 --- a/apps/client/src/widgets/view_widgets/board_view/api.ts +++ b/apps/client/src/widgets/view_widgets/board_view/api.ts @@ -135,16 +135,13 @@ export default class BoardApi { async refresh(parentNote: FNote) { // Refresh the API data by re-fetching from the parent note - const statusAttribute = parentNote.getLabelValue("board:groupBy") ?? "status"; - this._statusAttribute = statusAttribute; // Use the current in-memory persisted data instead of restoring from storage // This ensures we don't lose recent updates like column renames - const { byColumn, newPersistedData } = await getBoardData(parentNote, statusAttribute, this.persistedData); - + // Update internal state this.byColumn = byColumn; - + if (newPersistedData) { this.persistedData = newPersistedData; this.viewStorage.store(this.persistedData); @@ -161,18 +158,6 @@ export default class BoardApi { const statusAttribute = parentNote.getLabelValue("board:groupBy") ?? "status"; let persistedData = await viewStorage.restore() ?? {}; - const { byColumn, newPersistedData } = await getBoardData(parentNote, statusAttribute, persistedData); - - // Use the order from persistedData.columns, then add any new columns found - const orderedColumns = persistedData.columns?.map(col => col.value) || []; - const allColumns = Array.from(byColumn.keys()); - const newColumns = allColumns.filter(col => !orderedColumns.includes(col)); - const columns = [...orderedColumns, ...newColumns]; - - if (newPersistedData) { - persistedData = newPersistedData; - viewStorage.store(persistedData); - } return new BoardApi(columns, parentNote.noteId, viewStorage, byColumn, persistedData, statusAttribute); } diff --git a/apps/client/src/widgets/view_widgets/board_view/config.ts b/apps/client/src/widgets/view_widgets/board_view/config.ts deleted file mode 100644 index 92dd99f5f7..0000000000 --- a/apps/client/src/widgets/view_widgets/board_view/config.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface BoardColumnData { - value: string; -} - -export interface BoardData { - columns?: BoardColumnData[]; -} diff --git a/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts b/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts index 4f1cf64dc8..e73f74a512 100644 --- a/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts +++ b/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts @@ -329,24 +329,6 @@ export class DifferentialBoardRenderer { } private createColumn(column: string, columnItems: { note: any; branch: any }[]): JQuery { - const $columnEl = $("
") - .addClass("board-column") - .attr("data-column", column); - - // Create header - const $titleEl = $("

").attr("data-column-value", column); - - // Create title text - const $titleText = $("").text(column); - - // Create edit icon - const $editIcon = $("") - .addClass("edit-icon icon bx bx-edit-alt") - .attr("title", "Click to edit column title"); - - $titleEl.append($titleText, $editIcon); - $columnEl.append($titleEl); - // Setup column dragging this.dragHandler.setupColumnDrag($columnEl, column); diff --git a/apps/client/src/widgets/view_widgets/board_view/index.ts b/apps/client/src/widgets/view_widgets/board_view/index.ts index 1a4a48bb26..ec203068fc 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -9,279 +9,6 @@ import BoardApi from "./api"; import { BoardDragHandler, DragContext } from "./drag_handler"; import { DifferentialBoardRenderer } from "./differential_renderer"; -const TPL = /*html*/` -
- - -
-
-`; - export default class BoardView extends ViewMode { private $root: JQuery; From 4b769da90b4338bd41a23f41cec04345d0852030 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 10 Sep 2025 20:38:12 +0300 Subject: [PATCH 125/233] chore(react/collections/board): render items --- .../src/widgets/collections/board/index.tsx | 23 +++++++++++++-- .../board_view/differential_renderer.ts | 28 ------------------- 2 files changed, 20 insertions(+), 31 deletions(-) diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index a84e934b83..4220cca8e7 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -3,6 +3,8 @@ import { ViewModeProps } from "../interface"; import "./index.css"; import { ColumnMap, getBoardData } from "./data"; import { useNoteLabel } from "../../react/hooks"; +import FNote from "../../../entities/fnote"; +import FBranch from "../../../entities/fbranch"; export interface BoardViewData { columns?: BoardColumnData[]; @@ -37,15 +39,15 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC return (
- {columns?.map(column => ( - + {byColumn && columns?.map(column => ( + ))}
) } -function Column({ column }: { column: string }) { +function Column({ column, columnItems }: { column: string, columnItems?: { note: FNote, branch: FBranch }[] }) { return (

@@ -54,6 +56,21 @@ function Column({ column }: { column: string }) { className="edit-icon icon bx bx-edit-alt" title="Click to edit column title" />

+ + {(columnItems ?? []).map(({ note, branch }) => ( + + ))} +
+ ) +} + +function Card({ note }: { note: FNote, branch: FBranch, column: string }) { + const colorClass = note.getColorClass() || ''; + + return ( +
+ + {note.title}
) } diff --git a/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts b/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts index e73f74a512..474fa73172 100644 --- a/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts +++ b/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts @@ -345,14 +345,6 @@ export class DifferentialBoardRenderer { this.dragHandler.setupNoteDropZone($columnEl, column); this.dragHandler.setupColumnDropZone($columnEl); - // Add cards - for (const item of columnItems) { - if (item.note) { - const $noteEl = this.createCard(item.note, item.branch, column); - $columnEl.append($noteEl); - } - } - // Add "New item" button const $newItemEl = $("
") .addClass("board-new-item") @@ -366,26 +358,6 @@ export class DifferentialBoardRenderer { } private createCard(note: any, branch: any, column: string): JQuery { - const $iconEl = $("") - .addClass("icon") - .addClass(note.getIcon()); - - const colorClass = note.getColorClass() || ''; - - const $noteEl = $("
") - .addClass("board-note") - .attr("data-note-id", note.noteId) - .attr("data-branch-id", branch.branchId) - .attr("data-current-column", column) - .attr("data-icon-class", note.getIcon()) - .attr("data-color-class", colorClass) - .text(note.title); - - // Add color class to the card if it exists - if (colorClass) { - $noteEl.addClass(colorClass); - } - $noteEl.prepend($iconEl); $noteEl.on("click", () => appContext.triggerCommand("openInPopup", { noteIdOrPath: note.noteId })); From ecf8c4ffbeb6cc3265d58615ca1afc139f57e42c Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 10 Sep 2025 21:10:31 +0300 Subject: [PATCH 126/233] chore(react/collections/board): get new items to be created --- .../src/widgets/collections/board/api.ts | 31 +++++++++++++++++++ .../src/widgets/collections/board/index.tsx | 16 ++++++++-- .../widgets/view_widgets/board_view/api.ts | 4 --- .../board_view/differential_renderer.ts | 3 +- .../widgets/view_widgets/board_view/index.ts | 27 ---------------- 5 files changed, 46 insertions(+), 35 deletions(-) create mode 100644 apps/client/src/widgets/collections/board/api.ts diff --git a/apps/client/src/widgets/collections/board/api.ts b/apps/client/src/widgets/collections/board/api.ts new file mode 100644 index 0000000000..70246efccd --- /dev/null +++ b/apps/client/src/widgets/collections/board/api.ts @@ -0,0 +1,31 @@ +import FNote from "../../../entities/fnote"; +import attributes from "../../../services/attributes"; +import note_create from "../../../services/note_create"; + +export async function createNewItem(parentNote: FNote, column: string) { + try { + // Get the parent note path + const parentNotePath = parentNote.noteId; + const statusAttribute = parentNote.getLabelValue("board:groupBy") ?? "status"; + + // Create a new note as a child of the parent note + const { note: newNote } = await note_create.createNote(parentNotePath, { + activate: false, + title: "New item" + }); + + if (newNote) { + // Set the status label to place it in the correct column + await changeColumn(newNote.noteId, column, statusAttribute); + + // Start inline editing of the newly created card + //this.startInlineEditingCard(newNote.noteId); + } + } catch (error) { + console.error("Failed to create new item:", error); + } +} + +async function changeColumn(noteId: string, newColumn: string, statusAttribute: string) { + await attributes.setLabel(noteId, statusAttribute, newColumn); +} diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 4220cca8e7..d47a066bc1 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -5,6 +5,9 @@ import { ColumnMap, getBoardData } from "./data"; import { useNoteLabel } from "../../react/hooks"; import FNote from "../../../entities/fnote"; import FBranch from "../../../entities/fbranch"; +import Icon from "../../react/Icon"; +import { t } from "../../../services/i18n"; +import { createNewItem } from "./api"; export interface BoardViewData { columns?: BoardColumnData[]; @@ -40,14 +43,18 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC
{byColumn && columns?.map(column => ( - + ))}
) } -function Column({ column, columnItems }: { column: string, columnItems?: { note: FNote, branch: FBranch }[] }) { +function Column({ parentNote, column, columnItems }: { parentNote: FNote, column: string, columnItems?: { note: FNote, branch: FBranch }[] }) { return (

@@ -60,6 +67,11 @@ function Column({ column, columnItems }: { column: string, columnItems?: { note: {(columnItems ?? []).map(({ note, branch }) => ( ))} + +
createNewItem(parentNote, column)}> + {" "} + {t("board_view.new-item")} +

) } diff --git a/apps/client/src/widgets/view_widgets/board_view/api.ts b/apps/client/src/widgets/view_widgets/board_view/api.ts index df354ace4f..086a6a714e 100644 --- a/apps/client/src/widgets/view_widgets/board_view/api.ts +++ b/apps/client/src/widgets/view_widgets/board_view/api.ts @@ -29,10 +29,6 @@ export default class BoardApi { return this.byColumn.get(column); } - async changeColumn(noteId: string, newColumn: string) { - await attributes.setLabel(noteId, this._statusAttribute, newColumn); - } - openNote(noteId: string) { appContext.triggerCommand("openInPopup", { noteIdOrPath: noteId }); } diff --git a/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts b/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts index 474fa73172..ba2d0990e6 100644 --- a/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts +++ b/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts @@ -349,9 +349,8 @@ export class DifferentialBoardRenderer { const $newItemEl = $("
") .addClass("board-new-item") .attr("data-column", column) - .html(` ${t("board_view.new-item")}`); + .html(` ${}`); - $newItemEl.on("click", () => this.onCreateNewItem(column)); $columnEl.append($newItemEl); return $columnEl; diff --git a/apps/client/src/widgets/view_widgets/board_view/index.ts b/apps/client/src/widgets/view_widgets/board_view/index.ts index ec203068fc..489fd0b958 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -64,7 +64,6 @@ export default class BoardView extends ViewMode { this.$container, this.api, this.dragHandler, - (column: string) => this.createNewItem(column), this.parentNote, this.viewStorage, () => this.refreshApi() @@ -220,32 +219,6 @@ export default class BoardView extends ViewMode { } } - private async createNewItem(column: 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 } = await noteCreateService.createNote(parentNotePath, { - activate: false, - title: "New item" - }); - - if (newNote) { - // Set the status label to place it in the correct column - await this.api?.changeColumn(newNote.noteId, column); - - // Refresh the board to show the new item - await this.renderList(); - - // Start inline editing of the newly created card - this.startInlineEditingCard(newNote.noteId); - } - } catch (error) { - console.error("Failed to create new item:", error); - } - } - async insertItemAtPosition(column: string, relativeToBranchId: string, direction: "before" | "after"): Promise { try { // Create the note without opening it From 6f2d51f3ffc2a3f5a37c7e38ef5c2bf21477ca9d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 10 Sep 2025 21:41:15 +0300 Subject: [PATCH 127/233] chore(react/collections/board): attempt to reload events --- .../src/widgets/collections/board/data.ts | 1 + .../src/widgets/collections/board/index.tsx | 33 +++++++++++++++++-- .../widgets/view_widgets/board_view/index.ts | 25 -------------- 3 files changed, 31 insertions(+), 28 deletions(-) diff --git a/apps/client/src/widgets/collections/board/data.ts b/apps/client/src/widgets/collections/board/data.ts index 2a59e82b78..47cc101449 100644 --- a/apps/client/src/widgets/collections/board/data.ts +++ b/apps/client/src/widgets/collections/board/data.ts @@ -65,6 +65,7 @@ async function recursiveGroupBy(branches: FBranch[], byColumn: ColumnMap, groupB for (const branch of branches) { const note = await branch.getNote(); if (!note) { + console.warn("Not note found"); continue; } diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index d47a066bc1..97cdbf97a3 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "preact/hooks"; import { ViewModeProps } from "../interface"; import "./index.css"; import { ColumnMap, getBoardData } from "./data"; -import { useNoteLabel } from "../../react/hooks"; +import { useNoteLabel, useTriliumEvent } from "../../react/hooks"; import FNote from "../../../entities/fnote"; import FBranch from "../../../entities/fbranch"; import Icon from "../../react/Icon"; @@ -22,7 +22,7 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC const [ byColumn, setByColumn ] = useState(); const [ columns, setColumns ] = useState(); - useEffect(() => { + function refresh() { getBoardData(parentNote, statusAttribute ?? "status", viewConfig ?? {}).then(({ byColumn, newPersistedData }) => { setByColumn(byColumn); @@ -37,7 +37,34 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC const newColumns = allColumns.filter(col => !orderedColumns.includes(col)); setColumns([...orderedColumns, ...newColumns]); }); - }, [ parentNote ]); + } + + useEffect(refresh, [ parentNote, noteIds ]); + + useTriliumEvent("entitiesReloaded", ({ loadResults }) => { + // TODO: Re-enable + return; + + // 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) { + console.log("Trigger refresh"); + refresh(); + } + }); return (
diff --git a/apps/client/src/widgets/view_widgets/board_view/index.ts b/apps/client/src/widgets/view_widgets/board_view/index.ts index 489fd0b958..5b874a5f7e 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -318,31 +318,6 @@ export default class BoardView extends ViewMode { } } - async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) { - // 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 === this.api?.statusAttribute && this.noteIds.includes(attr.noteId!)) || - // React to changes in note title - loadResults.getNoteIds().some(noteId => this.noteIds.includes(noteId)) || - // React to changes in branches for subchildren (e.g., moved, added, or removed notes) - loadResults.getBranchRows().some(branch => this.noteIds.includes(branch.noteId!)) || - // React to changes in note icon or color. - loadResults.getAttributeRows().some(attr => [ "iconClass", "color" ].includes(attr.name ?? "") && this.noteIds.includes(attr.noteId ?? "")) || - // React to attachment change - loadResults.getAttachmentRows().some(att => att.ownerId === this.parentNote.noteId && att.title === "board.json") || - // React to changes in "groupBy" - loadResults.getAttributeRows().some(attr => attr.name === "board:groupBy" && attr.noteId === this.parentNote.noteId); - - if (hasRelevantChanges && this.renderer) { - // Use differential rendering with API refresh - await this.renderer.renderBoard(true); - } - - // Don't trigger full view refresh - let differential renderer handle it - return false; - } - private onSave() { this.viewStorage.store(this.persistentData); } From b029e0d79025c01d845fb25adfc0b5eebc0ed424 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 10 Sep 2025 22:20:17 +0300 Subject: [PATCH 128/233] chore(react/collections/board): add columns without refresh yet --- .../src/widgets/collections/board/index.css | 12 ++++ .../src/widgets/collections/board/index.tsx | 64 ++++++++++++++++++- .../widgets/view_widgets/board_view/api.ts | 15 ----- .../board_view/differential_renderer.ts | 12 ---- .../widgets/view_widgets/board_view/index.ts | 35 ---------- 5 files changed, 75 insertions(+), 63 deletions(-) diff --git a/apps/client/src/widgets/collections/board/index.css b/apps/client/src/widgets/collections/board/index.css index bc941b54ea..d5ed0ba5f0 100644 --- a/apps/client/src/widgets/collections/board/index.css +++ b/apps/client/src/widgets/collections/board/index.css @@ -247,6 +247,18 @@ 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; diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 97cdbf97a3..880321e822 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "preact/hooks"; +import { useCallback, useEffect, useRef, useState } from "preact/hooks"; import { ViewModeProps } from "../interface"; import "./index.css"; import { ColumnMap, getBoardData } from "./data"; @@ -8,6 +8,7 @@ import FBranch from "../../../entities/fbranch"; import Icon from "../../react/Icon"; import { t } from "../../../services/i18n"; import { createNewItem } from "./api"; +import FormTextBox from "../../react/FormTextBox"; export interface BoardViewData { columns?: BoardColumnData[]; @@ -76,6 +77,8 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC parentNote={parentNote} /> ))} + +
) @@ -113,3 +116,62 @@ function Card({ note }: { note: FNote, branch: FBranch, column: string }) {
) } + +function AddNewColumn({ viewConfig, saveConfig }: { viewConfig?: BoardViewData, saveConfig: (data: BoardViewData) => void }) { + const [ isCreatingNewColumn, setIsCreatingNewColumn ] = useState(false); + const columnNameRef = useRef(null); + + const addColumnCallback = useCallback(() => { + setIsCreatingNewColumn(true); + }, []); + + const finishEdit = useCallback((save: boolean) => { + const columnName = columnNameRef.current?.value; + if (!columnName || !save) { + setIsCreatingNewColumn(false); + return; + } + + // Add the new column to persisted data if it doesn't exist + if (!viewConfig) { + viewConfig = {}; + } + + if (!viewConfig.columns) { + viewConfig.columns = []; + } + + const existingColumn = viewConfig.columns.find(col => col.value === columnName); + if (!existingColumn) { + viewConfig.columns.push({ value: columnName }); + saveConfig(viewConfig); + } + }, []); + + return ( +
+ {!isCreatingNewColumn + ? <> + {" "} + {t("board_view.add-column")} + + : <> + finishEdit(true)} + onKeyDown={(e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + finishEdit(true); + } else if (e.key === "Escape") { + e.preventDefault(); + finishEdit(false); + } + }} + /> + } +
+ ) +} diff --git a/apps/client/src/widgets/view_widgets/board_view/api.ts b/apps/client/src/widgets/view_widgets/board_view/api.ts index 086a6a714e..2a6ed25bd0 100644 --- a/apps/client/src/widgets/view_widgets/board_view/api.ts +++ b/apps/client/src/widgets/view_widgets/board_view/api.ts @@ -91,21 +91,6 @@ export default class BoardApi { this.viewStorage.store(this.persistedData); } - async createColumn(columnValue: string) { - // Add the new column to persisted data if it doesn't exist - if (!this.persistedData.columns) { - this.persistedData.columns = []; - } - - const existingColumn = this.persistedData.columns.find(col => col.value === columnValue); - if (!existingColumn) { - this.persistedData.columns.push({ value: columnValue }); - await this.viewStorage.store(this.persistedData); - } - - return columnValue; - } - async reorderColumns(newColumnOrder: string[]) { // Update the column order in persisted data if (!this.persistedData.columns) { diff --git a/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts b/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts index ba2d0990e6..54658f6ee2 100644 --- a/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts +++ b/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts @@ -95,8 +95,6 @@ export class DifferentialBoardRenderer { const $columnEl = this.createColumn(column, columnItems); this.$container.append($columnEl); } - - this.addAddColumnButton(); } private async differentialRender(oldState: BoardState, newState: BoardState): Promise { @@ -366,16 +364,6 @@ export class DifferentialBoardRenderer { return $noteEl; } - private addAddColumnButton(): void { - if (this.$container.find('.board-add-column').length === 0) { - const $addColumnEl = $("
") - .addClass("board-add-column") - .html(` ${t("board_view.add-column")}`); - - this.$container.append($addColumnEl); - } - } - forceFullRender(): void { this.lastState = null; if (this.updateTimeout) { diff --git a/apps/client/src/widgets/view_widgets/board_view/index.ts b/apps/client/src/widgets/view_widgets/board_view/index.ts index 5b874a5f7e..83767445c0 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -246,27 +246,6 @@ export default class BoardView extends ViewMode { } private startCreatingNewColumn($addColumnEl: JQuery) { - if ($addColumnEl.hasClass("editing")) { - return; // Already editing - } - - $addColumnEl.addClass("editing"); - - const $input = $("") - .attr("type", "text") - .attr("placeholder", "Enter column name...") - .css({ - background: "var(--main-background-color)", - border: "1px solid var(--main-text-color)", - borderRadius: "4px", - padding: "0.5em", - color: "var(--main-text-color)", - fontFamily: "inherit", - fontSize: "inherit", - width: "100%", - textAlign: "center" - }); - $addColumnEl.empty().append($input); $input.focus(); @@ -283,21 +262,7 @@ export default class BoardView extends ViewMode { await this.createNewColumn(columnName.trim()); } } - - // Restore the add button - $addColumnEl.html('Add Column'); }; - - $input.on("blur", () => finishEdit(true)); - $input.on("keydown", (e) => { - if (e.key === "Enter") { - e.preventDefault(); - finishEdit(true); - } else if (e.key === "Escape") { - e.preventDefault(); - finishEdit(false); - } - }); } private async createNewColumn(columnName: string) { From 2e4791d3773ac71f87b21bcf52aa7f4d3e973958 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 11 Sep 2025 18:05:09 +0300 Subject: [PATCH 129/233] chore(react/collections/table): basic drag support to change columns --- .../src/widgets/collections/board/api.ts | 2 +- .../src/widgets/collections/board/index.tsx | 101 ++++++++++++++++-- 2 files changed, 96 insertions(+), 7 deletions(-) diff --git a/apps/client/src/widgets/collections/board/api.ts b/apps/client/src/widgets/collections/board/api.ts index 70246efccd..103ae3a0e1 100644 --- a/apps/client/src/widgets/collections/board/api.ts +++ b/apps/client/src/widgets/collections/board/api.ts @@ -26,6 +26,6 @@ export async function createNewItem(parentNote: FNote, column: string) { } } -async function changeColumn(noteId: string, newColumn: string, statusAttribute: string) { +export async function changeColumn(noteId: string, newColumn: string, statusAttribute: string) { await attributes.setLabel(noteId, statusAttribute, newColumn); } diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 880321e822..6eb9e17a98 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -7,7 +7,7 @@ import FNote from "../../../entities/fnote"; import FBranch from "../../../entities/fbranch"; import Icon from "../../react/Icon"; import { t } from "../../../services/i18n"; -import { createNewItem } from "./api"; +import { createNewItem, changeColumn } from "./api"; import FormTextBox from "../../react/FormTextBox"; export interface BoardViewData { @@ -22,6 +22,8 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC const [ statusAttribute ] = useNoteLabel(parentNote, "board:groupBy"); const [ byColumn, setByColumn ] = useState(); const [ columns, setColumns ] = useState(); + const [ draggedCard, setDraggedCard ] = useState<{ noteId: string, fromColumn: string } | null>(null); + const [ dropTarget, setDropTarget ] = useState(null); function refresh() { getBoardData(parentNote, statusAttribute ?? "status", viewConfig ?? {}).then(({ byColumn, newPersistedData }) => { @@ -75,6 +77,12 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC column={column} columnItems={byColumn.get(column)} parentNote={parentNote} + statusAttribute={statusAttribute ?? "status"} + draggedCard={draggedCard} + setDraggedCard={setDraggedCard} + dropTarget={dropTarget} + setDropTarget={setDropTarget} + onCardDrop={refresh} /> ))} @@ -84,9 +92,58 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC ) } -function Column({ parentNote, column, columnItems }: { parentNote: FNote, column: string, columnItems?: { note: FNote, branch: FBranch }[] }) { +function Column({ + parentNote, + column, + columnItems, + statusAttribute, + draggedCard, + setDraggedCard, + dropTarget, + setDropTarget, + onCardDrop +}: { + parentNote: FNote, + column: string, + columnItems?: { note: FNote, branch: FBranch }[], + statusAttribute: string, + draggedCard: { noteId: string, fromColumn: string } | null, + setDraggedCard: (card: { noteId: string, fromColumn: string } | null) => void, + dropTarget: string | null, + setDropTarget: (target: string | null) => void, + onCardDrop: () => void +}) { + const handleDragOver = useCallback((e: DragEvent) => { + e.preventDefault(); + setDropTarget(column); + }, [column, setDropTarget]); + + const handleDragLeave = useCallback((e: DragEvent) => { + const relatedTarget = e.relatedTarget as HTMLElement; + const currentTarget = e.currentTarget as HTMLElement; + + if (!currentTarget.contains(relatedTarget)) { + setDropTarget(null); + } + }, [setDropTarget]); + + const handleDrop = useCallback(async (e: DragEvent) => { + e.preventDefault(); + setDropTarget(null); + + if (draggedCard && draggedCard.fromColumn !== column) { + await changeColumn(draggedCard.noteId, column, statusAttribute); + onCardDrop(); + } + setDraggedCard(null); + }, [draggedCard, column, statusAttribute, setDraggedCard, setDropTarget, onCardDrop]); return ( -
+

{column} {(columnItems ?? []).map(({ note, branch }) => ( - + ))}
createNewItem(parentNote, column)}> @@ -106,11 +169,37 @@ function Column({ parentNote, column, columnItems }: { parentNote: FNote, column ) } -function Card({ note }: { note: FNote, branch: FBranch, column: string }) { +function Card({ + note, + column, + setDraggedCard, + isDragging +}: { + note: FNote, + branch: FBranch, + column: string, + setDraggedCard: (card: { noteId: string, fromColumn: string } | null) => void, + isDragging: boolean +}) { const colorClass = note.getColorClass() || ''; + const handleDragStart = useCallback((e: DragEvent) => { + e.dataTransfer!.effectAllowed = 'move'; + e.dataTransfer!.setData('text/plain', note.noteId); + setDraggedCard({ noteId: note.noteId, fromColumn: column }); + }, [note.noteId, column, setDraggedCard]); + + const handleDragEnd = useCallback(() => { + setDraggedCard(null); + }, [setDraggedCard]); + return ( -
+
{note.title}
From d9af0461efeb6d0678cd3aaf3f0904c638ed9dec Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 11 Sep 2025 18:11:12 +0300 Subject: [PATCH 130/233] chore(react/collections/table): add drop indicator --- .../src/widgets/collections/board/index.css | 19 ++-- .../src/widgets/collections/board/index.tsx | 103 ++++++++++++++---- 2 files changed, 91 insertions(+), 31 deletions(-) diff --git a/apps/client/src/widgets/collections/board/index.css b/apps/client/src/widgets/collections/board/index.css index d5ed0ba5f0..6a00dec2fd 100644 --- a/apps/client/src/widgets/collections/board/index.css +++ b/apps/client/src/widgets/collections/board/index.css @@ -106,7 +106,7 @@ position: relative; background-color: var(--main-background-color); border: 1px solid var(--main-border-color); - transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.15s ease; + transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.15s ease, margin-top 0.2s ease; opacity: 1; } @@ -144,12 +144,16 @@ } .board-view-container .board-note.dragging { - opacity: 0.8; + 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.shift-down { + margin-top: 45px; +} + .board-view-container .board-note.editing { box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.35); border-color: var(--main-text-color); @@ -171,16 +175,17 @@ } .board-drop-indicator { - height: 3px; - background-color: var(--main-text-color); + height: 2px; + background: linear-gradient(90deg, transparent, var(--main-text-color) 20%, var(--main-text-color) 80%, transparent); border-radius: 2px; - margin: 0.25em 0; + margin: -1px 0; opacity: 0; - transition: opacity 0.2s ease; + transition: opacity 0.15s ease; + position: relative; } .board-drop-indicator.show { - opacity: 1; + opacity: 0.8; } .column-drop-indicator { diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 6eb9e17a98..64364ed178 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -22,8 +22,9 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC const [ statusAttribute ] = useNoteLabel(parentNote, "board:groupBy"); const [ byColumn, setByColumn ] = useState(); const [ columns, setColumns ] = useState(); - const [ draggedCard, setDraggedCard ] = useState<{ noteId: string, fromColumn: string } | null>(null); + const [ draggedCard, setDraggedCard ] = useState<{ noteId: string, fromColumn: string, index: number } | null>(null); const [ dropTarget, setDropTarget ] = useState(null); + const [ dropPosition, setDropPosition ] = useState<{ column: string, index: number } | null>(null); function refresh() { getBoardData(parentNote, statusAttribute ?? "status", viewConfig ?? {}).then(({ byColumn, newPersistedData }) => { @@ -82,6 +83,8 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC setDraggedCard={setDraggedCard} dropTarget={dropTarget} setDropTarget={setDropTarget} + dropPosition={dropPosition} + setDropPosition={setDropPosition} onCardDrop={refresh} /> ))} @@ -101,22 +104,44 @@ function Column({ setDraggedCard, dropTarget, setDropTarget, + dropPosition, + setDropPosition, onCardDrop }: { parentNote: FNote, column: string, columnItems?: { note: FNote, branch: FBranch }[], statusAttribute: string, - draggedCard: { noteId: string, fromColumn: string } | null, - setDraggedCard: (card: { noteId: string, fromColumn: string } | null) => void, + draggedCard: { noteId: string, fromColumn: string, index: number } | null, + setDraggedCard: (card: { noteId: string, fromColumn: string, index: number } | null) => void, dropTarget: string | null, setDropTarget: (target: string | null) => void, + dropPosition: { column: string, index: number } | null, + setDropPosition: (position: { column: string, index: number } | null) => void, onCardDrop: () => void }) { const handleDragOver = useCallback((e: DragEvent) => { e.preventDefault(); setDropTarget(column); - }, [column, setDropTarget]); + + // Calculate drop position based on mouse position + const cards = Array.from(e.currentTarget.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; + } + } + + setDropPosition({ column, index: newIndex }); + }, [column, setDropTarget, setDropPosition]); const handleDragLeave = useCallback((e: DragEvent) => { const relatedTarget = e.relatedTarget as HTMLElement; @@ -124,19 +149,25 @@ function Column({ if (!currentTarget.contains(relatedTarget)) { setDropTarget(null); + setDropPosition(null); } - }, [setDropTarget]); + }, [setDropTarget, setDropPosition]); const handleDrop = useCallback(async (e: DragEvent) => { e.preventDefault(); setDropTarget(null); + setDropPosition(null); - if (draggedCard && draggedCard.fromColumn !== column) { - await changeColumn(draggedCard.noteId, column, statusAttribute); - onCardDrop(); + if (draggedCard) { + // For now, just handle column changes + // TODO: Add position/order handling + if (draggedCard.fromColumn !== column) { + await changeColumn(draggedCard.noteId, column, statusAttribute); + onCardDrop(); + } } setDraggedCard(null); - }, [draggedCard, column, statusAttribute, setDraggedCard, setDropTarget, onCardDrop]); + }, [draggedCard, column, statusAttribute, setDraggedCard, setDropTarget, setDropPosition, onCardDrop]); return (

- {(columnItems ?? []).map(({ note, branch }) => ( - - ))} + {(columnItems ?? []).map(({ note, branch }, index) => { + const showIndicatorBefore = dropPosition?.column === column && + dropPosition.index === index && + draggedCard?.noteId !== note.noteId; + const shouldShift = dropPosition?.column === column && + dropPosition.index <= index && + draggedCard?.noteId !== note.noteId && + draggedCard !== null; + + return ( + <> + {showIndicatorBefore && ( +
+ )} + + + ); + })} + {dropPosition?.column === column && dropPosition.index === (columnItems?.length ?? 0) && ( +
+ )}
createNewItem(parentNote, column)}> {" "} @@ -172,22 +223,26 @@ function Column({ function Card({ note, column, + index, setDraggedCard, - isDragging + isDragging, + shouldShift }: { note: FNote, branch: FBranch, column: string, - setDraggedCard: (card: { noteId: string, fromColumn: string } | null) => void, - isDragging: boolean + index: number, + setDraggedCard: (card: { noteId: string, fromColumn: string, index: number } | null) => void, + isDragging: boolean, + shouldShift?: boolean }) { const colorClass = note.getColorClass() || ''; const handleDragStart = useCallback((e: DragEvent) => { e.dataTransfer!.effectAllowed = 'move'; e.dataTransfer!.setData('text/plain', note.noteId); - setDraggedCard({ noteId: note.noteId, fromColumn: column }); - }, [note.noteId, column, setDraggedCard]); + setDraggedCard({ noteId: note.noteId, fromColumn: column, index }); + }, [note.noteId, column, index, setDraggedCard]); const handleDragEnd = useCallback(() => { setDraggedCard(null); @@ -195,7 +250,7 @@ function Card({ return (
Date: Thu, 11 Sep 2025 18:13:31 +0300 Subject: [PATCH 131/233] chore(react/collections/table): bring back refresh --- .../src/widgets/collections/board/index.css | 2 +- .../src/widgets/collections/board/index.tsx | 22 ++++++++----------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/apps/client/src/widgets/collections/board/index.css b/apps/client/src/widgets/collections/board/index.css index 6a00dec2fd..72edc61a2f 100644 --- a/apps/client/src/widgets/collections/board/index.css +++ b/apps/client/src/widgets/collections/board/index.css @@ -151,7 +151,7 @@ } .board-view-container .board-note.shift-down { - margin-top: 45px; + transform: translateY(100%); } .board-view-container .board-note.editing { diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 64364ed178..cf1bf981ce 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -46,9 +46,6 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC useEffect(refresh, [ parentNote, noteIds ]); useTriliumEvent("entitiesReloaded", ({ loadResults }) => { - // TODO: Re-enable - return; - // Check if any changes affect our board const hasRelevantChanges = // React to changes in status attribute for notes in this board @@ -65,7 +62,6 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC loadResults.getAttributeRows().some(attr => attr.name === "board:groupBy" && attr.noteId === parentNote.noteId); if (hasRelevantChanges) { - console.log("Trigger refresh"); refresh(); } }); @@ -123,23 +119,23 @@ function Column({ const handleDragOver = useCallback((e: DragEvent) => { e.preventDefault(); setDropTarget(column); - + // Calculate drop position based on mouse position const cards = Array.from(e.currentTarget.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; } } - + setDropPosition({ column, index: newIndex }); }, [column, setDropTarget, setDropPosition]); @@ -183,14 +179,14 @@ function Column({

{(columnItems ?? []).map(({ note, branch }, index) => { - const showIndicatorBefore = dropPosition?.column === column && - dropPosition.index === index && + const showIndicatorBefore = dropPosition?.column === column && + dropPosition.index === index && draggedCard?.noteId !== note.noteId; - const shouldShift = dropPosition?.column === column && - dropPosition.index <= index && + const shouldShift = dropPosition?.column === column && + dropPosition.index <= index && draggedCard?.noteId !== note.noteId && draggedCard !== null; - + return ( <> {showIndicatorBefore && ( From 728c20c184a4553589248a2f9f4946ecee4cd2b5 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 11 Sep 2025 18:27:42 +0300 Subject: [PATCH 132/233] chore(react/collections/table): bring back repositioning --- .../src/widgets/collections/board/index.tsx | 49 ++++++++++++++----- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index cf1bf981ce..ec9d18c7be 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -9,6 +9,7 @@ import Icon from "../../react/Icon"; import { t } from "../../../services/i18n"; import { createNewItem, changeColumn } from "./api"; import FormTextBox from "../../react/FormTextBox"; +import branchService from "../../../services/branches"; export interface BoardViewData { columns?: BoardColumnData[]; @@ -22,7 +23,7 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC const [ statusAttribute ] = useNoteLabel(parentNote, "board:groupBy"); const [ byColumn, setByColumn ] = useState(); const [ columns, setColumns ] = useState(); - const [ draggedCard, setDraggedCard ] = useState<{ noteId: string, fromColumn: string, index: number } | null>(null); + const [ draggedCard, setDraggedCard ] = useState<{ noteId: string, branchId: string, fromColumn: string, index: number } | null>(null); const [ dropTarget, setDropTarget ] = useState(null); const [ dropPosition, setDropPosition ] = useState<{ column: string, index: number } | null>(null); @@ -108,8 +109,8 @@ function Column({ column: string, columnItems?: { note: FNote, branch: FBranch }[], statusAttribute: string, - draggedCard: { noteId: string, fromColumn: string, index: number } | null, - setDraggedCard: (card: { noteId: string, fromColumn: string, index: number } | null) => void, + draggedCard: { noteId: string, branchId: string, fromColumn: string, index: number } | null, + setDraggedCard: (card: { noteId: string, branchId: string, fromColumn: string, index: number } | null) => void, dropTarget: string | null, setDropTarget: (target: string | null) => void, dropPosition: { column: string, index: number } | null, @@ -154,16 +155,41 @@ function Column({ setDropTarget(null); setDropPosition(null); - if (draggedCard) { - // For now, just handle column changes - // TODO: Add position/order handling + if (draggedCard && dropPosition) { + const targetIndex = dropPosition.index; + const targetItems = columnItems || []; + if (draggedCard.fromColumn !== column) { + // Moving to a different column await changeColumn(draggedCard.noteId, column, statusAttribute); - onCardDrop(); + + // If there are items in the target column, reorder + if (targetItems.length > 0 && targetIndex < targetItems.length) { + const targetBranch = targetItems[targetIndex].branch; + await branchService.moveBeforeBranch([ draggedCard.branchId ], targetBranch.branchId); + } + } else if (draggedCard.index !== targetIndex) { + // Reordering within the same column + let targetBranchId: string | null = null; + + if (targetIndex < targetItems.length) { + // Moving before an existing item + const adjustedIndex = draggedCard.index < targetIndex ? targetIndex : targetIndex; + if (adjustedIndex < targetItems.length) { + targetBranchId = targetItems[adjustedIndex].branch.branchId; + await branchService.moveBeforeBranch([ draggedCard.branchId ], targetBranchId); + } + } else if (targetIndex > 0) { + // Moving to the end - place after the last item + const lastItem = targetItems[targetItems.length - 1]; + await branchService.moveAfterBranch([ draggedCard.branchId ], lastItem.branch.branchId); + } } + + onCardDrop(); } setDraggedCard(null); - }, [draggedCard, column, statusAttribute, setDraggedCard, setDropTarget, setDropPosition, onCardDrop]); + }, [draggedCard, dropPosition, columnItems, column, statusAttribute, setDraggedCard, setDropTarget, setDropPosition, onCardDrop]); return (
void, + setDraggedCard: (card: { noteId: string, branchId: string, fromColumn: string, index: number } | null) => void, isDragging: boolean, shouldShift?: boolean }) { @@ -237,8 +264,8 @@ function Card({ const handleDragStart = useCallback((e: DragEvent) => { e.dataTransfer!.effectAllowed = 'move'; e.dataTransfer!.setData('text/plain', note.noteId); - setDraggedCard({ noteId: note.noteId, fromColumn: column, index }); - }, [note.noteId, column, index, setDraggedCard]); + setDraggedCard({ noteId: note.noteId, branchId: branch.branchId, fromColumn: column, index }); + }, [note.noteId, branch.branchId, column, index, setDraggedCard]); const handleDragEnd = useCallback(() => { setDraggedCard(null); From ce0da3fb80fde9a0972b959e3e4647b3239a72c9 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 11 Sep 2025 18:32:06 +0300 Subject: [PATCH 133/233] chore(react/collections/table): use a placeholder for items --- .../src/widgets/collections/board/index.css | 23 ++++++++----------- .../src/widgets/collections/board/index.tsx | 17 ++++---------- 2 files changed, 15 insertions(+), 25 deletions(-) diff --git a/apps/client/src/widgets/collections/board/index.css b/apps/client/src/widgets/collections/board/index.css index 72edc61a2f..9e672e22dc 100644 --- a/apps/client/src/widgets/collections/board/index.css +++ b/apps/client/src/widgets/collections/board/index.css @@ -150,10 +150,6 @@ box-shadow: 4px 8px 16px rgba(0, 0, 0, 0.5); } -.board-view-container .board-note.shift-down { - transform: translateY(100%); -} - .board-view-container .board-note.editing { box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.35); border-color: var(--main-text-color); @@ -174,18 +170,19 @@ margin-right: 0.25em; } -.board-drop-indicator { - height: 2px; - background: linear-gradient(90deg, transparent, var(--main-text-color) 20%, var(--main-text-color) 80%, transparent); - border-radius: 2px; - margin: -1px 0; +.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; - position: relative; + transition: opacity 0.15s ease, height 0.2s ease; + box-sizing: border-box; } -.board-drop-indicator.show { - opacity: 0.8; +.board-drop-placeholder.show { + opacity: 0.6; } .column-drop-indicator { diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index ec9d18c7be..1fa651f946 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -208,15 +208,11 @@ function Column({ const showIndicatorBefore = dropPosition?.column === column && dropPosition.index === index && draggedCard?.noteId !== note.noteId; - const shouldShift = dropPosition?.column === column && - dropPosition.index <= index && - draggedCard?.noteId !== note.noteId && - draggedCard !== null; return ( <> {showIndicatorBefore && ( -
+
)} ); })} {dropPosition?.column === column && dropPosition.index === (columnItems?.length ?? 0) && ( -
+
)}
createNewItem(parentNote, column)}> @@ -248,16 +243,14 @@ function Card({ column, index, setDraggedCard, - isDragging, - shouldShift + isDragging }: { note: FNote, branch: FBranch, column: string, index: number, setDraggedCard: (card: { noteId: string, branchId: string, fromColumn: string, index: number } | null) => void, - isDragging: boolean, - shouldShift?: boolean + isDragging: boolean }) { const colorClass = note.getColorClass() || ''; @@ -273,7 +266,7 @@ function Card({ return (
Date: Thu, 11 Sep 2025 18:42:32 +0300 Subject: [PATCH 134/233] chore(react/collections/table): set up column dragging --- .../src/widgets/collections/NoteList.tsx | 5 +- .../src/widgets/collections/board/index.css | 26 ++-- .../src/widgets/collections/board/index.tsx | 134 +++++++++++++++--- 3 files changed, 131 insertions(+), 34 deletions(-) diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index 8e462e44b2..fe62d02543 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -144,7 +144,10 @@ function useViewModeConfig(note: FNote | null | undefined, vie if (!note || !viewType) return; const viewStorage = new ViewModeStorage(note, viewType); viewStorage.restore().then(config => { - const storeFn = (config: T) => viewStorage.store(config); + const storeFn = (config: T) => { + setViewConfig([ config, storeFn ]); + viewStorage.store(config); + }; setViewConfig([ config, storeFn ]); }); }, [ note, viewType ]); diff --git a/apps/client/src/widgets/collections/board/index.css b/apps/client/src/widgets/collections/board/index.css index 9e672e22dc..dc1d2f10db 100644 --- a/apps/client/src/widgets/collections/board/index.css +++ b/apps/client/src/widgets/collections/board/index.css @@ -66,9 +66,10 @@ } .board-view-container .board-column.column-dragging { - opacity: 0.6; - transform: scale(0.98); + opacity: 0.5; + transform: scale(0.98) rotate(2deg); transition: opacity 0.2s ease, transform 0.2s ease; + box-shadow: 4px 8px 16px rgba(0, 0, 0, 0.3); } .board-view-container .board-column h3 input { @@ -185,20 +186,19 @@ opacity: 0.6; } -.column-drop-indicator { - width: 4px; - background-color: var(--main-text-color); - border-radius: 2px; - opacity: 0; - transition: opacity 0.2s ease; - height: 100%; - z-index: 1000; - box-shadow: 0 0 8px rgba(0, 0, 0, 0.3); +.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-indicator.show { - opacity: 1; +.column-drop-placeholder.show { + opacity: 0.6; } .board-new-item { diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 1fa651f946..1fee7e5c6d 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -26,6 +26,8 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC const [ draggedCard, setDraggedCard ] = useState<{ noteId: string, branchId: string, fromColumn: string, index: number } | null>(null); const [ dropTarget, setDropTarget ] = useState(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(null); function refresh() { getBoardData(parentNote, statusAttribute ?? "status", viewConfig ?? {}).then(({ byColumn, newPersistedData }) => { @@ -46,6 +48,26 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC useEffect(refresh, [ parentNote, noteIds ]); + const handleColumnDrop = useCallback((fromIndex: number, toIndex: number) => { + if (!columns || fromIndex === toIndex) return; + + const newColumns = [...columns]; + const [movedColumn] = newColumns.splice(fromIndex, 1); + newColumns.splice(toIndex, 0, movedColumn); + + // Update view config with new column order + const newViewConfig = { + ...viewConfig, + columns: newColumns.map(col => ({ value: col })) + }; + + saveConfig(newViewConfig); + setColumns(newColumns); + console.log("New columns are ", newColumns); + setDraggedColumn(null); + setColumnDropPosition(null); + }, [columns, viewConfig, saveConfig]); + useTriliumEvent("entitiesReloaded", ({ loadResults }) => { // Check if any changes affect our board const hasRelevantChanges = @@ -67,24 +89,70 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC } }); + const handleColumnDragOver = useCallback((e: DragEvent) => { + if (!draggedColumn) return; + e.preventDefault(); + + const container = e.currentTarget as HTMLElement; + const columns = Array.from(container.querySelectorAll('.board-column')); + const mouseX = e.clientX; + + let newIndex = columns.length; + for (let i = 0; i < columns.length; i++) { + const col = columns[i] as HTMLElement; + const rect = col.getBoundingClientRect(); + const colMiddle = rect.left + rect.width / 2; + + if (mouseX < colMiddle) { + newIndex = i; + break; + } + } + + setColumnDropPosition(newIndex); + }, [draggedColumn]); + + const handleContainerDrop = useCallback((e: DragEvent) => { + e.preventDefault(); + if (draggedColumn && columnDropPosition !== null) { + handleColumnDrop(draggedColumn.index, columnDropPosition); + } + }, [draggedColumn, columnDropPosition, handleColumnDrop]); + return (
-
- {byColumn && columns?.map(column => ( - +
+ {byColumn && columns?.map((column, index) => ( + <> + {columnDropPosition === index && draggedColumn?.column !== column && ( +
+ )} + + ))} + {columnDropPosition === columns?.length && draggedColumn && ( +
+ )}
@@ -95,6 +163,7 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC function Column({ parentNote, column, + columnIndex, columnItems, statusAttribute, draggedCard, @@ -103,10 +172,14 @@ function Column({ setDropTarget, dropPosition, setDropPosition, - onCardDrop + onCardDrop, + draggedColumn, + setDraggedColumn, + isDraggingColumn }: { parentNote: FNote, column: string, + columnIndex: number, columnItems?: { note: FNote, branch: FBranch }[], statusAttribute: string, draggedCard: { noteId: string, branchId: string, fromColumn: string, index: number } | null, @@ -115,9 +188,24 @@ function Column({ setDropTarget: (target: string | null) => void, dropPosition: { column: string, index: number } | null, setDropPosition: (position: { column: string, index: number } | null) => void, - onCardDrop: () => void + onCardDrop: () => void, + draggedColumn: { column: string, index: number } | null, + setDraggedColumn: (column: { column: string, index: number } | null) => void, + isDraggingColumn: boolean }) { + const handleColumnDragStart = useCallback((e: DragEvent) => { + e.dataTransfer!.effectAllowed = 'move'; + e.dataTransfer!.setData('text/plain', column); + setDraggedColumn({ column, index: columnIndex }); + e.stopPropagation(); // Prevent card drag from interfering + }, [column, columnIndex, setDraggedColumn]); + + const handleColumnDragEnd = useCallback(() => { + setDraggedColumn(null); + }, [setDraggedColumn]); + const handleDragOver = useCallback((e: DragEvent) => { + if (draggedColumn) return; // Don't handle card drops when dragging columns e.preventDefault(); setDropTarget(column); @@ -151,6 +239,7 @@ function Column({ }, [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); @@ -189,15 +278,20 @@ function Column({ onCardDrop(); } setDraggedCard(null); - }, [draggedCard, dropPosition, columnItems, column, statusAttribute, setDraggedCard, setDropTarget, setDropPosition, onCardDrop]); + }, [draggedCard, draggedColumn, dropPosition, columnItems, column, statusAttribute, setDraggedCard, setDropTarget, setDropPosition, onCardDrop]); + return (
-

+

{column} Date: Thu, 11 Sep 2025 19:03:25 +0300 Subject: [PATCH 135/233] chore(react/collections/table): fix adding new columns --- apps/client/src/widgets/collections/board/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 1fee7e5c6d..ae122c65e6 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -400,7 +400,9 @@ function AddNewColumn({ viewConfig, saveConfig }: { viewConfig?: BoardViewData, viewConfig.columns.push({ value: columnName }); saveConfig(viewConfig); } - }, []); + + setIsCreatingNewColumn(false); + }, [ viewConfig, saveConfig ]); return (
From 2b452a18dffb06e81dfc188fd130533f98f68f52 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 11 Sep 2025 19:14:54 +0300 Subject: [PATCH 136/233] refactor(react/collections/table): use class-based API --- .../src/widgets/collections/board/api.ts | 49 +++++++++++-------- .../src/widgets/collections/board/index.tsx | 27 +++++----- apps/client/src/widgets/react/hooks.tsx | 5 ++ 3 files changed, 49 insertions(+), 32 deletions(-) diff --git a/apps/client/src/widgets/collections/board/api.ts b/apps/client/src/widgets/collections/board/api.ts index 103ae3a0e1..600c5ebd1b 100644 --- a/apps/client/src/widgets/collections/board/api.ts +++ b/apps/client/src/widgets/collections/board/api.ts @@ -2,30 +2,39 @@ import FNote from "../../../entities/fnote"; import attributes from "../../../services/attributes"; import note_create from "../../../services/note_create"; -export async function createNewItem(parentNote: FNote, column: string) { - try { - // Get the parent note path - const parentNotePath = parentNote.noteId; - const statusAttribute = parentNote.getLabelValue("board:groupBy") ?? "status"; +export default class BoardApi { - // Create a new note as a child of the parent note - const { note: newNote } = await note_create.createNote(parentNotePath, { - activate: false, - title: "New item" - }); + constructor( + private parentNote: FNote, + private statusAttribute: string + ) {}; - if (newNote) { - // Set the status label to place it in the correct column - await changeColumn(newNote.noteId, column, statusAttribute); + async createNewItem(column: string) { + try { + // Get the parent note path + const parentNotePath = this.parentNote.noteId; - // Start inline editing of the newly created card - //this.startInlineEditingCard(newNote.noteId); + // Create a new note as a child of the parent note + const { note: newNote } = await note_create.createNote(parentNotePath, { + activate: false, + title: "New item" + }); + + if (newNote) { + // Set the status label to place it in the correct column + await this.changeColumn(newNote.noteId, column); + + // Start inline editing of the newly created card + //this.startInlineEditingCard(newNote.noteId); + } + } catch (error) { + console.error("Failed to create new item:", error); } - } catch (error) { - console.error("Failed to create new item:", error); } + + async changeColumn(noteId: string, newColumn: string) { + await attributes.setLabel(noteId, this.statusAttribute, newColumn); + } + } -export async function changeColumn(noteId: string, newColumn: string, statusAttribute: string) { - await attributes.setLabel(noteId, statusAttribute, newColumn); -} diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index ae122c65e6..b5f4ea603b 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -1,13 +1,13 @@ -import { useCallback, useEffect, useRef, useState } from "preact/hooks"; +import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; import { ViewModeProps } from "../interface"; import "./index.css"; import { ColumnMap, getBoardData } from "./data"; -import { useNoteLabel, useTriliumEvent } from "../../react/hooks"; +import { useNoteLabelWithDefault, useTriliumEvent } from "../../react/hooks"; import FNote from "../../../entities/fnote"; import FBranch from "../../../entities/fbranch"; import Icon from "../../react/Icon"; import { t } from "../../../services/i18n"; -import { createNewItem, changeColumn } from "./api"; +import Api from "./api"; import FormTextBox from "../../react/FormTextBox"; import branchService from "../../../services/branches"; @@ -20,7 +20,7 @@ export interface BoardColumnData { } export default function BoardView({ note: parentNote, noteIds, viewConfig, saveConfig }: ViewModeProps) { - const [ statusAttribute ] = useNoteLabel(parentNote, "board:groupBy"); + const [ statusAttribute ] = useNoteLabelWithDefault(parentNote, "board:groupBy", "status"); const [ byColumn, setByColumn ] = useState(); const [ columns, setColumns ] = useState(); const [ draggedCard, setDraggedCard ] = useState<{ noteId: string, branchId: string, fromColumn: string, index: number } | null>(null); @@ -28,9 +28,12 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC const [ dropPosition, setDropPosition ] = useState<{ column: string, index: number } | null>(null); const [ draggedColumn, setDraggedColumn ] = useState<{ column: string, index: number } | null>(null); const [ columnDropPosition, setColumnDropPosition ] = useState(null); + const api = useMemo(() => { + return new Api(parentNote, statusAttribute); + }, [ parentNote, statusAttribute ]); function refresh() { - getBoardData(parentNote, statusAttribute ?? "status", viewConfig ?? {}).then(({ byColumn, newPersistedData }) => { + getBoardData(parentNote, statusAttribute, viewConfig ?? {}).then(({ byColumn, newPersistedData }) => { setByColumn(byColumn); if (newPersistedData) { @@ -132,10 +135,10 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC
)} void, draggedColumn: { column: string, index: number } | null, setDraggedColumn: (column: { column: string, index: number } | null) => void, - isDraggingColumn: boolean + isDraggingColumn: boolean, + api: Api }) { const handleColumnDragStart = useCallback((e: DragEvent) => { e.dataTransfer!.effectAllowed = 'move'; @@ -250,7 +253,7 @@ function Column({ if (draggedCard.fromColumn !== column) { // Moving to a different column - await changeColumn(draggedCard.noteId, column, statusAttribute); + await api.changeColumn(draggedCard.noteId, column); // If there are items in the target column, reorder if (targetItems.length > 0 && targetIndex < targetItems.length) { @@ -323,7 +326,7 @@ function Column({
)} -
createNewItem(parentNote, column)}> +
api.createNewItem(column)}> {" "} {t("board_view.new-item")}
diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 4e72003286..13eff8c467 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -324,6 +324,11 @@ export function useNoteLabel(note: FNote | undefined | null, labelName: string): ] as const; } +export function useNoteLabelWithDefault(note: FNote | undefined | null, labelName: 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: string): [ boolean, (newValue: boolean) => void] { const [ labelValue, setLabelValue ] = useState(!!note?.hasLabel(labelName)); From 803164791f1232c999023493df29375370f1261f Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 11 Sep 2025 19:25:17 +0300 Subject: [PATCH 137/233] chore(react/collections/table): reintroduce column context menu --- .../src/widgets/collections/board/api.ts | 22 +++++++++++++- .../widgets/collections/board/context_menu.ts | 29 +++++++++++++++++++ .../src/widgets/collections/board/index.tsx | 9 +++++- .../widgets/view_widgets/board_view/api.ts | 12 -------- .../view_widgets/board_view/context_menu.ts | 25 ---------------- 5 files changed, 58 insertions(+), 39 deletions(-) create mode 100644 apps/client/src/widgets/collections/board/context_menu.ts diff --git a/apps/client/src/widgets/collections/board/api.ts b/apps/client/src/widgets/collections/board/api.ts index 600c5ebd1b..7088198de2 100644 --- a/apps/client/src/widgets/collections/board/api.ts +++ b/apps/client/src/widgets/collections/board/api.ts @@ -1,12 +1,18 @@ +import { BoardViewData } from "."; import FNote from "../../../entities/fnote"; import attributes from "../../../services/attributes"; +import { executeBulkActions } from "../../../services/bulk_action"; import note_create from "../../../services/note_create"; +import { ColumnMap } from "./data"; export default class BoardApi { constructor( + private byColumn: ColumnMap | undefined, private parentNote: FNote, - private statusAttribute: string + private statusAttribute: string, + private viewConfig: BoardViewData, + private saveConfig: (newConfig: BoardViewData) => void ) {}; async createNewItem(column: string) { @@ -36,5 +42,19 @@ export default class BoardApi { await attributes.setLabel(noteId, this.statusAttribute, newColumn); } + 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); + } + } diff --git a/apps/client/src/widgets/collections/board/context_menu.ts b/apps/client/src/widgets/collections/board/context_menu.ts new file mode 100644 index 0000000000..cdbe6b5e9e --- /dev/null +++ b/apps/client/src/widgets/collections/board/context_menu.ts @@ -0,0 +1,29 @@ +import contextMenu, { ContextMenuEvent } from "../../../menus/context_menu"; +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() {} + }); +} diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index b5f4ea603b..209f2b1061 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -10,6 +10,8 @@ import { t } from "../../../services/i18n"; import Api from "./api"; import FormTextBox from "../../react/FormTextBox"; import branchService from "../../../services/branches"; +import { openColumnContextMenu } from "./context_menu"; +import { ContextMenuEvent } from "../../../menus/context_menu"; export interface BoardViewData { columns?: BoardColumnData[]; @@ -29,7 +31,7 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC const [ draggedColumn, setDraggedColumn ] = useState<{ column: string, index: number } | null>(null); const [ columnDropPosition, setColumnDropPosition ] = useState(null); const api = useMemo(() => { - return new Api(parentNote, statusAttribute); + return new Api(byColumn, parentNote, statusAttribute, viewConfig ?? {}, saveConfig); }, [ parentNote, statusAttribute ]); function refresh() { @@ -283,12 +285,17 @@ function Column({ setDraggedCard(null); }, [draggedCard, draggedColumn, dropPosition, columnItems, column, statusAttribute, setDraggedCard, setDropTarget, setDropPosition, onCardDrop]); + const handleContextMenu = useCallback((e: ContextMenuEvent) => { + openColumnContextMenu(api, e, column); + }, [ api, column ]); + return (

item.note.noteId) || []; - await executeBulkActions(noteIds, [ - { - name: "deleteLabel", - labelName: this._statusAttribute - } - ]); - this.persistedData.columns = (this.persistedData.columns ?? []).filter(col => col.value !== column); - this.viewStorage.store(this.persistedData); - } async reorderColumns(newColumnOrder: string[]) { // Update the column order in persisted data diff --git a/apps/client/src/widgets/view_widgets/board_view/context_menu.ts b/apps/client/src/widgets/view_widgets/board_view/context_menu.ts index 62cf43e65a..378c508a28 100644 --- a/apps/client/src/widgets/view_widgets/board_view/context_menu.ts +++ b/apps/client/src/widgets/view_widgets/board_view/context_menu.ts @@ -16,32 +16,7 @@ export function setupContextMenu({ $container, api, boardView }: ShowNoteContext $container.on("contextmenu", ".board-note", showNoteContextMenu); $container.on("contextmenu", ".board-column h3", showColumnContextMenu); - function showColumnContextMenu(event: ContextMenuEvent) { - event.preventDefault(); - event.stopPropagation(); - const $el = $(event.currentTarget); - const column = $el.closest(".board-column").data("column"); - - 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() {} - }); } function showNoteContextMenu(event: ContextMenuEvent) { From 3d2a4d8c38f75bbeba0c65cc9ff083248574ee42 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 11 Sep 2025 19:35:46 +0300 Subject: [PATCH 138/233] chore(react/collections/table): reintroduce item context menu partially --- .../src/widgets/collections/board/api.ts | 1 + .../widgets/collections/board/context_menu.ts | 43 ++++++++++++ .../src/widgets/collections/board/index.tsx | 14 +++- .../view_widgets/board_view/context_menu.ts | 68 ------------------- 4 files changed, 55 insertions(+), 71 deletions(-) delete mode 100644 apps/client/src/widgets/view_widgets/board_view/context_menu.ts diff --git a/apps/client/src/widgets/collections/board/api.ts b/apps/client/src/widgets/collections/board/api.ts index 7088198de2..e937f1735c 100644 --- a/apps/client/src/widgets/collections/board/api.ts +++ b/apps/client/src/widgets/collections/board/api.ts @@ -9,6 +9,7 @@ export default class BoardApi { constructor( private byColumn: ColumnMap | undefined, + public columns: string[], private parentNote: FNote, private statusAttribute: string, private viewConfig: BoardViewData, diff --git a/apps/client/src/widgets/collections/board/context_menu.ts b/apps/client/src/widgets/collections/board/context_menu.ts index cdbe6b5e9e..7b90d7000c 100644 --- a/apps/client/src/widgets/collections/board/context_menu.ts +++ b/apps/client/src/widgets/collections/board/context_menu.ts @@ -1,4 +1,6 @@ import contextMenu, { ContextMenuEvent } from "../../../menus/context_menu"; +import link_context_menu from "../../../menus/link_context_menu"; +import branches from "../../../services/branches"; import dialog from "../../../services/dialog"; import { t } from "../../../services/i18n"; import Api from "./api"; @@ -27,3 +29,44 @@ export function openColumnContextMenu(api: Api, event: ContextMenuEvent, column: selectMenuItemHandler() {} }); } + +export function openNoteContextMenu(api: Api, event: ContextMenuEvent, noteId: string, 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(noteId, columnToMoveTo) + })) + }, + { title: "----" }, + { + title: t("board_view.insert-above"), + uiIcon: "bx bx-list-plus", + // handler: () => boardView.insertItemAtPosition(column, branchId, "before") + }, + { + title: t("board_view.insert-below"), + uiIcon: "bx bx-empty", + // handler: () => boardView.insertItemAtPosition(column, branchId, "after") + }, + { title: "----" }, + { + title: t("board_view.delete-note"), + uiIcon: "bx bx-trash", + handler: () => branches.deleteNotes([ branchId ], false, false) + } + ], + selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, noteId), + }); +} diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 209f2b1061..a7ba46fbc1 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -10,7 +10,7 @@ import { t } from "../../../services/i18n"; import Api from "./api"; import FormTextBox from "../../react/FormTextBox"; import branchService from "../../../services/branches"; -import { openColumnContextMenu } from "./context_menu"; +import { openColumnContextMenu, openNoteContextMenu } from "./context_menu"; import { ContextMenuEvent } from "../../../menus/context_menu"; export interface BoardViewData { @@ -31,8 +31,8 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC const [ draggedColumn, setDraggedColumn ] = useState<{ column: string, index: number } | null>(null); const [ columnDropPosition, setColumnDropPosition ] = useState(null); const api = useMemo(() => { - return new Api(byColumn, parentNote, statusAttribute, viewConfig ?? {}, saveConfig); - }, [ parentNote, statusAttribute ]); + return new Api(byColumn, columns ?? [], parentNote, statusAttribute, viewConfig ?? {}, saveConfig); + }, [ byColumn, columns, parentNote, statusAttribute, viewConfig, saveConfig ]); function refresh() { getBoardData(parentNote, statusAttribute, viewConfig ?? {}).then(({ byColumn, newPersistedData }) => { @@ -319,6 +319,7 @@ function Column({
)} { + openNoteContextMenu(api, e, note.noteId, branch.branchId, column); + }, [ api, note, branch, column ]); + return (
{note.title} diff --git a/apps/client/src/widgets/view_widgets/board_view/context_menu.ts b/apps/client/src/widgets/view_widgets/board_view/context_menu.ts deleted file mode 100644 index 378c508a28..0000000000 --- a/apps/client/src/widgets/view_widgets/board_view/context_menu.ts +++ /dev/null @@ -1,68 +0,0 @@ -import contextMenu, { ContextMenuEvent } from "../../../menus/context_menu.js"; -import link_context_menu from "../../../menus/link_context_menu.js"; -import branches from "../../../services/branches.js"; -import dialog from "../../../services/dialog.js"; -import { t } from "../../../services/i18n.js"; -import BoardApi from "./api.js"; -import type BoardView from "./index.js"; - -interface ShowNoteContextMenuArgs { - $container: JQuery; - api: BoardApi; - boardView: BoardView; -} - -export function setupContextMenu({ $container, api, boardView }: ShowNoteContextMenuArgs) { - $container.on("contextmenu", ".board-note", showNoteContextMenu); - $container.on("contextmenu", ".board-column h3", showColumnContextMenu); - - - } - - function showNoteContextMenu(event: ContextMenuEvent) { - event.preventDefault(); - event.stopPropagation(); - - const $el = $(event.currentTarget); - const noteId = $el.data("note-id"); - const branchId = $el.data("branch-id"); - const column = $el.closest(".board-column").data("column"); - if (!noteId) return; - - 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(noteId, columnToMoveTo) - })) - }, - { title: "----" }, - { - title: t("board_view.insert-above"), - uiIcon: "bx bx-list-plus", - handler: () => boardView.insertItemAtPosition(column, branchId, "before") - }, - { - title: t("board_view.insert-below"), - uiIcon: "bx bx-empty", - handler: () => boardView.insertItemAtPosition(column, branchId, "after") - }, - { title: "----" }, - { - title: t("board_view.delete-note"), - uiIcon: "bx bx-trash", - handler: () => branches.deleteNotes([ branchId ], false, false) - } - ], - selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, noteId), - }); - } -} From 1ce42d13016a8d4a2d3a688e1d9a022c67781760 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 11 Sep 2025 20:02:58 +0300 Subject: [PATCH 139/233] chore(react/collections/table): reintroduce editing of newly added item --- .../src/widgets/collections/board/api.ts | 19 ++- .../src/widgets/collections/board/index.tsx | 110 +++++++++++------- .../board_view/differential_renderer.ts | 3 +- 3 files changed, 85 insertions(+), 47 deletions(-) diff --git a/apps/client/src/widgets/collections/board/api.ts b/apps/client/src/widgets/collections/board/api.ts index e937f1735c..a3b67ab0a2 100644 --- a/apps/client/src/widgets/collections/board/api.ts +++ b/apps/client/src/widgets/collections/board/api.ts @@ -3,6 +3,7 @@ import FNote from "../../../entities/fnote"; import attributes from "../../../services/attributes"; import { executeBulkActions } from "../../../services/bulk_action"; import note_create from "../../../services/note_create"; +import server from "../../../services/server"; import { ColumnMap } from "./data"; export default class BoardApi { @@ -13,7 +14,8 @@ export default class BoardApi { private parentNote: FNote, private statusAttribute: string, private viewConfig: BoardViewData, - private saveConfig: (newConfig: BoardViewData) => void + private saveConfig: (newConfig: BoardViewData) => void, + private setBranchIdToEdit: (branchId: string | undefined) => void ) {}; async createNewItem(column: string) { @@ -22,17 +24,14 @@ export default class BoardApi { const parentNotePath = this.parentNote.noteId; // Create a new note as a child of the parent note - const { note: newNote } = await note_create.createNote(parentNotePath, { + const { note: newNote, branch: newBranch } = await note_create.createNote(parentNotePath, { activate: false, title: "New item" }); if (newNote) { - // Set the status label to place it in the correct column await this.changeColumn(newNote.noteId, column); - - // Start inline editing of the newly created card - //this.startInlineEditingCard(newNote.noteId); + this.setBranchIdToEdit(newBranch?.branchId); } } catch (error) { console.error("Failed to create new item:", error); @@ -57,5 +56,13 @@ export default class BoardApi { this.saveConfig(this.viewConfig); } + dismissEditingTitle() { + this.setBranchIdToEdit(undefined); + } + + renameCard(noteId: string, newTitle: string) { + return server.put(`notes/${noteId}/title`, { title: newTitle.trim() }); + } + } diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index a7ba46fbc1..b9a59a2590 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks"; import { ViewModeProps } from "../interface"; import "./index.css"; import { ColumnMap, getBoardData } from "./data"; @@ -12,6 +12,7 @@ import FormTextBox from "../../react/FormTextBox"; import branchService from "../../../services/branches"; import { openColumnContextMenu, openNoteContextMenu } from "./context_menu"; import { ContextMenuEvent } from "../../../menus/context_menu"; +import { createContext } from "preact"; export interface BoardViewData { columns?: BoardColumnData[]; @@ -21,6 +22,12 @@ export interface BoardColumnData { value: string; } +interface BoardViewContextData { + branchIdToEdit?: string; +} + +const BoardViewContext = createContext({}); + export default function BoardView({ note: parentNote, noteIds, viewConfig, saveConfig }: ViewModeProps) { const [ statusAttribute ] = useNoteLabelWithDefault(parentNote, "board:groupBy", "status"); const [ byColumn, setByColumn ] = useState(); @@ -30,9 +37,13 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC const [ dropPosition, setDropPosition ] = useState<{ column: string, index: number } | null>(null); const [ draggedColumn, setDraggedColumn ] = useState<{ column: string, index: number } | null>(null); const [ columnDropPosition, setColumnDropPosition ] = useState(null); + const [ branchIdToEdit, setBranchIdToEdit ] = useState(); const api = useMemo(() => { - return new Api(byColumn, columns ?? [], parentNote, statusAttribute, viewConfig ?? {}, saveConfig); - }, [ byColumn, columns, parentNote, statusAttribute, viewConfig, saveConfig ]); + return new Api(byColumn, columns ?? [], parentNote, statusAttribute, viewConfig ?? {}, saveConfig, setBranchIdToEdit ); + }, [ byColumn, columns, parentNote, statusAttribute, viewConfig, saveConfig, setBranchIdToEdit ]); + const boardViewContext = useMemo(() => ({ + branchIdToEdit + }), [ branchIdToEdit ]); function refresh() { getBoardData(parentNote, statusAttribute, viewConfig ?? {}).then(({ byColumn, newPersistedData }) => { @@ -126,41 +137,43 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC return (
-
- {byColumn && columns?.map((column, index) => ( - <> - {columnDropPosition === index && draggedColumn?.column !== column && ( -
- )} - - - ))} - {columnDropPosition === columns?.length && draggedColumn && ( -
- )} + +
+ {byColumn && columns?.map((column, index) => ( + <> + {columnDropPosition === index && draggedColumn?.column !== column && ( +
+ )} + + + ))} + {columnDropPosition === columns?.length && draggedColumn && ( +
+ )} - -
+ +
+
) } @@ -359,6 +372,7 @@ function Card({ setDraggedCard: (card: { noteId: string, branchId: string, fromColumn: string, index: number } | null) => void, isDragging: boolean }) { + const { branchIdToEdit } = useContext(BoardViewContext); const colorClass = note.getColorClass() || ''; const handleDragStart = useCallback((e: DragEvent) => { @@ -383,8 +397,26 @@ function Card({ onDragEnd={handleDragEnd} onContextMenu={handleContextMenu} > - - {note.title} + {branch.branchId !== branchIdToEdit ? ( + <> + + {note.title} + + ) : ( + { + if (e.key === "Enter") { + api.renameCard(note.noteId, e.currentTarget.value); + api.dismissEditingTitle(); + } + + if (e.key === "Escape") { + api.dismissEditingTitle(); + } + }} + /> + )}
) } diff --git a/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts b/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts index 54658f6ee2..4306296373 100644 --- a/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts +++ b/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts @@ -447,8 +447,7 @@ export class DifferentialBoardRenderer { try { // Update the note title using the board view's server call import('../../../services/server').then(async ({ default: server }) => { - await server.put(`notes/${noteId}/title`, { title: newTitle.trim() }); - finalTitle = newTitle.trim(); + }); } catch (error) { console.error("Failed to update note title:", error); From 228a1ad0da1e11c660bf1310731acbc9575dc6e3 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 11 Sep 2025 20:07:01 +0300 Subject: [PATCH 140/233] chore(react/collections/table): reintroduce icon while editing --- apps/client/src/widgets/collections/board/index.css | 2 ++ apps/client/src/widgets/collections/board/index.tsx | 11 +++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/client/src/widgets/collections/board/index.css b/apps/client/src/widgets/collections/board/index.css index dc1d2f10db..c620ab0bc8 100644 --- a/apps/client/src/widgets/collections/board/index.css +++ b/apps/client/src/widgets/collections/board/index.css @@ -154,6 +154,8 @@ .board-view-container .board-note.editing { box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.35); border-color: var(--main-text-color); + display: flex; + align-items: center; } .board-view-container .board-note.editing input { diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index b9a59a2590..13e15db5ef 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -373,6 +373,7 @@ function Card({ isDragging: boolean }) { const { branchIdToEdit } = useContext(BoardViewContext); + const isEditing = branch.branchId === branchIdToEdit; const colorClass = note.getColorClass() || ''; const handleDragStart = useCallback((e: DragEvent) => { @@ -391,17 +392,15 @@ function Card({ return (
- {branch.branchId !== branchIdToEdit ? ( - <> - - {note.title} - + + {!isEditing ? ( + <>{note.title} ) : ( Date: Thu, 11 Sep 2025 20:32:21 +0300 Subject: [PATCH 141/233] chore(react/collections/table): slightly improve editing experience --- .../src/widgets/collections/board/index.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 13e15db5ef..3547257b04 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -375,6 +375,7 @@ function Card({ const { branchIdToEdit } = useContext(BoardViewContext); const isEditing = branch.branchId === branchIdToEdit; const colorClass = note.getColorClass() || ''; + const editorRef = useRef(null); const handleDragStart = useCallback((e: DragEvent) => { e.dataTransfer!.effectAllowed = 'move'; @@ -390,6 +391,10 @@ function Card({ openNoteContextMenu(api, e, note.noteId, branch.branchId, column); }, [ api, note, branch, column ]); + useEffect(() => { + editorRef.current?.focus(); + }, []); + return (
{note.title} ) : ( { if (e.key === "Enter") { - api.renameCard(note.noteId, e.currentTarget.value); + const newTitle = e.currentTarget.value; + if (newTitle !== note.title) { + api.renameCard(note.noteId, newTitle); + } api.dismissEditingTitle(); } @@ -414,6 +423,12 @@ function Card({ api.dismissEditingTitle(); } }} + onBlur={(newTitle) => { + if (newTitle !== note.title) { + api.renameCard(note.noteId, newTitle); + } + api.dismissEditingTitle(); + }} /> )}
From d52cf455a9a1e35409674c4fd71009363428c1f3 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 11 Sep 2025 20:37:09 +0300 Subject: [PATCH 142/233] chore(react/collections/table): not loading config correctly --- apps/client/src/widgets/collections/board/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 3547257b04..cc00b7b10d 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -62,7 +62,7 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC }); } - useEffect(refresh, [ parentNote, noteIds ]); + useEffect(refresh, [ parentNote, noteIds, viewConfig ]); const handleColumnDrop = useCallback((fromIndex: number, toIndex: number) => { if (!columns || fromIndex === toIndex) return; From 68b8ba691faab1d0740b52fc1199631f1a0ba930 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 11 Sep 2025 20:45:54 +0300 Subject: [PATCH 143/233] chore(react/collections/table): fix one extra rendering of wrong type --- apps/client/src/widgets/react/hooks.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 13eff8c467..48a9590e59 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -291,7 +291,7 @@ export function useNoteRelation(note: FNote | undefined | null, relationName: st * @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(note?.getLabelValue(labelName)); + const [ , setLabelValue ] = useState(); useEffect(() => setLabelValue(note?.getLabelValue(labelName) ?? null), [ note ]); useTriliumEvent("entitiesReloaded", ({ loadResults }) => { @@ -319,7 +319,7 @@ export function useNoteLabel(note: FNote | undefined | null, labelName: string): useDebugValue(labelName); return [ - labelValue, + note?.getLabelValue(labelName), setter ] as const; } From 05973672e4fcfa966a2e171af85d5b18860f22c6 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 11 Sep 2025 21:11:44 +0300 Subject: [PATCH 144/233] chore(react/collections/table): add back insert above/below --- .../src/widgets/collections/board/api.ts | 35 ++++++- .../widgets/collections/board/context_menu.ts | 4 +- .../widgets/view_widgets/board_view/api.ts | 31 +----- .../board_view/differential_renderer.ts | 96 ------------------- .../widgets/view_widgets/board_view/index.ts | 21 ---- 5 files changed, 36 insertions(+), 151 deletions(-) diff --git a/apps/client/src/widgets/collections/board/api.ts b/apps/client/src/widgets/collections/board/api.ts index a3b67ab0a2..f70a8d00d1 100644 --- a/apps/client/src/widgets/collections/board/api.ts +++ b/apps/client/src/widgets/collections/board/api.ts @@ -1,4 +1,5 @@ import { BoardViewData } from "."; +import appContext from "../../../components/app_context"; import FNote from "../../../entities/fnote"; import attributes from "../../../services/attributes"; import { executeBulkActions } from "../../../services/bulk_action"; @@ -29,9 +30,9 @@ export default class BoardApi { title: "New item" }); - if (newNote) { + if (newNote && newBranch) { await this.changeColumn(newNote.noteId, column); - this.setBranchIdToEdit(newBranch?.branchId); + this.startEditing(newBranch?.branchId); } } catch (error) { console.error("Failed to create new item:", error); @@ -56,6 +57,36 @@ export default class BoardApi { this.saveConfig(this.viewConfig); } + 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: "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); } diff --git a/apps/client/src/widgets/collections/board/context_menu.ts b/apps/client/src/widgets/collections/board/context_menu.ts index 7b90d7000c..303db332a9 100644 --- a/apps/client/src/widgets/collections/board/context_menu.ts +++ b/apps/client/src/widgets/collections/board/context_menu.ts @@ -53,12 +53,12 @@ export function openNoteContextMenu(api: Api, event: ContextMenuEvent, noteId: s { title: t("board_view.insert-above"), uiIcon: "bx bx-list-plus", - // handler: () => boardView.insertItemAtPosition(column, branchId, "before") + handler: () => api.insertRowAtPosition(column, branchId, "before") }, { title: t("board_view.insert-below"), uiIcon: "bx bx-empty", - // handler: () => boardView.insertItemAtPosition(column, branchId, "after") + handler: () => api.insertRowAtPosition(column, branchId, "after") }, { title: "----" }, { diff --git a/apps/client/src/widgets/view_widgets/board_view/api.ts b/apps/client/src/widgets/view_widgets/board_view/api.ts index 66fc218b07..1739c8374a 100644 --- a/apps/client/src/widgets/view_widgets/board_view/api.ts +++ b/apps/client/src/widgets/view_widgets/board_view/api.ts @@ -29,35 +29,6 @@ export default class BoardApi { return this.byColumn.get(column); } - openNote(noteId: string) { - appContext.triggerCommand("openInPopup", { noteIdOrPath: noteId }); - } - - async insertRowAtPosition( - column: string, - relativeToBranchId: string, - direction: "before" | "after", - open: boolean = true) { - const { note } = await note_create.createNote(this._parentNoteId, { - activate: false, - targetBranchId: relativeToBranchId, - target: direction, - title: "New item" - }); - - if (!note) { - throw new Error("Failed to create note"); - } - - const { noteId } = note; - await this.changeColumn(noteId, column); - if (open) { - this.openNote(noteId); - } - - return note; - } - async renameColumn(oldValue: string, newValue: string, noteIds: string[]) { // Change the value in the notes. await executeBulkActions(noteIds, [ @@ -80,7 +51,7 @@ export default class BoardApi { async reorderColumns(newColumnOrder: string[]) { - // Update the column order in persisted data + // Update the co lumn order in persisted data if (!this.persistedData.columns) { this.persistedData.columns = []; } diff --git a/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts b/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts index 4306296373..3641fc50dd 100644 --- a/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts +++ b/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts @@ -380,100 +380,4 @@ export class DifferentialBoardRenderer { } } - startInlineEditing(noteId: string): void { - // Use setTimeout to ensure the card is rendered before trying to edit it - setTimeout(() => { - const $card = this.$container.find(`[data-note-id="${noteId}"]`); - if ($card.length) { - this.makeCardEditable($card, noteId); - } - }, 100); - } - - private makeCardEditable($card: JQuery, noteId: string): void { - if ($card.hasClass('editing')) { - return; // Already editing - } - - // Get the current title (get text without icon) - const $icon = $card.find('.icon'); - const currentTitle = $card.text().trim(); - - // Add editing class and store original click handler - $card.addClass('editing'); - $card.off('click'); // Remove any existing click handlers temporarily - - // Create input element - const $input = $('') - .attr('type', 'text') - .val(currentTitle) - .css({ - background: 'transparent', - border: 'none', - outline: 'none', - fontFamily: 'inherit', - fontSize: 'inherit', - color: 'inherit', - flex: '1', - minWidth: '0', - padding: '0', - marginLeft: '0.25em' - }); - - // Create a flex container to keep icon and input inline - const $editContainer = $('
') - .css({ - display: 'flex', - alignItems: 'center', - width: '100%' - }); - - // Replace content with icon + input in flex container - $editContainer.append($icon.clone(), $input); - $card.empty().append($editContainer); - $input.focus().select(); - - const finishEdit = async (save = true) => { - if (!$card.hasClass('editing')) { - return; // Already finished - } - - $card.removeClass('editing'); - - let finalTitle = currentTitle; - if (save) { - const newTitle = $input.val() as string; - if (newTitle.trim() && newTitle !== currentTitle) { - try { - // Update the note title using the board view's server call - import('../../../services/server').then(async ({ default: server }) => { - - }); - } catch (error) { - console.error("Failed to update note title:", error); - } - } - } - - // Restore the card content - const iconClass = $card.attr('data-icon-class') || 'bx bx-file'; - const $newIcon = $('').addClass('icon').addClass(iconClass); - $card.text(finalTitle); - $card.prepend($newIcon); - - // Re-attach click handler for quick edit (for existing cards) - $card.on('click', () => appContext.triggerCommand("openInPopup", { noteIdOrPath: noteId })); - }; - - $input.on('blur', () => finishEdit(true)); - $input.on('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - finishEdit(true); - } else if (e.key === 'Escape') { - e.preventDefault(); - finishEdit(false); - } - }); - } } diff --git a/apps/client/src/widgets/view_widgets/board_view/index.ts b/apps/client/src/widgets/view_widgets/board_view/index.ts index 83767445c0..11fe5fe6d3 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -219,27 +219,6 @@ export default class BoardView extends ViewMode { } } - async insertItemAtPosition(column: string, relativeToBranchId: string, direction: "before" | "after"): Promise { - try { - // Create the note without opening it - const newNote = await this.api?.insertRowAtPosition(column, relativeToBranchId, direction, false); - - if (newNote) { - // Refresh the board to show the new item - await this.renderList(); - - // Start inline editing of the newly created card - this.startInlineEditingCard(newNote.noteId); - } - } catch (error) { - console.error("Failed to insert new item:", error); - } - } - - private startInlineEditingCard(noteId: string) { - this.renderer?.startInlineEditing(noteId); - } - forceFullRefresh() { this.renderer?.forceFullRender(); return this.renderList(); From d367cf997254bd6a634f439a8f15195747ee3427 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 11 Sep 2025 21:20:25 +0300 Subject: [PATCH 145/233] chore(react/collections/table): bring back wheel scroll --- .../src/widgets/collections/board/index.tsx | 7 +++++-- .../widgets/view_widgets/board_view/index.ts | 1 - apps/client/src/widgets/widget_utils.ts | 17 ++++++++++------- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index cc00b7b10d..5c2c446031 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -13,6 +13,7 @@ import branchService from "../../../services/branches"; import { openColumnContextMenu, openNoteContextMenu } from "./context_menu"; import { ContextMenuEvent } from "../../../menus/context_menu"; import { createContext } from "preact"; +import { onWheelHorizontalScroll } from "../../widget_utils"; export interface BoardViewData { columns?: BoardColumnData[]; @@ -79,7 +80,6 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC saveConfig(newViewConfig); setColumns(newColumns); - console.log("New columns are ", newColumns); setDraggedColumn(null); setColumnDropPosition(null); }, [columns, viewConfig, saveConfig]); @@ -136,7 +136,10 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC }, [draggedColumn, columnDropPosition, handleColumnDrop]); return ( -
+
{ super(args, "board"); this.$root = $(TPL); - setupHorizontalScrollViaWheel(this.$root); this.$container = this.$root.find(".board-view-container"); this.spacedUpdate = new SpacedUpdate(() => this.onSave(), 5_000); this.persistentData = { diff --git a/apps/client/src/widgets/widget_utils.ts b/apps/client/src/widgets/widget_utils.ts index f27fe68143..ba48a38dff 100644 --- a/apps/client/src/widgets/widget_utils.ts +++ b/apps/client/src/widgets/widget_utils.ts @@ -7,12 +7,15 @@ import utils from "../services/utils.js"; */ export function setupHorizontalScrollViaWheel($container: JQuery) { $container.on("wheel", (event) => { - const wheelEvent = event.originalEvent as WheelEvent; - if (utils.isCtrlKey(event) || event.altKey || event.shiftKey) { - return; - } - event.preventDefault(); - event.stopImmediatePropagation(); - event.currentTarget.scrollLeft += wheelEvent.deltaY + wheelEvent.deltaX; + onWheelHorizontalScroll(event.originalEvent as WheelEvent); }); } + +export function onWheelHorizontalScroll(event: WheelEvent) { + if (!event.currentTarget || utils.isCtrlKey(event) || event.altKey || event.shiftKey) { + return; + } + event.preventDefault(); + event.stopImmediatePropagation(); + (event.currentTarget as HTMLElement).scrollLeft += event.deltaY + event.deltaX; +} From 60ef816f0cbf80a6cdb8b0ee4af2126175710cd7 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 11 Sep 2025 21:34:10 +0300 Subject: [PATCH 146/233] chore(react/collections/table): bring back renaming columns --- .../src/widgets/collections/board/api.ts | 21 ++++++ .../src/widgets/collections/board/index.css | 4 ++ .../src/widgets/collections/board/index.tsx | 64 +++++++++++++++++-- .../widgets/view_widgets/board_view/api.ts | 21 ------ .../widgets/view_widgets/board_view/index.ts | 12 ---- 5 files changed, 82 insertions(+), 40 deletions(-) diff --git a/apps/client/src/widgets/collections/board/api.ts b/apps/client/src/widgets/collections/board/api.ts index f70a8d00d1..210c2fb921 100644 --- a/apps/client/src/widgets/collections/board/api.ts +++ b/apps/client/src/widgets/collections/board/api.ts @@ -57,6 +57,27 @@ export default class BoardApi { 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); + } + async insertRowAtPosition( column: string, relativeToBranchId: string, diff --git a/apps/client/src/widgets/collections/board/index.css b/apps/client/src/widgets/collections/board/index.css index c620ab0bc8..6e002ba20a 100644 --- a/apps/client/src/widgets/collections/board/index.css +++ b/apps/client/src/widgets/collections/board/index.css @@ -54,6 +54,10 @@ 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; diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 5c2c446031..2acbfe82dd 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -25,6 +25,8 @@ export interface BoardColumnData { interface BoardViewContextData { branchIdToEdit?: string; + columnNameToEdit?: string; + setColumnNameToEdit?: (column: string | undefined) => void; } const BoardViewContext = createContext({}); @@ -39,12 +41,15 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC const [ draggedColumn, setDraggedColumn ] = useState<{ column: string, index: number } | null>(null); const [ columnDropPosition, setColumnDropPosition ] = useState(null); const [ branchIdToEdit, setBranchIdToEdit ] = useState(); + const [ columnNameToEdit, setColumnNameToEdit ] = useState(); const api = useMemo(() => { return new Api(byColumn, columns ?? [], parentNote, statusAttribute, viewConfig ?? {}, saveConfig, setBranchIdToEdit ); }, [ byColumn, columns, parentNote, statusAttribute, viewConfig, saveConfig, setBranchIdToEdit ]); const boardViewContext = useMemo(() => ({ - branchIdToEdit - }), [ branchIdToEdit ]); + branchIdToEdit, + columnNameToEdit, + setColumnNameToEdit + }), [ branchIdToEdit, columnNameToEdit, setColumnNameToEdit ]); function refresh() { getBoardData(parentNote, statusAttribute, viewConfig ?? {}).then(({ byColumn, newPersistedData }) => { @@ -214,6 +219,10 @@ function Column({ isDraggingColumn: boolean, api: Api }) { + const context = useContext(BoardViewContext); + const isEditing = (context.columnNameToEdit === column); + const editorRef = useRef(null); + const handleColumnDragStart = useCallback((e: DragEvent) => { e.dataTransfer!.effectAllowed = 'move'; e.dataTransfer!.setData('text/plain', column); @@ -301,10 +310,18 @@ function Column({ setDraggedCard(null); }, [draggedCard, draggedColumn, dropPosition, columnItems, column, statusAttribute, setDraggedCard, setDropTarget, setDropPosition, onCardDrop]); + const handleEdit = useCallback(() => { + context.setColumnNameToEdit?.(column); + }, [column]); + const handleContextMenu = useCallback((e: ContextMenuEvent) => { openColumnContextMenu(api, e, column); }, [ api, column ]); + useEffect(() => { + editorRef.current?.focus(); + }, [ isEditing ]); + return (

- {column} - + {!isEditing ? ( + <> + {column} + + + ) : ( + <> + { + if (e.key === "Enter") { + const newTitle = e.currentTarget.value; + if (newTitle !== column) { + api.renameColumn(column, newTitle); + } + context.setColumnNameToEdit?.(undefined); + } + + if (e.key === "Escape") { + context.setColumnNameToEdit?.(undefined); + } + }} + onBlur={(newTitle) => { + if (newTitle !== column) { + api.renameColumn(column, newTitle); + } + context.setColumnNameToEdit?.(undefined); + }} + /> + + )}

{(columnItems ?? []).map(({ note, branch }, index) => { @@ -396,7 +446,7 @@ function Card({ useEffect(() => { editorRef.current?.focus(); - }, []); + }, [ isEditing ]); return (
{ }); } - private createTitleStructure(title: string): { $titleText: JQuery; $editIcon: JQuery } { - const $titleText = $("").text(title); - const $editIcon = $("") - .addClass("edit-icon icon bx bx-edit-alt") - .attr("title", "Click to edit column title"); - - return { $titleText, $editIcon }; - } - private startEditingColumnTitle($titleEl: JQuery, columnValue: string, columnItems: { branch: any; note: any; }[]) { if ($titleEl.hasClass("editing")) { return; // Already editing @@ -261,8 +252,5 @@ export default class BoardView extends ViewMode { } } - private onSave() { - this.viewStorage.store(this.persistentData); - } } From cb84e4c7b6f842db74c71f51215508b6ac6c84bc Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 11 Sep 2025 21:42:59 +0300 Subject: [PATCH 147/233] refactor(react/collections/table): split card/column --- .../src/widgets/collections/board/card.tsx | 88 +++++ .../src/widgets/collections/board/column.tsx | 234 +++++++++++++ .../src/widgets/collections/board/index.tsx | 312 +----------------- 3 files changed, 325 insertions(+), 309 deletions(-) create mode 100644 apps/client/src/widgets/collections/board/card.tsx create mode 100644 apps/client/src/widgets/collections/board/column.tsx diff --git a/apps/client/src/widgets/collections/board/card.tsx b/apps/client/src/widgets/collections/board/card.tsx new file mode 100644 index 0000000000..3dda7fd897 --- /dev/null +++ b/apps/client/src/widgets/collections/board/card.tsx @@ -0,0 +1,88 @@ +import { useCallback, useContext, useEffect, useRef } from "preact/hooks"; +import FBranch from "../../../entities/fbranch"; +import FNote from "../../../entities/fnote"; +import BoardApi from "./api"; +import { BoardViewContext } from "."; +import { ContextMenuEvent } from "../../../menus/context_menu"; +import { openNoteContextMenu } from "./context_menu"; +import FormTextBox from "../../react/FormTextBox"; + +export default function Card({ + api, + note, + branch, + column, + index, + setDraggedCard, + isDragging +}: { + api: BoardApi, + note: FNote, + branch: FBranch, + column: string, + index: number, + setDraggedCard: (card: { noteId: string, branchId: string, fromColumn: string, index: number } | null) => void, + isDragging: boolean +}) { + const { branchIdToEdit } = useContext(BoardViewContext); + const isEditing = branch.branchId === branchIdToEdit; + const colorClass = note.getColorClass() || ''; + const editorRef = useRef(null); + + const handleDragStart = useCallback((e: DragEvent) => { + e.dataTransfer!.effectAllowed = 'move'; + e.dataTransfer!.setData('text/plain', note.noteId); + setDraggedCard({ noteId: note.noteId, branchId: branch.branchId, fromColumn: column, index }); + }, [note.noteId, branch.branchId, column, index, setDraggedCard]); + + const handleDragEnd = useCallback(() => { + setDraggedCard(null); + }, [setDraggedCard]); + + const handleContextMenu = useCallback((e: ContextMenuEvent) => { + openNoteContextMenu(api, e, note.noteId, branch.branchId, column); + }, [ api, note, branch, column ]); + + useEffect(() => { + editorRef.current?.focus(); + }, [ isEditing ]); + + return ( +
+ + {!isEditing ? ( + <>{note.title} + ) : ( + { + if (e.key === "Enter") { + const newTitle = e.currentTarget.value; + if (newTitle !== note.title) { + api.renameCard(note.noteId, newTitle); + } + api.dismissEditingTitle(); + } + + if (e.key === "Escape") { + api.dismissEditingTitle(); + } + }} + onBlur={(newTitle) => { + if (newTitle !== note.title) { + api.renameCard(note.noteId, newTitle); + } + api.dismissEditingTitle(); + }} + /> + )} +
+ ) +} diff --git a/apps/client/src/widgets/collections/board/column.tsx b/apps/client/src/widgets/collections/board/column.tsx new file mode 100644 index 0000000000..08da9c97b7 --- /dev/null +++ b/apps/client/src/widgets/collections/board/column.tsx @@ -0,0 +1,234 @@ +import { useCallback, useContext, useEffect, useRef } from "preact/hooks"; +import FBranch from "../../../entities/fbranch"; +import FNote from "../../../entities/fnote"; +import { BoardViewContext } from "."; +import branches from "../../../services/branches"; +import { openColumnContextMenu } from "./context_menu"; +import { ContextMenuEvent } from "../../../menus/context_menu"; +import FormTextBox from "../../react/FormTextBox"; +import Icon from "../../react/Icon"; +import { t } from "../../../services/i18n"; +import BoardApi from "./api"; +import Card from "./card"; + +export default function Column({ + column, + columnIndex, + columnItems, + statusAttribute, + draggedCard, + setDraggedCard, + dropTarget, + setDropTarget, + dropPosition, + setDropPosition, + onCardDrop, + draggedColumn, + setDraggedColumn, + isDraggingColumn, + api +}: { + column: string, + columnIndex: number, + columnItems?: { note: FNote, branch: FBranch }[], + statusAttribute: string, + draggedCard: { noteId: string, branchId: string, fromColumn: string, index: number } | null, + setDraggedCard: (card: { noteId: string, branchId: string, fromColumn: string, index: number } | null) => void, + dropTarget: string | null, + setDropTarget: (target: string | null) => void, + dropPosition: { column: string, index: number } | null, + setDropPosition: (position: { column: string, index: number } | null) => void, + onCardDrop: () => void, + draggedColumn: { column: string, index: number } | null, + setDraggedColumn: (column: { column: string, index: number } | null) => void, + isDraggingColumn: boolean, + api: BoardApi +}) { + const context = useContext(BoardViewContext); + const isEditing = (context.columnNameToEdit === column); + const editorRef = useRef(null); + + const handleColumnDragStart = useCallback((e: DragEvent) => { + e.dataTransfer!.effectAllowed = 'move'; + e.dataTransfer!.setData('text/plain', column); + setDraggedColumn({ column, index: columnIndex }); + e.stopPropagation(); // Prevent card drag from interfering + }, [column, columnIndex, setDraggedColumn]); + + const handleColumnDragEnd = useCallback(() => { + setDraggedColumn(null); + }, [setDraggedColumn]); + + const handleDragOver = useCallback((e: DragEvent) => { + if (draggedColumn) return; // Don't handle card drops when dragging columns + e.preventDefault(); + setDropTarget(column); + + // Calculate drop position based on mouse position + const cards = Array.from(e.currentTarget.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; + } + } + + setDropPosition({ column, index: newIndex }); + }, [column, setDropTarget, setDropPosition]); + + 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); + + if (draggedCard && dropPosition) { + const targetIndex = dropPosition.index; + const targetItems = columnItems || []; + + if (draggedCard.fromColumn !== column) { + // Moving to a different column + await api.changeColumn(draggedCard.noteId, column); + + // If there are items in the target column, reorder + if (targetItems.length > 0 && targetIndex < targetItems.length) { + const targetBranch = targetItems[targetIndex].branch; + await branches.moveBeforeBranch([ draggedCard.branchId ], targetBranch.branchId); + } + } else if (draggedCard.index !== targetIndex) { + // Reordering within the same column + let targetBranchId: string | null = null; + + if (targetIndex < targetItems.length) { + // Moving before an existing item + const adjustedIndex = draggedCard.index < targetIndex ? targetIndex : targetIndex; + if (adjustedIndex < targetItems.length) { + targetBranchId = targetItems[adjustedIndex].branch.branchId; + await branches.moveBeforeBranch([ draggedCard.branchId ], targetBranchId); + } + } else if (targetIndex > 0) { + // Moving to the end - place after the last item + const lastItem = targetItems[targetItems.length - 1]; + await branches.moveAfterBranch([ draggedCard.branchId ], lastItem.branch.branchId); + } + } + + onCardDrop(); + } + setDraggedCard(null); + }, [draggedCard, draggedColumn, dropPosition, columnItems, column, statusAttribute, setDraggedCard, setDropTarget, setDropPosition, onCardDrop]); + + const handleEdit = useCallback(() => { + context.setColumnNameToEdit?.(column); + }, [column]); + + const handleContextMenu = useCallback((e: ContextMenuEvent) => { + openColumnContextMenu(api, e, column); + }, [ api, column ]); + + useEffect(() => { + editorRef.current?.focus(); + }, [ isEditing ]); + + return ( +
+

+ {!isEditing ? ( + <> + {column} + + + ) : ( + <> + { + if (e.key === "Enter") { + const newTitle = e.currentTarget.value; + if (newTitle !== column) { + api.renameColumn(column, newTitle); + } + context.setColumnNameToEdit?.(undefined); + } + + if (e.key === "Escape") { + context.setColumnNameToEdit?.(undefined); + } + }} + onBlur={(newTitle) => { + if (newTitle !== column) { + api.renameColumn(column, newTitle); + } + context.setColumnNameToEdit?.(undefined); + }} + /> + + )} +

+ + {(columnItems ?? []).map(({ note, branch }, index) => { + const showIndicatorBefore = dropPosition?.column === column && + dropPosition.index === index && + draggedCard?.noteId !== note.noteId; + + return ( + <> + {showIndicatorBefore && ( +
+ )} + + + ); + })} + {dropPosition?.column === column && dropPosition.index === (columnItems?.length ?? 0) && ( +
+ )} + +
api.createNewItem(column)}> + {" "} + {t("board_view.new-item")} +
+
+ ) +} diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 2acbfe82dd..e7b9a93221 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -1,19 +1,15 @@ -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks"; +import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; import { ViewModeProps } from "../interface"; import "./index.css"; import { ColumnMap, getBoardData } from "./data"; import { useNoteLabelWithDefault, useTriliumEvent } from "../../react/hooks"; -import FNote from "../../../entities/fnote"; -import FBranch from "../../../entities/fbranch"; import Icon from "../../react/Icon"; import { t } from "../../../services/i18n"; import Api from "./api"; import FormTextBox from "../../react/FormTextBox"; -import branchService from "../../../services/branches"; -import { openColumnContextMenu, openNoteContextMenu } from "./context_menu"; -import { ContextMenuEvent } from "../../../menus/context_menu"; import { createContext } from "preact"; import { onWheelHorizontalScroll } from "../../widget_utils"; +import Column from "./column"; export interface BoardViewData { columns?: BoardColumnData[]; @@ -29,7 +25,7 @@ interface BoardViewContextData { setColumnNameToEdit?: (column: string | undefined) => void; } -const BoardViewContext = createContext({}); +export const BoardViewContext = createContext({}); export default function BoardView({ note: parentNote, noteIds, viewConfig, saveConfig }: ViewModeProps) { const [ statusAttribute ] = useNoteLabelWithDefault(parentNote, "board:groupBy", "status"); @@ -186,308 +182,6 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC ) } -function Column({ - column, - columnIndex, - columnItems, - statusAttribute, - draggedCard, - setDraggedCard, - dropTarget, - setDropTarget, - dropPosition, - setDropPosition, - onCardDrop, - draggedColumn, - setDraggedColumn, - isDraggingColumn, - api -}: { - column: string, - columnIndex: number, - columnItems?: { note: FNote, branch: FBranch }[], - statusAttribute: string, - draggedCard: { noteId: string, branchId: string, fromColumn: string, index: number } | null, - setDraggedCard: (card: { noteId: string, branchId: string, fromColumn: string, index: number } | null) => void, - dropTarget: string | null, - setDropTarget: (target: string | null) => void, - dropPosition: { column: string, index: number } | null, - setDropPosition: (position: { column: string, index: number } | null) => void, - onCardDrop: () => void, - draggedColumn: { column: string, index: number } | null, - setDraggedColumn: (column: { column: string, index: number } | null) => void, - isDraggingColumn: boolean, - api: Api -}) { - const context = useContext(BoardViewContext); - const isEditing = (context.columnNameToEdit === column); - const editorRef = useRef(null); - - const handleColumnDragStart = useCallback((e: DragEvent) => { - e.dataTransfer!.effectAllowed = 'move'; - e.dataTransfer!.setData('text/plain', column); - setDraggedColumn({ column, index: columnIndex }); - e.stopPropagation(); // Prevent card drag from interfering - }, [column, columnIndex, setDraggedColumn]); - - const handleColumnDragEnd = useCallback(() => { - setDraggedColumn(null); - }, [setDraggedColumn]); - - const handleDragOver = useCallback((e: DragEvent) => { - if (draggedColumn) return; // Don't handle card drops when dragging columns - e.preventDefault(); - setDropTarget(column); - - // Calculate drop position based on mouse position - const cards = Array.from(e.currentTarget.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; - } - } - - setDropPosition({ column, index: newIndex }); - }, [column, setDropTarget, setDropPosition]); - - 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); - - if (draggedCard && dropPosition) { - const targetIndex = dropPosition.index; - const targetItems = columnItems || []; - - if (draggedCard.fromColumn !== column) { - // Moving to a different column - await api.changeColumn(draggedCard.noteId, column); - - // If there are items in the target column, reorder - if (targetItems.length > 0 && targetIndex < targetItems.length) { - const targetBranch = targetItems[targetIndex].branch; - await branchService.moveBeforeBranch([ draggedCard.branchId ], targetBranch.branchId); - } - } else if (draggedCard.index !== targetIndex) { - // Reordering within the same column - let targetBranchId: string | null = null; - - if (targetIndex < targetItems.length) { - // Moving before an existing item - const adjustedIndex = draggedCard.index < targetIndex ? targetIndex : targetIndex; - if (adjustedIndex < targetItems.length) { - targetBranchId = targetItems[adjustedIndex].branch.branchId; - await branchService.moveBeforeBranch([ draggedCard.branchId ], targetBranchId); - } - } else if (targetIndex > 0) { - // Moving to the end - place after the last item - const lastItem = targetItems[targetItems.length - 1]; - await branchService.moveAfterBranch([ draggedCard.branchId ], lastItem.branch.branchId); - } - } - - onCardDrop(); - } - setDraggedCard(null); - }, [draggedCard, draggedColumn, dropPosition, columnItems, column, statusAttribute, setDraggedCard, setDropTarget, setDropPosition, onCardDrop]); - - const handleEdit = useCallback(() => { - context.setColumnNameToEdit?.(column); - }, [column]); - - const handleContextMenu = useCallback((e: ContextMenuEvent) => { - openColumnContextMenu(api, e, column); - }, [ api, column ]); - - useEffect(() => { - editorRef.current?.focus(); - }, [ isEditing ]); - - return ( -
-

- {!isEditing ? ( - <> - {column} - - - ) : ( - <> - { - if (e.key === "Enter") { - const newTitle = e.currentTarget.value; - if (newTitle !== column) { - api.renameColumn(column, newTitle); - } - context.setColumnNameToEdit?.(undefined); - } - - if (e.key === "Escape") { - context.setColumnNameToEdit?.(undefined); - } - }} - onBlur={(newTitle) => { - if (newTitle !== column) { - api.renameColumn(column, newTitle); - } - context.setColumnNameToEdit?.(undefined); - }} - /> - - )} -

- - {(columnItems ?? []).map(({ note, branch }, index) => { - const showIndicatorBefore = dropPosition?.column === column && - dropPosition.index === index && - draggedCard?.noteId !== note.noteId; - - return ( - <> - {showIndicatorBefore && ( -
- )} - - - ); - })} - {dropPosition?.column === column && dropPosition.index === (columnItems?.length ?? 0) && ( -
- )} - -
api.createNewItem(column)}> - {" "} - {t("board_view.new-item")} -
-
- ) -} - -function Card({ - api, - note, - branch, - column, - index, - setDraggedCard, - isDragging -}: { - api: Api, - note: FNote, - branch: FBranch, - column: string, - index: number, - setDraggedCard: (card: { noteId: string, branchId: string, fromColumn: string, index: number } | null) => void, - isDragging: boolean -}) { - const { branchIdToEdit } = useContext(BoardViewContext); - const isEditing = branch.branchId === branchIdToEdit; - const colorClass = note.getColorClass() || ''; - const editorRef = useRef(null); - - const handleDragStart = useCallback((e: DragEvent) => { - e.dataTransfer!.effectAllowed = 'move'; - e.dataTransfer!.setData('text/plain', note.noteId); - setDraggedCard({ noteId: note.noteId, branchId: branch.branchId, fromColumn: column, index }); - }, [note.noteId, branch.branchId, column, index, setDraggedCard]); - - const handleDragEnd = useCallback(() => { - setDraggedCard(null); - }, [setDraggedCard]); - - const handleContextMenu = useCallback((e: ContextMenuEvent) => { - openNoteContextMenu(api, e, note.noteId, branch.branchId, column); - }, [ api, note, branch, column ]); - - useEffect(() => { - editorRef.current?.focus(); - }, [ isEditing ]); - - return ( -
- - {!isEditing ? ( - <>{note.title} - ) : ( - { - if (e.key === "Enter") { - const newTitle = e.currentTarget.value; - if (newTitle !== note.title) { - api.renameCard(note.noteId, newTitle); - } - api.dismissEditingTitle(); - } - - if (e.key === "Escape") { - api.dismissEditingTitle(); - } - }} - onBlur={(newTitle) => { - if (newTitle !== note.title) { - api.renameCard(note.noteId, newTitle); - } - api.dismissEditingTitle(); - }} - /> - )} -
- ) -} - function AddNewColumn({ viewConfig, saveConfig }: { viewConfig?: BoardViewData, saveConfig: (data: BoardViewData) => void }) { const [ isCreatingNewColumn, setIsCreatingNewColumn ] = useState(false); const columnNameRef = useRef(null); From 62452b61b1b28b890fe151e7c99dec1cc0d24aa7 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 11 Sep 2025 21:51:02 +0300 Subject: [PATCH 148/233] refactor(react/collections/table): deduplicate editing --- .../src/widgets/collections/board/card.tsx | 27 ++----------- .../src/widgets/collections/board/column.tsx | 33 +++------------- .../src/widgets/collections/board/index.tsx | 39 +++++++++++++++++++ 3 files changed, 49 insertions(+), 50 deletions(-) diff --git a/apps/client/src/widgets/collections/board/card.tsx b/apps/client/src/widgets/collections/board/card.tsx index 3dda7fd897..842af7ebdc 100644 --- a/apps/client/src/widgets/collections/board/card.tsx +++ b/apps/client/src/widgets/collections/board/card.tsx @@ -2,10 +2,9 @@ import { useCallback, useContext, useEffect, useRef } from "preact/hooks"; import FBranch from "../../../entities/fbranch"; import FNote from "../../../entities/fnote"; import BoardApi from "./api"; -import { BoardViewContext } from "."; +import { BoardViewContext, TitleEditor } from "."; import { ContextMenuEvent } from "../../../menus/context_menu"; import { openNoteContextMenu } from "./context_menu"; -import FormTextBox from "../../react/FormTextBox"; export default function Card({ api, @@ -59,28 +58,10 @@ export default function Card({ {!isEditing ? ( <>{note.title} ) : ( - { - if (e.key === "Enter") { - const newTitle = e.currentTarget.value; - if (newTitle !== note.title) { - api.renameCard(note.noteId, newTitle); - } - api.dismissEditingTitle(); - } - - if (e.key === "Escape") { - api.dismissEditingTitle(); - } - }} - onBlur={(newTitle) => { - if (newTitle !== note.title) { - api.renameCard(note.noteId, newTitle); - } - api.dismissEditingTitle(); - }} + save={newTitle => api.renameCard(note.noteId, newTitle)} + dismiss={() => api.dismissEditingTitle()} /> )}
diff --git a/apps/client/src/widgets/collections/board/column.tsx b/apps/client/src/widgets/collections/board/column.tsx index 08da9c97b7..a64ed4afb6 100644 --- a/apps/client/src/widgets/collections/board/column.tsx +++ b/apps/client/src/widgets/collections/board/column.tsx @@ -1,11 +1,10 @@ import { useCallback, useContext, useEffect, useRef } from "preact/hooks"; import FBranch from "../../../entities/fbranch"; import FNote from "../../../entities/fnote"; -import { BoardViewContext } from "."; +import { BoardViewContext, TitleEditor } from "."; import branches from "../../../services/branches"; import { openColumnContextMenu } from "./context_menu"; import { ContextMenuEvent } from "../../../menus/context_menu"; -import FormTextBox from "../../react/FormTextBox"; import Icon from "../../react/Icon"; import { t } from "../../../services/i18n"; import BoardApi from "./api"; @@ -171,31 +170,11 @@ export default function Column({ /> ) : ( - <> - { - if (e.key === "Enter") { - const newTitle = e.currentTarget.value; - if (newTitle !== column) { - api.renameColumn(column, newTitle); - } - context.setColumnNameToEdit?.(undefined); - } - - if (e.key === "Escape") { - context.setColumnNameToEdit?.(undefined); - } - }} - onBlur={(newTitle) => { - if (newTitle !== column) { - api.renameColumn(column, newTitle); - } - context.setColumnNameToEdit?.(undefined); - }} - /> - + api.renameColumn(column, newTitle)} + dismiss={() => context.setColumnNameToEdit?.(undefined)} + /> )}

diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index e7b9a93221..fdb7d2a635 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -242,3 +242,42 @@ function AddNewColumn({ viewConfig, saveConfig }: { viewConfig?: BoardViewData,
) } + +export function TitleEditor({ currentValue, save, dismiss }: { + currentValue: string, + save: (newValue: string) => void, + dismiss: () => void +}) { + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, [ inputRef ]); + + return ( + { + if (e.key === "Enter") { + const newValue = e.currentTarget.value; + if (newValue !== currentValue) { + save(newValue); + } + dismiss(); + } + + if (e.key === "Escape") { + dismiss(); + } + }} + onBlur={(newValue) => { + if (newValue !== currentValue) { + save(newValue); + } + dismiss(); + }} + /> + ) +} From f7e47b5120fcb7a9431095386949b41e411e58c1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 11 Sep 2025 22:22:50 +0300 Subject: [PATCH 149/233] feat(react/collections/table): make note title editable --- .../src/widgets/collections/board/card.tsx | 15 ++++++++++-- .../src/widgets/collections/board/column.tsx | 2 +- .../src/widgets/collections/board/index.css | 23 ++++++++++++------- .../src/widgets/collections/board/index.tsx | 10 ++++---- 4 files changed, 35 insertions(+), 15 deletions(-) diff --git a/apps/client/src/widgets/collections/board/card.tsx b/apps/client/src/widgets/collections/board/card.tsx index 842af7ebdc..04e025dcf3 100644 --- a/apps/client/src/widgets/collections/board/card.tsx +++ b/apps/client/src/widgets/collections/board/card.tsx @@ -23,7 +23,7 @@ export default function Card({ setDraggedCard: (card: { noteId: string, branchId: string, fromColumn: string, index: number } | null) => void, isDragging: boolean }) { - const { branchIdToEdit } = useContext(BoardViewContext); + const { branchIdToEdit, setBranchIdToEdit } = useContext(BoardViewContext); const isEditing = branch.branchId === branchIdToEdit; const colorClass = note.getColorClass() || ''; const editorRef = useRef(null); @@ -42,6 +42,10 @@ export default function Card({ openNoteContextMenu(api, e, note.noteId, branch.branchId, column); }, [ api, note, branch, column ]); + const handleEdit = useCallback((e) => { + setBranchIdToEdit?.(branch.branchId); + }, [ setBranchIdToEdit, branch ]); + useEffect(() => { editorRef.current?.focus(); }, [ isEditing ]); @@ -56,7 +60,14 @@ export default function Card({ > {!isEditing ? ( - <>{note.title} + <> + {note.title} + + ) : ( {!isEditing ? ( <> - {column} + {column} .title, +.board-view-container .board-note > .title { + flex-grow: 1; +} + .board-view-container .board-column h3:active { cursor: grabbing; } @@ -87,20 +95,19 @@ font-family: inherit; } -.board-view-container .board-column h3 .edit-icon { +.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-column h3:hover .edit-icon, +.board-view-container .board-note:hover .edit-icon { opacity: 1; } -.board-view-container .board-column h3.editing .edit-icon { - display: none; -} .board-view-container .board-note { box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.25); diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index fdb7d2a635..400e5b5781 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; +import { Dispatch, StateUpdater, useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; import { ViewModeProps } from "../interface"; import "./index.css"; import { ColumnMap, getBoardData } from "./data"; @@ -22,7 +22,8 @@ export interface BoardColumnData { interface BoardViewContextData { branchIdToEdit?: string; columnNameToEdit?: string; - setColumnNameToEdit?: (column: string | undefined) => void; + setColumnNameToEdit?: Dispatch>; + setBranchIdToEdit?: Dispatch>; } export const BoardViewContext = createContext({}); @@ -44,8 +45,9 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC const boardViewContext = useMemo(() => ({ branchIdToEdit, columnNameToEdit, - setColumnNameToEdit - }), [ branchIdToEdit, columnNameToEdit, setColumnNameToEdit ]); + setColumnNameToEdit, + setBranchIdToEdit + }), [ branchIdToEdit, columnNameToEdit, setColumnNameToEdit, setBranchIdToEdit ]); function refresh() { getBoardData(parentNote, statusAttribute, viewConfig ?? {}).then(({ byColumn, newPersistedData }) => { From d67018b6d75a777a0c328f006575c15f31f996fd Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 11 Sep 2025 22:35:31 +0300 Subject: [PATCH 150/233] chore(react/collections/board): use translations --- apps/client/src/translations/en/translation.json | 5 ++++- apps/client/src/widgets/collections/board/api.ts | 3 ++- apps/client/src/widgets/collections/board/card.tsx | 3 ++- apps/client/src/widgets/collections/board/column.tsx | 2 +- apps/client/src/widgets/collections/board/data.ts | 1 - apps/client/src/widgets/collections/board/index.tsx | 2 +- 6 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 56ef0945d1..7e09326169 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -2000,7 +2000,10 @@ "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" + "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}}", diff --git a/apps/client/src/widgets/collections/board/api.ts b/apps/client/src/widgets/collections/board/api.ts index 210c2fb921..b696cb2280 100644 --- a/apps/client/src/widgets/collections/board/api.ts +++ b/apps/client/src/widgets/collections/board/api.ts @@ -3,6 +3,7 @@ import appContext from "../../../components/app_context"; import FNote from "../../../entities/fnote"; import attributes from "../../../services/attributes"; import { executeBulkActions } from "../../../services/bulk_action"; +import { t } from "../../../services/i18n"; import note_create from "../../../services/note_create"; import server from "../../../services/server"; import { ColumnMap } from "./data"; @@ -86,7 +87,7 @@ export default class BoardApi { activate: false, targetBranchId: relativeToBranchId, target: direction, - title: "New item" + title: t("board_view.new-item") }); if (!note || !branch) { diff --git a/apps/client/src/widgets/collections/board/card.tsx b/apps/client/src/widgets/collections/board/card.tsx index 04e025dcf3..0535b493e6 100644 --- a/apps/client/src/widgets/collections/board/card.tsx +++ b/apps/client/src/widgets/collections/board/card.tsx @@ -5,6 +5,7 @@ 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 default function Card({ api, @@ -64,7 +65,7 @@ export default function Card({ {note.title} diff --git a/apps/client/src/widgets/collections/board/column.tsx b/apps/client/src/widgets/collections/board/column.tsx index 43ee9ef633..67f6e76749 100644 --- a/apps/client/src/widgets/collections/board/column.tsx +++ b/apps/client/src/widgets/collections/board/column.tsx @@ -165,7 +165,7 @@ export default function Column({ {column} diff --git a/apps/client/src/widgets/collections/board/data.ts b/apps/client/src/widgets/collections/board/data.ts index 47cc101449..2a59e82b78 100644 --- a/apps/client/src/widgets/collections/board/data.ts +++ b/apps/client/src/widgets/collections/board/data.ts @@ -65,7 +65,6 @@ async function recursiveGroupBy(branches: FBranch[], byColumn: ColumnMap, groupB for (const branch of branches) { const note = await branch.getNote(); if (!note) { - console.warn("Not note found"); continue; } diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 400e5b5781..ea2badb3cb 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -228,7 +228,7 @@ function AddNewColumn({ viewConfig, saveConfig }: { viewConfig?: BoardViewData, finishEdit(true)} onKeyDown={(e: KeyboardEvent) => { if (e.key === "Enter") { From c96a65b21d6c23b42b4d3c0e6c40d963b1b5f851 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 11 Sep 2025 22:44:17 +0300 Subject: [PATCH 151/233] chore(react/collections/board): minor flicker when renaming note --- apps/client/src/widgets/collections/board/card.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/client/src/widgets/collections/board/card.tsx b/apps/client/src/widgets/collections/board/card.tsx index 0535b493e6..6d684ad3c0 100644 --- a/apps/client/src/widgets/collections/board/card.tsx +++ b/apps/client/src/widgets/collections/board/card.tsx @@ -1,4 +1,4 @@ -import { useCallback, useContext, useEffect, useRef } from "preact/hooks"; +import { useCallback, useContext, useEffect, useRef, useState } from "preact/hooks"; import FBranch from "../../../entities/fbranch"; import FNote from "../../../entities/fnote"; import BoardApi from "./api"; @@ -28,6 +28,7 @@ export default function Card({ const isEditing = branch.branchId === branchIdToEdit; const colorClass = note.getColorClass() || ''; const editorRef = useRef(null); + const [ title, setTitle ] = useState(note.title); const handleDragStart = useCallback((e: DragEvent) => { e.dataTransfer!.effectAllowed = 'move'; @@ -62,7 +63,7 @@ export default function Card({ {!isEditing ? ( <> - {note.title} + {title} api.renameCard(note.noteId, newTitle)} + save={newTitle => { + api.renameCard(note.noteId, newTitle); + setTitle(newTitle); + }} dismiss={() => api.dismissEditingTitle()} /> )} From 174f796b56107cc6fe5d661651a556e9d66a035f Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 13:58:52 +0300 Subject: [PATCH 152/233] chore(collections/board): context menu wrongly positioned --- apps/client/src/widgets/collections/board/column.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/widgets/collections/board/column.tsx b/apps/client/src/widgets/collections/board/column.tsx index 67f6e76749..5518ae7712 100644 --- a/apps/client/src/widgets/collections/board/column.tsx +++ b/apps/client/src/widgets/collections/board/column.tsx @@ -152,13 +152,13 @@ export default function Column({ onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} - onContextMenu={handleContextMenu} >

{!isEditing ? ( <> From 08dc05c504fb56b4fba7361826d38e8f5a32fd63 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 14:11:25 +0300 Subject: [PATCH 153/233] chore(collections/board): extract dragging to separate hook --- .../src/widgets/collections/board/column.tsx | 199 +++++++++--------- .../src/widgets/collections/board/index.tsx | 1 - 2 files changed, 102 insertions(+), 98 deletions(-) diff --git a/apps/client/src/widgets/collections/board/column.tsx b/apps/client/src/widgets/collections/board/column.tsx index 5518ae7712..9567862aa5 100644 --- a/apps/client/src/widgets/collections/board/column.tsx +++ b/apps/client/src/widgets/collections/board/column.tsx @@ -10,43 +10,123 @@ import { t } from "../../../services/i18n"; import BoardApi from "./api"; import Card from "./card"; +interface DragContext { + api: BoardApi; + column: string; + draggedColumn: { column: string, index: number } | null; + setDraggedColumn: (column: { column: string, index: number } | null) => void; + columnIndex: number, + setDropTarget: (target: string | null) => void, + setDropPosition: (position: { column: string, index: number } | null) => void; + onCardDrop: () => void; + dropPosition: { column: string, index: number } | null; + draggedCard: { noteId: string, branchId: string, fromColumn: string, index: number } | null; + setDraggedCard: (card: { noteId: string, branchId: string, fromColumn: string, index: number } | null) => void; + columnItems?: { note: FNote, branch: FBranch }[], +} + export default function Column({ column, - columnIndex, columnItems, - statusAttribute, draggedCard, setDraggedCard, dropTarget, - setDropTarget, dropPosition, - setDropPosition, - onCardDrop, - draggedColumn, - setDraggedColumn, isDraggingColumn, - api + api, + ...restProps }: { column: string, - columnIndex: number, - columnItems?: { note: FNote, branch: FBranch }[], - statusAttribute: string, - draggedCard: { noteId: string, branchId: string, fromColumn: string, index: number } | null, - setDraggedCard: (card: { noteId: string, branchId: string, fromColumn: string, index: number } | null) => void, dropTarget: string | null, - setDropTarget: (target: string | null) => void, - dropPosition: { column: string, index: number } | null, - setDropPosition: (position: { column: string, index: number } | null) => void, - onCardDrop: () => void, - draggedColumn: { column: string, index: number } | null, - setDraggedColumn: (column: { column: string, index: number } | null) => void, isDraggingColumn: boolean, api: BoardApi -}) { +} & DragContext) { const context = useContext(BoardViewContext); const isEditing = (context.columnNameToEdit === column); const editorRef = useRef(null); + const { handleColumnDragStart, handleColumnDragEnd, handleDragOver, handleDragLeave, handleDrop } = useDragging({ + api, column, dropPosition, draggedCard, setDraggedCard, columnItems, ...restProps + }); + const handleEdit = useCallback(() => { + context.setColumnNameToEdit?.(column); + }, [column]); + + const handleContextMenu = useCallback((e: ContextMenuEvent) => { + openColumnContextMenu(api, e, column); + }, [ api, column ]); + + useEffect(() => { + editorRef.current?.focus(); + }, [ isEditing ]); + + return ( +
+

+ {!isEditing ? ( + <> + {column} + + + ) : ( + api.renameColumn(column, newTitle)} + dismiss={() => context.setColumnNameToEdit?.(undefined)} + /> + )} +

+ + {(columnItems ?? []).map(({ note, branch }, index) => { + const showIndicatorBefore = dropPosition?.column === column && + dropPosition.index === index && + draggedCard?.noteId !== note.noteId; + + return ( + <> + {showIndicatorBefore && ( +
+ )} + + + ); + })} + {dropPosition?.column === column && dropPosition.index === (columnItems?.length ?? 0) && ( +
+ )} + +
api.createNewItem(column)}> + {" "} + {t("board_view.new-item")} +
+
+ ) +} + +function useDragging({ api, column, columnIndex, draggedColumn, setDraggedColumn, setDropTarget, setDropPosition, onCardDrop, draggedCard, dropPosition, setDraggedCard, columnItems }: DragContext) { const handleColumnDragStart = useCallback((e: DragEvent) => { e.dataTransfer!.effectAllowed = 'move'; e.dataTransfer!.setData('text/plain', column); @@ -132,82 +212,7 @@ export default function Column({ onCardDrop(); } setDraggedCard(null); - }, [draggedCard, draggedColumn, dropPosition, columnItems, column, statusAttribute, setDraggedCard, setDropTarget, setDropPosition, onCardDrop]); + }, [draggedCard, draggedColumn, dropPosition, columnItems, column, setDraggedCard, setDropTarget, setDropPosition, onCardDrop]); - const handleEdit = useCallback(() => { - context.setColumnNameToEdit?.(column); - }, [column]); - - const handleContextMenu = useCallback((e: ContextMenuEvent) => { - openColumnContextMenu(api, e, column); - }, [ api, column ]); - - useEffect(() => { - editorRef.current?.focus(); - }, [ isEditing ]); - - return ( -
-

- {!isEditing ? ( - <> - {column} - - - ) : ( - api.renameColumn(column, newTitle)} - dismiss={() => context.setColumnNameToEdit?.(undefined)} - /> - )} -

- - {(columnItems ?? []).map(({ note, branch }, index) => { - const showIndicatorBefore = dropPosition?.column === column && - dropPosition.index === index && - draggedCard?.noteId !== note.noteId; - - return ( - <> - {showIndicatorBefore && ( -
- )} - - - ); - })} - {dropPosition?.column === column && dropPosition.index === (columnItems?.length ?? 0) && ( -
- )} - -
api.createNewItem(column)}> - {" "} - {t("board_view.new-item")} -
-
- ) + return { handleColumnDragStart, handleColumnDragEnd, handleDragOver, handleDragLeave, handleDrop }; } diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index ea2badb3cb..f4ae8769c3 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -159,7 +159,6 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC column={column} columnIndex={index} columnItems={byColumn.get(column)} - statusAttribute={statusAttribute ?? "status"} draggedCard={draggedCard} setDraggedCard={setDraggedCard} dropTarget={dropTarget} From 8611328a03c5fd1359064ccd02bd305e6c107f30 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 14:21:10 +0300 Subject: [PATCH 154/233] chore(collections/board): reordering notes not refreshing properly --- apps/client/src/widgets/collections/board/card.tsx | 4 ++++ apps/client/src/widgets/collections/board/column.tsx | 1 + 2 files changed, 5 insertions(+) diff --git a/apps/client/src/widgets/collections/board/card.tsx b/apps/client/src/widgets/collections/board/card.tsx index 6d684ad3c0..ca880f77a2 100644 --- a/apps/client/src/widgets/collections/board/card.tsx +++ b/apps/client/src/widgets/collections/board/card.tsx @@ -52,6 +52,10 @@ export default function Card({ editorRef.current?.focus(); }, [ isEditing ]); + useEffect(() => { + setTitle(note.title); + }, [ note ]); + return (
)} Date: Fri, 12 Sep 2025 14:31:59 +0300 Subject: [PATCH 155/233] chore(collections/board): clean up old code --- .../src/widgets/collections/board/index.css | 10 - .../board_view/differential_renderer.ts | 383 ------------------ .../view_widgets/board_view/drag_handler.ts | 45 -- .../view_widgets/board_view/drag_types.ts | 11 - .../board_view/note_drag_handler.ts | 322 --------------- 5 files changed, 771 deletions(-) delete mode 100644 apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts delete mode 100644 apps/client/src/widgets/view_widgets/board_view/drag_handler.ts delete mode 100644 apps/client/src/widgets/view_widgets/board_view/drag_types.ts delete mode 100644 apps/client/src/widgets/view_widgets/board_view/note_drag_handler.ts diff --git a/apps/client/src/widgets/collections/board/index.css b/apps/client/src/widgets/collections/board/index.css index 1b03a42733..81d0ca1e52 100644 --- a/apps/client/src/widgets/collections/board/index.css +++ b/apps/client/src/widgets/collections/board/index.css @@ -140,16 +140,6 @@ to { opacity: 0; transform: translateY(-10px); } } -.board-view-container .board-note.card-updated { - animation: cardUpdate 0.3s ease-in-out; -} - -@keyframes cardUpdate { - 0% { transform: scale(1); } - 50% { transform: scale(1.02); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); } - 100% { transform: scale(1); } -} - .board-view-container .board-note:hover { transform: translateY(-2px); box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.35); diff --git a/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts b/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts deleted file mode 100644 index 3641fc50dd..0000000000 --- a/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts +++ /dev/null @@ -1,383 +0,0 @@ -import { BoardDragHandler } from "./drag_handler"; -import BoardApi from "./api"; -import appContext from "../../../components/app_context"; -import FNote from "../../../entities/fnote"; -import ViewModeStorage from "../view_mode_storage"; -import { BoardData } from "./config"; -import { t } from "../../../services/i18n.js"; - -export interface BoardState { - columns: { [key: string]: { note: any; branch: any }[] }; - columnOrder: string[]; -} - -export class DifferentialBoardRenderer { - private $container: JQuery; - private api: BoardApi; - private dragHandler: BoardDragHandler; - private lastState: BoardState | null = null; - private onCreateNewItem: (column: string) => void; - private updateTimeout: number | null = null; - private pendingUpdate = false; - private parentNote: FNote; - private viewStorage: ViewModeStorage; - private onRefreshApi: () => Promise; - - constructor( - $container: JQuery, - api: BoardApi, - dragHandler: BoardDragHandler, - onCreateNewItem: (column: string) => void, - parentNote: FNote, - viewStorage: ViewModeStorage, - onRefreshApi: () => Promise - ) { - this.$container = $container; - this.api = api; - this.dragHandler = dragHandler; - this.onCreateNewItem = onCreateNewItem; - this.parentNote = parentNote; - this.viewStorage = viewStorage; - this.onRefreshApi = onRefreshApi; - } - - async renderBoard(refreshApi = false): Promise { - // Refresh API data if requested - if (refreshApi) { - await this.onRefreshApi(); - } - - // Debounce rapid updates - if (this.updateTimeout) { - clearTimeout(this.updateTimeout); - } - - this.updateTimeout = window.setTimeout(async () => { - await this.performUpdate(); - this.updateTimeout = null; - }, 16); // ~60fps - } - - private async performUpdate(): Promise { - // Clean up any stray drag indicators before updating - this.dragHandler.cleanup(); - - const currentState = this.getCurrentState(); - - if (!this.lastState) { - // First render - do full render - await this.fullRender(currentState); - } else { - // Differential render - only update what changed - await this.differentialRender(this.lastState, currentState); - } - - this.lastState = currentState; - } - - private getCurrentState(): BoardState { - const columns: { [key: string]: { note: any; branch: any }[] } = {}; - const columnOrder: string[] = []; - - for (const column of this.api.columns) { - columnOrder.push(column); - columns[column] = this.api.getColumn(column) || []; - } - - return { columns, columnOrder }; - } - - private async fullRender(state: BoardState): Promise { - this.$container.empty(); - - for (const column of state.columnOrder) { - const columnItems = state.columns[column]; - const $columnEl = this.createColumn(column, columnItems); - this.$container.append($columnEl); - } - } - - private async differentialRender(oldState: BoardState, newState: BoardState): Promise { - // Store scroll positions before making changes - const scrollPositions = this.saveScrollPositions(); - - // Handle column additions/removals - this.updateColumns(oldState, newState); - - // Handle card updates within existing columns - for (const column of newState.columnOrder) { - this.updateColumnCards(column, oldState.columns[column] || [], newState.columns[column]); - } - - // Restore scroll positions - this.restoreScrollPositions(scrollPositions); - } - - private saveScrollPositions(): { [column: string]: number } { - const positions: { [column: string]: number } = {}; - this.$container.find('.board-column').each((_, el) => { - const column = $(el).attr('data-column'); - if (column) { - positions[column] = el.scrollTop; - } - }); - return positions; - } - - private restoreScrollPositions(positions: { [column: string]: number }): void { - this.$container.find('.board-column').each((_, el) => { - const column = $(el).attr('data-column'); - if (column && positions[column] !== undefined) { - el.scrollTop = positions[column]; - } - }); - } - - private updateColumns(oldState: BoardState, newState: BoardState): void { - // Check if column order has changed - const orderChanged = !this.arraysEqual(oldState.columnOrder, newState.columnOrder); - - if (orderChanged) { - // If order changed, we need to reorder the columns in the DOM - this.reorderColumns(newState.columnOrder); - } - - // Remove columns that no longer exist - for (const oldColumn of oldState.columnOrder) { - if (!newState.columnOrder.includes(oldColumn)) { - this.$container.find(`[data-column="${oldColumn}"]`).remove(); - } - } - - // Add new columns - for (const newColumn of newState.columnOrder) { - if (!oldState.columnOrder.includes(newColumn)) { - const columnItems = newState.columns[newColumn]; - const $columnEl = this.createColumn(newColumn, columnItems); - - // Insert at correct position - const insertIndex = newState.columnOrder.indexOf(newColumn); - const $existingColumns = this.$container.find('.board-column'); - - if (insertIndex === 0) { - this.$container.prepend($columnEl); - } else if (insertIndex >= $existingColumns.length) { - this.$container.find('.board-add-column').before($columnEl); - } else { - $($existingColumns[insertIndex - 1]).after($columnEl); - } - } - } - } - - private arraysEqual(a: string[], b: string[]): boolean { - return a.length === b.length && a.every((val, index) => val === b[index]); - } - - private reorderColumns(newOrder: string[]): void { - // Get all existing column elements - const $columns = this.$container.find('.board-column'); - const $addColumnButton = this.$container.find('.board-add-column'); - - // Create a map of column elements by their data-column attribute - const columnElements = new Map>(); - $columns.each((_, el) => { - const $el = $(el); - const columnValue = $el.attr('data-column'); - if (columnValue) { - columnElements.set(columnValue, $el); - } - }); - - // Remove all columns from DOM (but keep references) - $columns.detach(); - - // Re-insert columns in the new order - let $insertAfter: JQuery | null = null; - for (const columnValue of newOrder) { - const $columnEl = columnElements.get(columnValue); - if ($columnEl) { - if ($insertAfter) { - $insertAfter.after($columnEl); - } else { - // Insert at the beginning - this.$container.prepend($columnEl); - } - $insertAfter = $columnEl; - } - } - - // Ensure add column button is at the end - if ($addColumnButton.length) { - this.$container.append($addColumnButton); - } - } - - private updateColumnCards(column: string, oldCards: { note: any; branch: any }[], newCards: { note: any; branch: any }[]): void { - const $column = this.$container.find(`[data-column="${column}"]`); - if (!$column.length) return; - - const $cardContainer = $column; - const oldCardIds = oldCards.map(item => item.note.noteId); - const newCardIds = newCards.map(item => item.note.noteId); - - // Remove cards that no longer exist - $cardContainer.find('.board-note').each((_, el) => { - const noteId = $(el).attr('data-note-id'); - if (noteId && !newCardIds.includes(noteId)) { - $(el).addClass('fade-out'); - setTimeout(() => $(el).remove(), 150); - } - }); - - // Add or update cards - for (let i = 0; i < newCards.length; i++) { - const item = newCards[i]; - const noteId = item.note.noteId; - const $existingCard = $cardContainer.find(`[data-note-id="${noteId}"]`); - const isNewCard = !oldCardIds.includes(noteId); - - if ($existingCard.length) { - // Check for changes in title, icon, or color - const currentTitle = $existingCard.text().trim(); - const currentIconClass = $existingCard.attr('data-icon-class'); - const currentColorClass = $existingCard.attr('data-color-class') || ''; - - const newIconClass = item.note.getIcon(); - const newColorClass = item.note.getColorClass() || ''; - - let hasChanges = false; - - // Update title if changed - if (currentTitle !== item.note.title) { - $existingCard.contents().filter(function() { - return this.nodeType === 3; // Text nodes - }).remove(); - $existingCard.append(document.createTextNode(item.note.title)); - hasChanges = true; - } - - // Update icon if changed - if (currentIconClass !== newIconClass) { - const $icon = $existingCard.find('.icon'); - $icon.removeClass().addClass('icon').addClass(newIconClass); - $existingCard.attr('data-icon-class', newIconClass); - hasChanges = true; - } - - // Update color if changed - if (currentColorClass !== newColorClass) { - // Remove old color class if it exists - if (currentColorClass) { - $existingCard.removeClass(currentColorClass); - } - // Add new color class if it exists - if (newColorClass) { - $existingCard.addClass(newColorClass); - } - $existingCard.attr('data-color-class', newColorClass); - hasChanges = true; - } - - // Add subtle animation if there were changes - if (hasChanges) { - $existingCard.addClass('card-updated'); - setTimeout(() => $existingCard.removeClass('card-updated'), 300); - } - - // Ensure card is in correct position - this.ensureCardPosition($existingCard, i, $cardContainer); - } else { - // Create new card - const $newCard = this.createCard(item.note, item.branch, column); - $newCard.addClass('fade-in').css('opacity', '0'); - - // Insert at correct position - if (i === 0) { - $cardContainer.find('h3').after($newCard); - } else { - const $prevCard = $cardContainer.find('.board-note').eq(i - 1); - if ($prevCard.length) { - $prevCard.after($newCard); - } else { - $cardContainer.find('.board-new-item').before($newCard); - } - } - - // Trigger fade in animation - setTimeout(() => $newCard.css('opacity', '1'), 10); - } - } - } - - private ensureCardPosition($card: JQuery, targetIndex: number, $container: JQuery): void { - const $allCards = $container.find('.board-note'); - const currentIndex = $allCards.index($card); - - if (currentIndex !== targetIndex) { - if (targetIndex === 0) { - $container.find('h3').after($card); - } else { - const $targetPrev = $allCards.eq(targetIndex - 1); - if ($targetPrev.length) { - $targetPrev.after($card); - } - } - } - } - - private createColumn(column: string, columnItems: { note: any; branch: any }[]): JQuery { - // Setup column dragging - this.dragHandler.setupColumnDrag($columnEl, column); - - // Handle wheel events for scrolling - $columnEl.on("wheel", (event) => { - const el = $columnEl[0]; - const needsScroll = el.scrollHeight > el.clientHeight; - if (needsScroll) { - event.stopPropagation(); - } - }); - - // Setup drop zones for both notes and columns - this.dragHandler.setupNoteDropZone($columnEl, column); - this.dragHandler.setupColumnDropZone($columnEl); - - // Add "New item" button - const $newItemEl = $("
") - .addClass("board-new-item") - .attr("data-column", column) - .html(` ${}`); - - $columnEl.append($newItemEl); - - return $columnEl; - } - - private createCard(note: any, branch: any, column: string): JQuery { - $noteEl.prepend($iconEl); - $noteEl.on("click", () => appContext.triggerCommand("openInPopup", { noteIdOrPath: note.noteId })); - - // Setup drag functionality - this.dragHandler.setupNoteDrag($noteEl, note, branch); - - return $noteEl; - } - - forceFullRender(): void { - this.lastState = null; - if (this.updateTimeout) { - clearTimeout(this.updateTimeout); - this.updateTimeout = null; - } - } - - async flushPendingUpdates(): Promise { - if (this.updateTimeout) { - clearTimeout(this.updateTimeout); - this.updateTimeout = null; - await this.performUpdate(); - } - } - -} diff --git a/apps/client/src/widgets/view_widgets/board_view/drag_handler.ts b/apps/client/src/widgets/view_widgets/board_view/drag_handler.ts deleted file mode 100644 index 11be8f9f23..0000000000 --- a/apps/client/src/widgets/view_widgets/board_view/drag_handler.ts +++ /dev/null @@ -1,45 +0,0 @@ -import BoardApi from "./api"; -import { DragContext } from "./drag_types"; -import { NoteDragHandler } from "./note_drag_handler"; -import { ColumnDragHandler } from "./column_drag_handler"; - -export class BoardDragHandler { - private noteDragHandler: NoteDragHandler; - private columnDragHandler: ColumnDragHandler; - - constructor( - $container: JQuery, - api: BoardApi, - context: DragContext, - ) { - // Initialize specialized drag handlers - this.noteDragHandler = new NoteDragHandler($container, api, context); - this.columnDragHandler = new ColumnDragHandler($container, api, context); - } - - // Note drag methods - delegate to NoteDragHandler - setupNoteDrag($noteEl: JQuery, note: any, branch: any) { - this.noteDragHandler.setupNoteDrag($noteEl, note, branch); - } - - setupNoteDropZone($columnEl: JQuery, column: string) { - this.noteDragHandler.setupNoteDropZone($columnEl, column); - } - - // Column drag methods - delegate to ColumnDragHandler - setupColumnDrag($columnEl: JQuery, columnValue: string) { - this.columnDragHandler.setupColumnDrag($columnEl, columnValue); - } - - setupColumnDropZone($columnEl: JQuery) { - this.columnDragHandler.setupColumnDropZone($columnEl); - } - - cleanup() { - this.noteDragHandler.cleanup(); - this.columnDragHandler.cleanup(); - } -} - -// Export the drag context type for external use -export type { DragContext } from "./drag_types"; diff --git a/apps/client/src/widgets/view_widgets/board_view/drag_types.ts b/apps/client/src/widgets/view_widgets/board_view/drag_types.ts deleted file mode 100644 index 3957ee2e90..0000000000 --- a/apps/client/src/widgets/view_widgets/board_view/drag_types.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface DragContext { - draggedNote: any; - draggedBranch: any; - draggedNoteElement: JQuery | null; - draggedColumn: string | null; - draggedColumnElement: JQuery | null; -} - -export interface BaseDragHandler { - cleanup(): void; -} diff --git a/apps/client/src/widgets/view_widgets/board_view/note_drag_handler.ts b/apps/client/src/widgets/view_widgets/board_view/note_drag_handler.ts deleted file mode 100644 index ef08d79004..0000000000 --- a/apps/client/src/widgets/view_widgets/board_view/note_drag_handler.ts +++ /dev/null @@ -1,322 +0,0 @@ -import branchService from "../../../services/branches"; -import BoardApi from "./api"; -import { DragContext, BaseDragHandler } from "./drag_types"; - -export class NoteDragHandler implements BaseDragHandler { - private $container: JQuery; - private api: BoardApi; - private context: DragContext; - - constructor( - $container: JQuery, - api: BoardApi, - context: DragContext, - ) { - this.$container = $container; - this.api = api; - this.context = context; - } - - setupNoteDrag($noteEl: JQuery, note: any, branch: any) { - $noteEl.attr("draggable", "true"); - - // Mouse drag events - this.setupMouseDrag($noteEl, note, branch); - - // Touch drag events - this.setupTouchDrag($noteEl, note, branch); - } - - setupNoteDropZone($columnEl: JQuery, column: string) { - $columnEl.on("dragover", (e) => { - // Only handle note drops when a note is being dragged - if (this.context.draggedNote && !this.context.draggedColumn) { - e.preventDefault(); - const originalEvent = e.originalEvent as DragEvent; - if (originalEvent.dataTransfer) { - originalEvent.dataTransfer.dropEffect = "move"; - } - - $columnEl.addClass("drag-over"); - this.showDropIndicator($columnEl, e); - } - }); - - $columnEl.on("dragleave", (e) => { - // Only remove drag-over if we're leaving the column entirely - const rect = $columnEl[0].getBoundingClientRect(); - const originalEvent = e.originalEvent as DragEvent; - const x = originalEvent.clientX; - const y = originalEvent.clientY; - - if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { - $columnEl.removeClass("drag-over"); - this.cleanupNoteDropIndicators($columnEl); - } - }); - - $columnEl.on("drop", async (e) => { - if (this.context.draggedNote && !this.context.draggedColumn) { - e.preventDefault(); - $columnEl.removeClass("drag-over"); - - if (this.context.draggedNote && this.context.draggedNoteElement && this.context.draggedBranch) { - await this.handleNoteDrop($columnEl, column); - } - } - }); - } - - cleanup() { - this.cleanupAllDropIndicators(); - this.$container.find('.board-column').removeClass('drag-over'); - } - - private setupMouseDrag($noteEl: JQuery, note: any, branch: any) { - $noteEl.on("dragstart", (e) => { - this.context.draggedNote = note; - this.context.draggedBranch = branch; - this.context.draggedNoteElement = $noteEl; - $noteEl.addClass("dragging"); - - // Set drag data - const originalEvent = e.originalEvent as DragEvent; - if (originalEvent.dataTransfer) { - originalEvent.dataTransfer.effectAllowed = "move"; - originalEvent.dataTransfer.setData("text/plain", note.noteId); - } - }); - - $noteEl.on("dragend", () => { - $noteEl.removeClass("dragging"); - this.context.draggedNote = null; - this.context.draggedBranch = null; - this.context.draggedNoteElement = null; - - // Clean up all drop indicators properly - this.cleanupAllDropIndicators(); - }); - } - - private setupTouchDrag($noteEl: JQuery, note: any, branch: any) { - let isDragging = false; - let startY = 0; - let startX = 0; - let dragThreshold = 10; // Minimum distance to start dragging - let $dragPreview: JQuery | null = null; - - $noteEl.on("touchstart", (e) => { - const touch = (e.originalEvent as TouchEvent).touches[0]; - startX = touch.clientX; - startY = touch.clientY; - isDragging = false; - $dragPreview = null; - }); - - $noteEl.on("touchmove", (e) => { - e.preventDefault(); // Prevent scrolling - const touch = (e.originalEvent as TouchEvent).touches[0]; - const deltaX = Math.abs(touch.clientX - startX); - const deltaY = Math.abs(touch.clientY - startY); - - // Start dragging if we've moved beyond threshold - if (!isDragging && (deltaX > dragThreshold || deltaY > dragThreshold)) { - isDragging = true; - this.context.draggedNote = note; - this.context.draggedBranch = branch; - this.context.draggedNoteElement = $noteEl; - $noteEl.addClass("dragging"); - - // Create drag preview - $dragPreview = this.createDragPreview($noteEl, touch.clientX, touch.clientY); - } - - if (isDragging && $dragPreview) { - // Update drag preview position - $dragPreview.css({ - left: touch.clientX - ($dragPreview.outerWidth() || 0) / 2, - top: touch.clientY - ($dragPreview.outerHeight() || 0) / 2 - }); - - // Find element under touch point - const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY); - if (elementBelow) { - const $columnEl = $(elementBelow).closest('.board-column'); - - if ($columnEl.length > 0) { - // Remove drag-over from all columns - this.$container.find('.board-column').removeClass('drag-over'); - $columnEl.addClass('drag-over'); - - // Show drop indicator - this.showDropIndicatorAtPoint($columnEl, touch.clientY); - } else { - // Remove all drag indicators if not over a column - this.$container.find('.board-column').removeClass('drag-over'); - this.cleanupAllDropIndicators(); - } - } - } - }); - - $noteEl.on("touchend", async (e) => { - if (isDragging) { - const touch = (e.originalEvent as TouchEvent).changedTouches[0]; - const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY); - if (elementBelow) { - const $columnEl = $(elementBelow).closest('.board-column'); - - if ($columnEl.length > 0) { - const column = $columnEl.attr('data-column'); - if (column && this.context.draggedNote && this.context.draggedNoteElement && this.context.draggedBranch) { - await this.handleNoteDrop($columnEl, column); - } - } - } - - // Clean up - $noteEl.removeClass("dragging"); - this.context.draggedNote = null; - this.context.draggedBranch = null; - this.context.draggedNoteElement = null; - this.$container.find('.board-column').removeClass('drag-over'); - this.cleanupAllDropIndicators(); - - // Remove drag preview - if ($dragPreview) { - $dragPreview.remove(); - $dragPreview = null; - } - } - isDragging = false; - }); - } - - private createDragPreview($noteEl: JQuery, x: number, y: number): JQuery { - // Clone the note element for the preview - const $preview = $noteEl.clone(); - - $preview - .addClass('board-drag-preview') - .css({ - position: 'fixed', - left: x - ($noteEl.outerWidth() || 0) / 2, - top: y - ($noteEl.outerHeight() || 0) / 2, - pointerEvents: 'none', - zIndex: 10000 - }) - .appendTo('body'); - - return $preview; - } - - private showDropIndicator($columnEl: JQuery, e: JQuery.DragOverEvent) { - const originalEvent = e.originalEvent as DragEvent; - const mouseY = originalEvent.clientY; - this.showDropIndicatorAtY($columnEl, mouseY); - } - - private showDropIndicatorAtPoint($columnEl: JQuery, touchY: number) { - this.showDropIndicatorAtY($columnEl, touchY); - } - - private showDropIndicatorAtY($columnEl: JQuery, y: number) { - const columnRect = $columnEl[0].getBoundingClientRect(); - const relativeY = y - columnRect.top; - - // Clean up any existing drop indicators in this column first - this.cleanupNoteDropIndicators($columnEl); - - // Create a new drop indicator - const $dropIndicator = $("
").addClass("board-drop-indicator"); - - // Find the best position to insert the note - const $notes = this.context.draggedNoteElement ? - $columnEl.find(".board-note").not(this.context.draggedNoteElement) : - $columnEl.find(".board-note"); - let insertAfterElement: HTMLElement | null = null; - - $notes.each((_, noteEl) => { - const noteRect = noteEl.getBoundingClientRect(); - const noteMiddle = noteRect.top + noteRect.height / 2 - columnRect.top; - - if (relativeY > noteMiddle) { - insertAfterElement = noteEl; - } - }); - - // Position the drop indicator - if (insertAfterElement) { - $(insertAfterElement).after($dropIndicator); - } else { - // Insert at the beginning (after the header) - const $header = $columnEl.find("h3"); - $header.after($dropIndicator); - } - - $dropIndicator.addClass("show"); - } - - private async handleNoteDrop($columnEl: JQuery, column: string) { - const draggedNoteElement = this.context.draggedNoteElement; - const draggedNote = this.context.draggedNote; - const draggedBranch = this.context.draggedBranch; - - if (draggedNote && draggedNoteElement && draggedBranch) { - const currentColumn = draggedNoteElement.attr("data-current-column"); - - // Capture drop indicator position BEFORE removing it - const dropIndicator = $columnEl.find(".board-drop-indicator.show"); - let targetBranchId: string | null = null; - let moveType: "before" | "after" | null = null; - - if (dropIndicator.length > 0) { - // Find the note element that the drop indicator is positioned relative to - const nextNote = dropIndicator.next(".board-note"); - const prevNote = dropIndicator.prev(".board-note"); - - if (nextNote.length > 0) { - targetBranchId = nextNote.attr("data-branch-id") || null; - moveType = "before"; - } else if (prevNote.length > 0) { - targetBranchId = prevNote.attr("data-branch-id") || null; - moveType = "after"; - } - } - - try { - // Handle column change - if (currentColumn !== column) { - await this.api.changeColumn(draggedNote.noteId, column); - } - - // Handle position change (works for both same column and different column moves) - if (targetBranchId && moveType) { - if (moveType === "before") { - await branchService.moveBeforeBranch([draggedBranch.branchId], targetBranchId); - } else if (moveType === "after") { - await branchService.moveAfterBranch([draggedBranch.branchId], targetBranchId); - } - } - - // Update the data attributes - draggedNoteElement.attr("data-current-column", column); - } catch (error) { - console.error("Failed to update note position:", error); - } finally { - // Always clean up drop indicators after drop operation - this.cleanupAllDropIndicators(); - } - } - } - - private cleanupAllDropIndicators() { - // Remove all drop indicators from the DOM to prevent layout issues - this.$container.find(".board-drop-indicator").remove(); - } - - private cleanupNoteDropIndicators($columnEl: JQuery) { - // Remove note drop indicators from a specific column - $columnEl.find(".board-drop-indicator").remove(); - } -} From 2972a23f19c1ae3fe5936a45cebf55128c74d35d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 14:48:05 +0300 Subject: [PATCH 156/233] chore(collections/board): use context for column dragging --- .../src/widgets/collections/board/column.tsx | 42 +++++++------------ .../src/widgets/collections/board/index.tsx | 31 ++++++++------ 2 files changed, 32 insertions(+), 41 deletions(-) diff --git a/apps/client/src/widgets/collections/board/column.tsx b/apps/client/src/widgets/collections/board/column.tsx index 93b14d94ff..d30a6e340a 100644 --- a/apps/client/src/widgets/collections/board/column.tsx +++ b/apps/client/src/widgets/collections/board/column.tsx @@ -11,45 +11,31 @@ import BoardApi from "./api"; import Card from "./card"; interface DragContext { - api: BoardApi; column: string; - draggedColumn: { column: string, index: number } | null; - setDraggedColumn: (column: { column: string, index: number } | null) => void; columnIndex: number, - setDropTarget: (target: string | null) => void, - setDropPosition: (position: { column: string, index: number } | null) => void; - onCardDrop: () => void; - dropPosition: { column: string, index: number } | null; - draggedCard: { noteId: string, branchId: string, fromColumn: string, index: number } | null; - setDraggedCard: (card: { noteId: string, branchId: string, fromColumn: string, index: number } | null) => void; - columnItems?: { note: FNote, branch: FBranch }[], + columnItems?: { note: FNote, branch: FBranch }[] } export default function Column({ column, - columnItems, - draggedCard, - setDraggedCard, - dropTarget, - dropPosition, + columnIndex, isDraggingColumn, + columnItems, api, - ...restProps }: { - column: string, - dropTarget: string | null, + columnItems?: { note: FNote, branch: FBranch }[]; isDraggingColumn: boolean, api: BoardApi } & DragContext) { - const context = useContext(BoardViewContext); - const isEditing = (context.columnNameToEdit === column); + const { columnNameToEdit, setColumnNameToEdit, dropTarget, draggedCard, dropPosition, setDraggedCard} = useContext(BoardViewContext); + const isEditing = (columnNameToEdit === column); const editorRef = useRef(null); const { handleColumnDragStart, handleColumnDragEnd, handleDragOver, handleDragLeave, handleDrop } = useDragging({ - api, column, dropPosition, draggedCard, setDraggedCard, columnItems, ...restProps + column, columnIndex }); const handleEdit = useCallback(() => { - context.setColumnNameToEdit?.(column); + setColumnNameToEdit?.(column); }, [column]); const handleContextMenu = useCallback((e: ContextMenuEvent) => { @@ -87,7 +73,7 @@ export default function Column({ api.renameColumn(column, newTitle)} - dismiss={() => context.setColumnNameToEdit?.(undefined)} + dismiss={() => setColumnNameToEdit?.(undefined)} /> )}

@@ -127,7 +113,9 @@ export default function Column({ ) } -function useDragging({ api, column, columnIndex, draggedColumn, setDraggedColumn, setDropTarget, setDropPosition, onCardDrop, draggedCard, dropPosition, setDraggedCard, columnItems }: DragContext) { +function useDragging({ column, columnIndex, columnItems }: DragContext) { + const { api, draggedColumn, setDraggedColumn, setDropTarget, setDropPosition, draggedCard, dropPosition, setDraggedCard } = useContext(BoardViewContext); + const handleColumnDragStart = useCallback((e: DragEvent) => { e.dataTransfer!.effectAllowed = 'move'; e.dataTransfer!.setData('text/plain', column); @@ -185,7 +173,7 @@ function useDragging({ api, column, columnIndex, draggedColumn, setDraggedColumn if (draggedCard.fromColumn !== column) { // Moving to a different column - await api.changeColumn(draggedCard.noteId, column); + await api?.changeColumn(draggedCard.noteId, column); // If there are items in the target column, reorder if (targetItems.length > 0 && targetIndex < targetItems.length) { @@ -209,11 +197,9 @@ function useDragging({ api, column, columnIndex, draggedColumn, setDraggedColumn await branches.moveAfterBranch([ draggedCard.branchId ], lastItem.branch.branchId); } } - - onCardDrop(); } setDraggedCard(null); - }, [draggedCard, draggedColumn, dropPosition, columnItems, column, setDraggedCard, setDropTarget, setDropPosition, onCardDrop]); + }, [draggedCard, draggedColumn, dropPosition, columnItems, column, setDraggedCard, setDropTarget, setDropPosition]); return { handleColumnDragStart, handleColumnDragEnd, handleDragOver, handleDragLeave, handleDrop }; } diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index f4ae8769c3..e963d00fbf 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -10,6 +10,9 @@ import FormTextBox from "../../react/FormTextBox"; import { createContext } from "preact"; import { onWheelHorizontalScroll } from "../../widget_utils"; import Column from "./column"; +import BoardApi from "./api"; +import FBranch from "../../../entities/fbranch"; +import FNote from "../../../entities/fnote"; export interface BoardViewData { columns?: BoardColumnData[]; @@ -20,10 +23,19 @@ export interface BoardColumnData { } interface BoardViewContextData { + api?: BoardApi; branchIdToEdit?: string; columnNameToEdit?: string; setColumnNameToEdit?: Dispatch>; setBranchIdToEdit?: Dispatch>; + 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; + draggedCard: { noteId: string, branchId: string, fromColumn: string, index: number } | null; + setDraggedCard: (card: { noteId: string, branchId: string, fromColumn: string, index: number } | null) => void; + setDropTarget: (target: string | null) => void, + dropTarget: string | null } export const BoardViewContext = createContext({}); @@ -43,10 +55,12 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC return new Api(byColumn, columns ?? [], parentNote, statusAttribute, viewConfig ?? {}, saveConfig, setBranchIdToEdit ); }, [ byColumn, columns, parentNote, statusAttribute, viewConfig, saveConfig, setBranchIdToEdit ]); const boardViewContext = useMemo(() => ({ - branchIdToEdit, - columnNameToEdit, - setColumnNameToEdit, - setBranchIdToEdit + branchIdToEdit, setBranchIdToEdit, + columnNameToEdit, setColumnNameToEdit, + draggedColumn, setDraggedColumn, + dropPosition, setDropPosition, + draggedCard, setDraggedCard, + dropTarget, setDropTarget }), [ branchIdToEdit, columnNameToEdit, setColumnNameToEdit, setBranchIdToEdit ]); function refresh() { @@ -159,15 +173,6 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC column={column} columnIndex={index} columnItems={byColumn.get(column)} - draggedCard={draggedCard} - setDraggedCard={setDraggedCard} - dropTarget={dropTarget} - setDropTarget={setDropTarget} - dropPosition={dropPosition} - setDropPosition={setDropPosition} - onCardDrop={refresh} - draggedColumn={draggedColumn} - setDraggedColumn={setDraggedColumn} isDraggingColumn={draggedColumn?.column === column} /> From 95a392ccfa446f0b761ca6f173a6981496c8747b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 15:08:00 +0300 Subject: [PATCH 157/233] chore(collections/board): fix dragging notes not working --- apps/client/src/widgets/collections/board/card.tsx | 4 +--- apps/client/src/widgets/collections/board/column.tsx | 5 ++--- apps/client/src/widgets/collections/board/index.tsx | 8 +++++++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/client/src/widgets/collections/board/card.tsx b/apps/client/src/widgets/collections/board/card.tsx index ca880f77a2..f301a2855d 100644 --- a/apps/client/src/widgets/collections/board/card.tsx +++ b/apps/client/src/widgets/collections/board/card.tsx @@ -13,7 +13,6 @@ export default function Card({ branch, column, index, - setDraggedCard, isDragging }: { api: BoardApi, @@ -21,10 +20,9 @@ export default function Card({ branch: FBranch, column: string, index: number, - setDraggedCard: (card: { noteId: string, branchId: string, fromColumn: string, index: number } | null) => void, isDragging: boolean }) { - const { branchIdToEdit, setBranchIdToEdit } = useContext(BoardViewContext); + const { branchIdToEdit, setBranchIdToEdit, setDraggedCard } = useContext(BoardViewContext); const isEditing = branch.branchId === branchIdToEdit; const colorClass = note.getColorClass() || ''; const editorRef = useRef(null); diff --git a/apps/client/src/widgets/collections/board/column.tsx b/apps/client/src/widgets/collections/board/column.tsx index d30a6e340a..5a2ed3ad2c 100644 --- a/apps/client/src/widgets/collections/board/column.tsx +++ b/apps/client/src/widgets/collections/board/column.tsx @@ -27,11 +27,11 @@ export default function Column({ isDraggingColumn: boolean, api: BoardApi } & DragContext) { - const { columnNameToEdit, setColumnNameToEdit, dropTarget, draggedCard, dropPosition, setDraggedCard} = useContext(BoardViewContext); + const { columnNameToEdit, setColumnNameToEdit, dropTarget, draggedCard, dropPosition } = useContext(BoardViewContext); const isEditing = (columnNameToEdit === column); const editorRef = useRef(null); const { handleColumnDragStart, handleColumnDragEnd, handleDragOver, handleDragLeave, handleDrop } = useDragging({ - column, columnIndex + column, columnIndex, columnItems }); const handleEdit = useCallback(() => { @@ -95,7 +95,6 @@ export default function Column({ branch={branch} column={column} index={index} - setDraggedCard={setDraggedCard} isDragging={draggedCard?.noteId === note.noteId} /> diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index e963d00fbf..520817fbd2 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -61,7 +61,13 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC dropPosition, setDropPosition, draggedCard, setDraggedCard, dropTarget, setDropTarget - }), [ branchIdToEdit, columnNameToEdit, setColumnNameToEdit, setBranchIdToEdit ]); + }), [ branchIdToEdit, setBranchIdToEdit, + columnNameToEdit, setColumnNameToEdit, + draggedColumn, setDraggedColumn, + dropPosition, setDropPosition, + draggedCard, setDraggedCard, + dropTarget, setDropTarget + ]); function refresh() { getBoardData(parentNote, statusAttribute, viewConfig ?? {}).then(({ byColumn, newPersistedData }) => { From c8f9d6e6df75858ccf8d0467068c81c21dc75970 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 15:10:20 +0300 Subject: [PATCH 158/233] chore(collections/board): fix dragging notes across columns --- apps/client/src/widgets/collections/board/column.tsx | 2 +- apps/client/src/widgets/collections/board/index.tsx | 7 ++++--- apps/client/src/widgets/view_widgets/board_view/index.ts | 3 --- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/client/src/widgets/collections/board/column.tsx b/apps/client/src/widgets/collections/board/column.tsx index 5a2ed3ad2c..5d859d9420 100644 --- a/apps/client/src/widgets/collections/board/column.tsx +++ b/apps/client/src/widgets/collections/board/column.tsx @@ -198,7 +198,7 @@ function useDragging({ column, columnIndex, columnItems }: DragContext) { } } setDraggedCard(null); - }, [draggedCard, draggedColumn, dropPosition, columnItems, column, setDraggedCard, setDropTarget, setDropPosition]); + }, [ api, draggedCard, draggedColumn, dropPosition, columnItems, column, setDraggedCard, setDropTarget, setDropPosition ]); return { handleColumnDragStart, handleColumnDragEnd, handleDragOver, handleDragLeave, handleDrop }; } diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 520817fbd2..886719e004 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -11,8 +11,6 @@ import { createContext } from "preact"; import { onWheelHorizontalScroll } from "../../widget_utils"; import Column from "./column"; import BoardApi from "./api"; -import FBranch from "../../../entities/fbranch"; -import FNote from "../../../entities/fnote"; export interface BoardViewData { columns?: BoardColumnData[]; @@ -55,13 +53,16 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC return new Api(byColumn, columns ?? [], parentNote, statusAttribute, viewConfig ?? {}, saveConfig, setBranchIdToEdit ); }, [ byColumn, columns, parentNote, statusAttribute, viewConfig, saveConfig, setBranchIdToEdit ]); const boardViewContext = useMemo(() => ({ + api, branchIdToEdit, setBranchIdToEdit, columnNameToEdit, setColumnNameToEdit, draggedColumn, setDraggedColumn, dropPosition, setDropPosition, draggedCard, setDraggedCard, dropTarget, setDropTarget - }), [ branchIdToEdit, setBranchIdToEdit, + }), [ + api, + branchIdToEdit, setBranchIdToEdit, columnNameToEdit, setColumnNameToEdit, draggedColumn, setDraggedColumn, dropPosition, setDropPosition, diff --git a/apps/client/src/widgets/view_widgets/board_view/index.ts b/apps/client/src/widgets/view_widgets/board_view/index.ts index e81704bb99..4d548d8cb5 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -1,7 +1,4 @@ -import { setupHorizontalScrollViaWheel } from "../../widget_utils"; import ViewMode, { ViewModeArgs } from "../view_mode"; -import noteCreateService from "../../../services/note_create"; -import { EventData } from "../../../components/app_context"; import { BoardData } from "./config"; import SpacedUpdate from "../../../services/spaced_update"; import { setupContextMenu } from "./context_menu"; From 0844f60343d251f6e72f1bbc7ad665823af1dd87 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 15:29:20 +0300 Subject: [PATCH 159/233] chore(collections/board): fix unnecessary repaint --- apps/client/src/widgets/collections/board/column.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/client/src/widgets/collections/board/column.tsx b/apps/client/src/widgets/collections/board/column.tsx index 5d859d9420..cfca7b28fd 100644 --- a/apps/client/src/widgets/collections/board/column.tsx +++ b/apps/client/src/widgets/collections/board/column.tsx @@ -147,8 +147,10 @@ function useDragging({ column, columnIndex, columnItems }: DragContext) { } } - setDropPosition({ column, index: newIndex }); - }, [column, setDropTarget, setDropPosition]); + if (!(dropPosition?.column === column && dropPosition.index === newIndex)) { + setDropPosition({ column, index: newIndex }); + } + }, [column, setDropTarget, dropPosition, setDropPosition]); const handleDragLeave = useCallback((e: DragEvent) => { const relatedTarget = e.relatedTarget as HTMLElement; From 1e1a458addc1153858ab9a2d7b575438a90c679b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 15:39:30 +0300 Subject: [PATCH 160/233] chore(collections/board): bring back scrolling inside columns --- .../client/src/widgets/collections/board/column.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/client/src/widgets/collections/board/column.tsx b/apps/client/src/widgets/collections/board/column.tsx index cfca7b28fd..b1e6559ed1 100644 --- a/apps/client/src/widgets/collections/board/column.tsx +++ b/apps/client/src/widgets/collections/board/column.tsx @@ -9,6 +9,7 @@ import Icon from "../../react/Icon"; import { t } from "../../../services/i18n"; import BoardApi from "./api"; import Card from "./card"; +import { JSX } from "preact/jsx-runtime"; interface DragContext { column: string; @@ -42,6 +43,17 @@ export default function Column({ 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) => { + const el = event.currentTarget; + if (!el) return; + + const needsScroll = el.scrollHeight > el.clientHeight; + if (needsScroll) { + event.stopPropagation(); + } + }, []); + useEffect(() => { editorRef.current?.focus(); }, [ isEditing ]); @@ -52,6 +64,7 @@ export default function Column({ onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} + onWheel={handleScroll} >

Date: Fri, 12 Sep 2025 15:39:44 +0300 Subject: [PATCH 161/233] chore(collections/board): remove more of the old files --- .../board_view/column_drag_handler.ts | 278 ------------------ .../widgets/view_widgets/board_view/index.ts | 253 ---------------- 2 files changed, 531 deletions(-) delete mode 100644 apps/client/src/widgets/view_widgets/board_view/column_drag_handler.ts delete mode 100644 apps/client/src/widgets/view_widgets/board_view/index.ts diff --git a/apps/client/src/widgets/view_widgets/board_view/column_drag_handler.ts b/apps/client/src/widgets/view_widgets/board_view/column_drag_handler.ts deleted file mode 100644 index 2812120aaa..0000000000 --- a/apps/client/src/widgets/view_widgets/board_view/column_drag_handler.ts +++ /dev/null @@ -1,278 +0,0 @@ -import BoardApi from "./api"; -import { DragContext, BaseDragHandler } from "./drag_types"; - -export class ColumnDragHandler implements BaseDragHandler { - private $container: JQuery; - private api: BoardApi; - private context: DragContext; - - constructor( - $container: JQuery, - api: BoardApi, - context: DragContext, - ) { - this.$container = $container; - this.api = api; - this.context = context; - } - - setupColumnDrag($columnEl: JQuery, columnValue: string) { - const $titleEl = $columnEl.find('h3[data-column-value]'); - - $titleEl.attr("draggable", "true"); - - // Delay drag start to allow click detection - let dragStartTimer: number | null = null; - - $titleEl.on("mousedown", (e) => { - // Don't interfere with editing mode or input field interactions - if ($titleEl.hasClass('editing') || $(e.target).is('input')) { - return; - } - - // Clear any existing timer - if (dragStartTimer) { - clearTimeout(dragStartTimer); - dragStartTimer = null; - } - - // Set a short delay before enabling dragging - dragStartTimer = window.setTimeout(() => { - $titleEl.attr("draggable", "true"); - dragStartTimer = null; - }, 150); - }); - - $titleEl.on("mouseup mouseleave", (e) => { - // Don't interfere with editing mode - if ($titleEl.hasClass('editing') || $(e.target).is('input')) { - return; - } - - // Cancel drag start timer on mouse up or leave - if (dragStartTimer) { - clearTimeout(dragStartTimer); - dragStartTimer = null; - } - }); - - $titleEl.on("dragstart", (e) => { - // Only start dragging if the target is not an input (for inline editing) - if ($(e.target).is('input') || $titleEl.hasClass('editing')) { - e.preventDefault(); - return false; - } - - this.context.draggedColumn = columnValue; - this.context.draggedColumnElement = $columnEl; - $columnEl.addClass("column-dragging"); - - const originalEvent = e.originalEvent as DragEvent; - if (originalEvent.dataTransfer) { - originalEvent.dataTransfer.effectAllowed = "move"; - originalEvent.dataTransfer.setData("text/plain", columnValue); - } - - // Prevent note dragging when column is being dragged - e.stopPropagation(); - - // Setup global drag tracking for better drop indicator positioning - this.setupGlobalColumnDragTracking(); - }); - - $titleEl.on("dragend", () => { - $columnEl.removeClass("column-dragging"); - this.context.draggedColumn = null; - this.context.draggedColumnElement = null; - this.cleanupColumnDropIndicators(); - this.cleanupGlobalColumnDragTracking(); - - // Re-enable draggable - $titleEl.attr("draggable", "true"); - }); - } - - setupColumnDropZone($columnEl: JQuery) { - $columnEl.on("dragover", (e) => { - // Only handle column drops when a column is being dragged - if (this.context.draggedColumn && !this.context.draggedNote) { - e.preventDefault(); - const originalEvent = e.originalEvent as DragEvent; - if (originalEvent.dataTransfer) { - originalEvent.dataTransfer.dropEffect = "move"; - } - - // Don't highlight columns - we only care about the drop indicator position - } - }); - - $columnEl.on("drop", async (e) => { - if (this.context.draggedColumn && !this.context.draggedNote) { - e.preventDefault(); - console.log("Column drop event triggered for column:", this.context.draggedColumn); - - // Use the drop indicator position to determine where to place the column - await this.handleColumnDrop(); - } - }); - } - - cleanup() { - this.cleanupColumnDropIndicators(); - this.context.draggedColumn = null; - this.context.draggedColumnElement = null; - this.cleanupGlobalColumnDragTracking(); - } - - private setupGlobalColumnDragTracking() { - // Add container-level drag tracking for better indicator positioning - this.$container.on("dragover.columnDrag", (e) => { - if (this.context.draggedColumn) { - e.preventDefault(); - const originalEvent = e.originalEvent as DragEvent; - this.showColumnDropIndicator(originalEvent.clientX); - } - }); - - // Add container-level drop handler for column reordering - this.$container.on("drop.columnDrag", async (e) => { - if (this.context.draggedColumn) { - e.preventDefault(); - console.log("Container drop event triggered for column:", this.context.draggedColumn); - await this.handleColumnDrop(); - } - }); - } - - private cleanupGlobalColumnDragTracking() { - this.$container.off("dragover.columnDrag"); - this.$container.off("drop.columnDrag"); - } - - private cleanupColumnDropIndicators() { - // Remove column drop indicators - this.$container.find(".column-drop-indicator").remove(); - } - - private showColumnDropIndicator(mouseX: number) { - // Clean up existing indicators - this.cleanupColumnDropIndicators(); - - // Get all columns (excluding the dragged one if it exists) - let $allColumns = this.$container.find('.board-column'); - if (this.context.draggedColumnElement) { - $allColumns = $allColumns.not(this.context.draggedColumnElement); - } - - let $targetColumn: JQuery = $(); - let insertBefore = false; - - // Find which column the mouse is closest to - $allColumns.each((_, columnEl) => { - const $column = $(columnEl); - const rect = columnEl.getBoundingClientRect(); - const columnMiddle = rect.left + rect.width / 2; - - if (mouseX >= rect.left && mouseX <= rect.right) { - // Mouse is over this column - $targetColumn = $column; - insertBefore = mouseX < columnMiddle; - return false; // Break the loop - } - }); - - // If no column found under mouse, find the closest one - if ($targetColumn.length === 0) { - let closestDistance = Infinity; - $allColumns.each((_, columnEl) => { - const $column = $(columnEl); - const rect = columnEl.getBoundingClientRect(); - const columnCenter = rect.left + rect.width / 2; - const distance = Math.abs(mouseX - columnCenter); - - if (distance < closestDistance) { - closestDistance = distance; - $targetColumn = $column; - insertBefore = mouseX < columnCenter; - } - }); - } - - if ($targetColumn.length > 0) { - const $dropIndicator = $("
").addClass("column-drop-indicator"); - - if (insertBefore) { - $targetColumn.before($dropIndicator); - } else { - $targetColumn.after($dropIndicator); - } - - $dropIndicator.addClass("show"); - } - } - - private async handleColumnDrop() { - console.log("handleColumnDrop called for:", this.context.draggedColumn); - - if (!this.context.draggedColumn || !this.context.draggedColumnElement) { - console.log("No dragged column or element found"); - return; - } - - try { - // Find the drop indicator to determine insert position - const $dropIndicator = this.$container.find(".column-drop-indicator.show"); - console.log("Drop indicator found:", $dropIndicator.length > 0); - - if ($dropIndicator.length > 0) { - // Get current column order from the API (source of truth) - const currentOrder = [...this.api.columns]; - - let newOrder = [...currentOrder]; - - // Remove dragged column from current position - newOrder = newOrder.filter(col => col !== this.context.draggedColumn); - - // Determine insertion position based on drop indicator position - const $nextColumn = $dropIndicator.next('.board-column'); - const $prevColumn = $dropIndicator.prev('.board-column'); - - let insertIndex = -1; - - if ($nextColumn.length > 0) { - // Insert before the next column - const nextColumnValue = $nextColumn.attr('data-column'); - if (nextColumnValue) { - insertIndex = newOrder.indexOf(nextColumnValue); - } - } else if ($prevColumn.length > 0) { - // Insert after the previous column - const prevColumnValue = $prevColumn.attr('data-column'); - if (prevColumnValue) { - insertIndex = newOrder.indexOf(prevColumnValue) + 1; - } - } else { - // Insert at the beginning - insertIndex = 0; - } - - // Insert the dragged column at the determined position - if (insertIndex >= 0 && insertIndex <= newOrder.length) { - newOrder.splice(insertIndex, 0, this.context.draggedColumn); - } else { - // Fallback: insert at the end - newOrder.push(this.context.draggedColumn); - } - - // Update column order in API - await this.api.reorderColumns(newOrder); - } else { - console.warn("No drop indicator found for column drop"); - } - } catch (error) { - console.error("Failed to reorder columns:", error); - } finally { - this.cleanupColumnDropIndicators(); - } - } -} diff --git a/apps/client/src/widgets/view_widgets/board_view/index.ts b/apps/client/src/widgets/view_widgets/board_view/index.ts deleted file mode 100644 index 4d548d8cb5..0000000000 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ /dev/null @@ -1,253 +0,0 @@ -import ViewMode, { ViewModeArgs } from "../view_mode"; -import { BoardData } from "./config"; -import SpacedUpdate from "../../../services/spaced_update"; -import { setupContextMenu } from "./context_menu"; -import BoardApi from "./api"; -import { BoardDragHandler, DragContext } from "./drag_handler"; -import { DifferentialBoardRenderer } from "./differential_renderer"; - -export default class BoardView extends ViewMode { - - private $root: JQuery; - private $container: JQuery; - private spacedUpdate: SpacedUpdate; - private dragContext: DragContext; - private persistentData: BoardData; - private api?: BoardApi; - private dragHandler?: BoardDragHandler; - private renderer?: DifferentialBoardRenderer; - - constructor(args: ViewModeArgs) { - super(args, "board"); - - this.$root = $(TPL); - this.$container = this.$root.find(".board-view-container"); - this.spacedUpdate = new SpacedUpdate(() => this.onSave(), 5_000); - this.persistentData = { - columns: [] - }; - this.dragContext = { - draggedNote: null, - draggedBranch: null, - draggedNoteElement: null, - draggedColumn: null, - draggedColumnElement: null - }; - - args.$parent.append(this.$root); - } - - async renderList(): Promise | undefined> { - if (!this.renderer) { - // First time setup - this.$container.empty(); - await this.initializeRenderer(); - } - - await this.renderer!.renderBoard(); - return this.$root; - } - - private async initializeRenderer() { - this.api = await BoardApi.build(this.parentNote, this.viewStorage); - this.dragHandler = new BoardDragHandler( - this.$container, - this.api, - this.dragContext - ); - - this.renderer = new DifferentialBoardRenderer( - this.$container, - this.api, - this.dragHandler, - this.parentNote, - this.viewStorage, - () => this.refreshApi() - ); - - setupContextMenu({ - $container: this.$container, - api: this.api, - boardView: this - }); - - // Setup column title editing and add column functionality - this.setupBoardInteractions(); - } - - private async refreshApi(): Promise { - if (!this.api) { - throw new Error("API not initialized"); - } - - await this.api.refresh(this.parentNote); - } - - private setupBoardInteractions() { - // Handle column title editing with click detection that works with dragging - this.$container.on('mousedown', 'h3[data-column-value]', (e) => { - const $titleEl = $(e.currentTarget); - - // Don't interfere with editing mode - if ($titleEl.hasClass('editing') || $(e.target).is('input')) { - return; - } - - const startTime = Date.now(); - let hasMoved = false; - const startX = e.clientX; - const startY = e.clientY; - - const handleMouseMove = (moveEvent: JQuery.MouseMoveEvent) => { - const deltaX = Math.abs(moveEvent.clientX - startX); - const deltaY = Math.abs(moveEvent.clientY - startY); - if (deltaX > 5 || deltaY > 5) { - hasMoved = true; - } - }; - - const handleMouseUp = (upEvent: JQuery.MouseUpEvent) => { - const duration = Date.now() - startTime; - $(document).off('mousemove', handleMouseMove); - $(document).off('mouseup', handleMouseUp); - - // If it was a quick click without much movement, treat as edit request - if (duration < 500 && !hasMoved && upEvent.button === 0) { - const columnValue = $titleEl.attr('data-column-value'); - if (columnValue) { - const columnItems = this.api?.getColumn(columnValue) || []; - this.startEditingColumnTitle($titleEl, columnValue, columnItems); - } - } - }; - - $(document).on('mousemove', handleMouseMove); - $(document).on('mouseup', handleMouseUp); - }); - - // Handle add column button - this.$container.on('click', '.board-add-column', (e) => { - e.stopPropagation(); - this.startCreatingNewColumn($(e.currentTarget)); - }); - } - - private startEditingColumnTitle($titleEl: JQuery, columnValue: string, columnItems: { branch: any; note: any; }[]) { - if ($titleEl.hasClass("editing")) { - return; // Already editing - } - - const $titleSpan = $titleEl.find("span").first(); // Get the text span - const currentTitle = $titleSpan.text(); - $titleEl.addClass("editing"); - - // Disable dragging while editing - $titleEl.attr("draggable", "false"); - - const $input = $("") - .attr("type", "text") - .val(currentTitle) - .attr("placeholder", "Column title"); - - // Prevent events from bubbling to parent drag handlers - $input.on('mousedown mouseup click', (e) => { - e.stopPropagation(); - }); - - $titleEl.empty().append($input); - $input.focus().select(); - - const finishEdit = async (save: boolean = true) => { - if (!$titleEl.hasClass("editing")) { - return; // Already finished - } - - $titleEl.removeClass("editing"); - - // Re-enable dragging after editing - $titleEl.attr("draggable", "true"); - - let finalTitle = currentTitle; - if (save) { - const newTitle = $input.val() as string; - if (newTitle.trim() && newTitle !== currentTitle) { - await this.renameColumn(columnValue, newTitle.trim(), columnItems); - finalTitle = newTitle.trim(); - } - } - - // Recreate the title structure - const { $titleText, $editIcon } = this.createTitleStructure(finalTitle); - $titleEl.empty().append($titleText, $editIcon); - }; - - $input.on("blur", () => finishEdit(true)); - $input.on("keydown", (e) => { - if (e.key === "Enter") { - e.preventDefault(); - finishEdit(true); - } else if (e.key === "Escape") { - e.preventDefault(); - finishEdit(false); - } - }); - } - - private async renameColumn(oldValue: string, newValue: string, columnItems: { branch: any; note: any; }[]) { - try { - // Get all note IDs in this column - const noteIds = columnItems.map(item => item.note.noteId); - - // Use the API to rename the column (update all notes) - // This will trigger onEntitiesReloaded which will automatically refresh the board - await this.api?.renameColumn(oldValue, newValue, noteIds); - } catch (error) { - console.error("Failed to rename column:", error); - } - } - - forceFullRefresh() { - this.renderer?.forceFullRender(); - return this.renderList(); - } - - private startCreatingNewColumn($addColumnEl: JQuery) { - $addColumnEl.empty().append($input); - $input.focus(); - - const finishEdit = async (save: boolean = true) => { - if (!$addColumnEl.hasClass("editing")) { - return; // Already finished - } - - $addColumnEl.removeClass("editing"); - - if (save) { - const columnName = $input.val() as string; - if (columnName.trim()) { - await this.createNewColumn(columnName.trim()); - } - } - }; - } - - private async createNewColumn(columnName: string) { - try { - // Check if column already exists - if (this.api?.columns.includes(columnName)) { - console.warn("A column with this name already exists."); - return; - } - - // Create the new column - await this.api?.createColumn(columnName); - - // Refresh the board to show the new column - await this.renderList(); - } catch (error) { - console.error("Failed to create new column:", error); - } - } - - -} From 54fe9dde70d336487a8256f0a0f3dac3f474bef6 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 15:46:39 +0300 Subject: [PATCH 162/233] chore(collections/board): floating edit button for note titles --- .../src/widgets/collections/board/card.tsx | 2 +- .../src/widgets/collections/board/index.css | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/client/src/widgets/collections/board/card.tsx b/apps/client/src/widgets/collections/board/card.tsx index f301a2855d..15cb11d355 100644 --- a/apps/client/src/widgets/collections/board/card.tsx +++ b/apps/client/src/widgets/collections/board/card.tsx @@ -67,7 +67,7 @@ export default function Card({ <> {title} diff --git a/apps/client/src/widgets/collections/board/index.css b/apps/client/src/widgets/collections/board/index.css index 81d0ca1e52..a704402868 100644 --- a/apps/client/src/widgets/collections/board/index.css +++ b/apps/client/src/widgets/collections/board/index.css @@ -120,6 +120,24 @@ border: 1px solid var(--main-border-color); transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.15s ease, margin-top 0.2s ease; opacity: 1; + line-height: 1.1; +} + +.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 > .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 { From 79e51b543a5456aa6258e1bd26a95e7dec2ea977 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 15:49:45 +0300 Subject: [PATCH 163/233] chore(collections/board): icon as part of the text for better fit on multiline --- apps/client/src/widgets/collections/board/card.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/client/src/widgets/collections/board/card.tsx b/apps/client/src/widgets/collections/board/card.tsx index 15cb11d355..8c325b72f5 100644 --- a/apps/client/src/widgets/collections/board/card.tsx +++ b/apps/client/src/widgets/collections/board/card.tsx @@ -62,10 +62,12 @@ export default function Card({ onDragEnd={handleDragEnd} onContextMenu={handleContextMenu} > - {!isEditing ? ( <> - {title} + + + {title} + Date: Fri, 12 Sep 2025 15:58:38 +0300 Subject: [PATCH 164/233] chore(collections/board): basic multiline editing --- apps/client/src/widgets/collections/board/card.tsx | 1 + apps/client/src/widgets/collections/board/index.css | 12 ++++++++++-- apps/client/src/widgets/collections/board/index.tsx | 13 +++++++++---- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/client/src/widgets/collections/board/card.tsx b/apps/client/src/widgets/collections/board/card.tsx index 8c325b72f5..4ad335b4bb 100644 --- a/apps/client/src/widgets/collections/board/card.tsx +++ b/apps/client/src/widgets/collections/board/card.tsx @@ -82,6 +82,7 @@ export default function Card({ setTitle(newTitle); }} dismiss={() => api.dismissEditingTitle()} + multiline /> )}
diff --git a/apps/client/src/widgets/collections/board/index.css b/apps/client/src/widgets/collections/board/index.css index a704402868..acadb5b6d2 100644 --- a/apps/client/src/widgets/collections/board/index.css +++ b/apps/client/src/widgets/collections/board/index.css @@ -175,9 +175,11 @@ border-color: var(--main-text-color); display: flex; align-items: center; + padding: 0; } -.board-view-container .board-note.editing input { +.board-view-container .board-note.editing input, +.board-view-container .board-note.editing textarea { background: transparent; border: none; outline: none; @@ -185,7 +187,13 @@ font-size: inherit; color: inherit; width: 100%; - padding: 0; + padding: 0.5em; +} + +.board-view-container .board-note.editing textarea { + height: auto; + field-sizing: content; + resize: none; } .board-view-container .board-note .icon { diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 886719e004..23c8662650 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -11,6 +11,7 @@ import { createContext } from "preact"; import { onWheelHorizontalScroll } from "../../widget_utils"; import Column from "./column"; import BoardApi from "./api"; +import FormTextArea from "../../react/FormTextArea"; export interface BoardViewData { columns?: BoardColumnData[]; @@ -256,22 +257,26 @@ function AddNewColumn({ viewConfig, saveConfig }: { viewConfig?: BoardViewData, ) } -export function TitleEditor({ currentValue, save, dismiss }: { +export function TitleEditor({ currentValue, save, dismiss, multiline }: { currentValue: string, save: (newValue: string) => void, - dismiss: () => void + dismiss: () => void, + multiline?: boolean }) { - const inputRef = useRef(null); + const inputRef = useRef(null); useEffect(() => { inputRef.current?.focus(); inputRef.current?.select(); }, [ inputRef ]); + const Element = multiline ? FormTextArea : FormTextBox; + return ( - { if (e.key === "Enter") { const newValue = e.currentTarget.value; From b4fa70d1d5ef230991feb4352f1faf5eb75be457 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 16:06:19 +0300 Subject: [PATCH 165/233] chore(collections/board): improve fit in multiline --- .../src/widgets/collections/board/index.css | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/client/src/widgets/collections/board/index.css b/apps/client/src/widgets/collections/board/index.css index acadb5b6d2..80e0a83cf3 100644 --- a/apps/client/src/widgets/collections/board/index.css +++ b/apps/client/src/widgets/collections/board/index.css @@ -43,14 +43,12 @@ background-color: transparent; } -.board-view-container .board-column h3, -.board-view-container .board-note { +.board-view-container .board-column h3 { display: flex; align-items: center; } -.board-view-container .board-column h3 > .title, -.board-view-container .board-note > .title { +.board-view-container .board-column h3 > .title { flex-grow: 1; } @@ -121,6 +119,13 @@ transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.15s ease, margin-top 0.2s ease; opacity: 1; line-height: 1.1; + overflow-wrap: break-word; + overflow: hidden; +} + +.board-view-container .board-note .icon { + margin-right: 0.25em; + display: inline; } .board-view-container .board-note > .edit-icon { @@ -196,10 +201,6 @@ resize: none; } -.board-view-container .board-note .icon { - margin-right: 0.25em; -} - .board-drop-placeholder { height: 40px; margin: 0.65em 0; From 519d76d809fe62e0819f31f9e06dff5ee31544f0 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 16:07:04 +0300 Subject: [PATCH 166/233] chore(collections/board): normalize line height when editing --- apps/client/src/widgets/collections/board/index.css | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/client/src/widgets/collections/board/index.css b/apps/client/src/widgets/collections/board/index.css index 80e0a83cf3..11971f591e 100644 --- a/apps/client/src/widgets/collections/board/index.css +++ b/apps/client/src/widgets/collections/board/index.css @@ -3,6 +3,9 @@ position: relative; height: 100%; user-select: none; + + --card-line-height: 1.1; + --card-padding: 0.5em; } .board-view-container { @@ -110,7 +113,7 @@ .board-view-container .board-note { box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.25); margin: 0.65em 0; - padding: 0.5em; + padding: var(--card-padding); border-radius: 5px; cursor: move; position: relative; @@ -118,7 +121,7 @@ border: 1px solid var(--main-border-color); transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.15s ease, margin-top 0.2s ease; opacity: 1; - line-height: 1.1; + line-height: var(--card-line-height); overflow-wrap: break-word; overflow: hidden; } @@ -192,7 +195,8 @@ font-size: inherit; color: inherit; width: 100%; - padding: 0.5em; + padding: var(--card-padding); + line-height: var(--card-line-height); } .board-view-container .board-note.editing textarea { From e156f0a2e8397e894fe077f3af77f814905cda3d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 16:16:12 +0300 Subject: [PATCH 167/233] chore(collections/board): improve font size --- apps/client/src/widgets/collections/board/index.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/client/src/widgets/collections/board/index.css b/apps/client/src/widgets/collections/board/index.css index 11971f591e..154f4adb3a 100644 --- a/apps/client/src/widgets/collections/board/index.css +++ b/apps/client/src/widgets/collections/board/index.css @@ -4,7 +4,8 @@ height: 100%; user-select: none; - --card-line-height: 1.1; + --card-font-size: 0.9em; + --card-line-height: 1.2; --card-padding: 0.5em; } @@ -124,6 +125,7 @@ line-height: var(--card-line-height); overflow-wrap: break-word; overflow: hidden; + font-size: var(--card-font-size); } .board-view-container .board-note .icon { From 245675d409da23fe648873364078fbaab16f9d96 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 16:24:35 +0300 Subject: [PATCH 168/233] chore(collections/board): reintroduce note click on the board --- apps/client/src/widgets/collections/board/card.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/client/src/widgets/collections/board/card.tsx b/apps/client/src/widgets/collections/board/card.tsx index 4ad335b4bb..165eccc751 100644 --- a/apps/client/src/widgets/collections/board/card.tsx +++ b/apps/client/src/widgets/collections/board/card.tsx @@ -42,7 +42,12 @@ export default function Card({ openNoteContextMenu(api, e, note.noteId, branch.branchId, column); }, [ api, note, branch, column ]); - const handleEdit = useCallback((e) => { + 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 ]); @@ -61,6 +66,7 @@ export default function Card({ onDragStart={handleDragStart} onDragEnd={handleDragEnd} onContextMenu={handleContextMenu} + onClick={!isEditing ? handleOpen : undefined} > {!isEditing ? ( <> From 114fdd6f913fb88c3663c17252edf5afac503bd4 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 16:26:45 +0300 Subject: [PATCH 169/233] style(collections/board): smoother shadows, no shift --- apps/client/src/widgets/collections/board/index.css | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/client/src/widgets/collections/board/index.css b/apps/client/src/widgets/collections/board/index.css index 154f4adb3a..67fe0985c5 100644 --- a/apps/client/src/widgets/collections/board/index.css +++ b/apps/client/src/widgets/collections/board/index.css @@ -112,7 +112,7 @@ .board-view-container .board-note { - box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.25); + box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.1); margin: 0.65em 0; padding: var(--card-padding); border-radius: 5px; @@ -141,6 +141,10 @@ 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; @@ -168,11 +172,6 @@ to { opacity: 0; transform: translateY(-10px); } } -.board-view-container .board-note:hover { - transform: translateY(-2px); - box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.35); -} - .board-view-container .board-note.dragging { opacity: 0.5; transform: rotate(5deg); From e99748e45f6feec6645cebc72ccfbb8f92798b46 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 16:28:26 +0300 Subject: [PATCH 170/233] style(collections/board): minor improvements to Add item --- apps/client/src/widgets/collections/board/index.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/client/src/widgets/collections/board/index.css b/apps/client/src/widgets/collections/board/index.css index 67fe0985c5..740412b2f9 100644 --- a/apps/client/src/widgets/collections/board/index.css +++ b/apps/client/src/widgets/collections/board/index.css @@ -238,12 +238,13 @@ .board-new-item { margin-top: 0.5em; - padding: 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 { From ede4b99bcda54dc18c9a41d0375bf2c0844885c8 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 16:57:23 +0300 Subject: [PATCH 171/233] style(collections/board): better new item that creates only after enter --- .../src/widgets/collections/board/api.ts | 5 ++- .../src/widgets/collections/board/column.tsx | 33 ++++++++++++++++--- .../src/widgets/collections/board/index.css | 17 ++++++---- .../src/widgets/collections/board/index.tsx | 7 ++-- 4 files changed, 45 insertions(+), 17 deletions(-) diff --git a/apps/client/src/widgets/collections/board/api.ts b/apps/client/src/widgets/collections/board/api.ts index b696cb2280..cc00b54954 100644 --- a/apps/client/src/widgets/collections/board/api.ts +++ b/apps/client/src/widgets/collections/board/api.ts @@ -20,7 +20,7 @@ export default class BoardApi { private setBranchIdToEdit: (branchId: string | undefined) => void ) {}; - async createNewItem(column: string) { + async createNewItem(column: string, title: string) { try { // Get the parent note path const parentNotePath = this.parentNote.noteId; @@ -28,12 +28,11 @@ export default class BoardApi { // Create a new note as a child of the parent note const { note: newNote, branch: newBranch } = await note_create.createNote(parentNotePath, { activate: false, - title: "New item" + title }); if (newNote && newBranch) { await this.changeColumn(newNote.noteId, column); - this.startEditing(newBranch?.branchId); } } catch (error) { console.error("Failed to create new item:", error); diff --git a/apps/client/src/widgets/collections/board/column.tsx b/apps/client/src/widgets/collections/board/column.tsx index b1e6559ed1..44aca3fd97 100644 --- a/apps/client/src/widgets/collections/board/column.tsx +++ b/apps/client/src/widgets/collections/board/column.tsx @@ -1,4 +1,4 @@ -import { useCallback, useContext, useEffect, useRef } from "preact/hooks"; +import { useCallback, useContext, useEffect, useRef, useState } from "preact/hooks"; import FBranch from "../../../entities/fbranch"; import FNote from "../../../entities/fnote"; import { BoardViewContext, TitleEditor } from "."; @@ -117,14 +117,37 @@ export default function Column({
)} -
api.createNewItem(column)}> - {" "} - {t("board_view.new-item")} -
+
) } +function AddNewItem({ column, api }: { column: string, api: BoardApi }) { + const [ isCreatingNewItem, setIsCreatingNewItem ] = useState(false); + const addItemCallback = useCallback(() => setIsCreatingNewItem(true), []); + + return ( +
+ {!isCreatingNewItem ? ( + <> + {" "} + {t("board_view.new-item")} + + ) : ( + api.createNewItem(column, title)} + dismiss={() => setIsCreatingNewItem(false)} + multiline isNewItem + /> + )} +
+ ); +} + function useDragging({ column, columnIndex, columnItems }: DragContext) { const { api, draggedColumn, setDraggedColumn, setDropTarget, setDropPosition, draggedCard, dropPosition, setDraggedCard } = useContext(BoardViewContext); diff --git a/apps/client/src/widgets/collections/board/index.css b/apps/client/src/widgets/collections/board/index.css index 740412b2f9..18f9bf76be 100644 --- a/apps/client/src/widgets/collections/board/index.css +++ b/apps/client/src/widgets/collections/board/index.css @@ -111,7 +111,8 @@ } -.board-view-container .board-note { +.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); @@ -120,7 +121,6 @@ position: relative; background-color: var(--main-background-color); border: 1px solid var(--main-border-color); - transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.15s ease, margin-top 0.2s ease; opacity: 1; line-height: var(--card-line-height); overflow-wrap: break-word; @@ -128,6 +128,10 @@ 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 .icon { margin-right: 0.25em; display: inline; @@ -179,16 +183,17 @@ box-shadow: 4px 8px 16px rgba(0, 0, 0, 0.5); } -.board-view-container .board-note.editing { - box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.35); +.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 input, -.board-view-container .board-note.editing textarea { +.board-view-container .board-note.editing textarea, +.board-view-container .board-new-item textarea.form-control { background: transparent; border: none; outline: none; diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 23c8662650..df3fcf6cd6 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -257,11 +257,12 @@ function AddNewColumn({ viewConfig, saveConfig }: { viewConfig?: BoardViewData, ) } -export function TitleEditor({ currentValue, save, dismiss, multiline }: { +export function TitleEditor({ currentValue, save, dismiss, multiline, isNewItem }: { currentValue: string, save: (newValue: string) => void, dismiss: () => void, - multiline?: boolean + multiline?: boolean, + isNewItem?: boolean }) { const inputRef = useRef(null); @@ -280,7 +281,7 @@ export function TitleEditor({ currentValue, save, dismiss, multiline }: { onKeyDown={(e) => { if (e.key === "Enter") { const newValue = e.currentTarget.value; - if (newValue !== currentValue) { + if (newValue !== currentValue || isNewItem) { save(newValue); } dismiss(); From 0d275b325996bc8742e43a5843c212c14480f860 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 17:05:17 +0300 Subject: [PATCH 172/233] refactor(collections/board): use same title editor for new columns --- .../src/widgets/collections/board/api.ts | 21 ++++++ .../src/widgets/collections/board/index.tsx | 69 +++++-------------- 2 files changed, 39 insertions(+), 51 deletions(-) diff --git a/apps/client/src/widgets/collections/board/api.ts b/apps/client/src/widgets/collections/board/api.ts index cc00b54954..7188ef699f 100644 --- a/apps/client/src/widgets/collections/board/api.ts +++ b/apps/client/src/widgets/collections/board/api.ts @@ -43,6 +43,27 @@ export default class BoardApi { 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) || []; diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index df3fcf6cd6..4837035529 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -189,46 +189,20 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC
)} - +

) } -function AddNewColumn({ viewConfig, saveConfig }: { viewConfig?: BoardViewData, saveConfig: (data: BoardViewData) => void }) { +function AddNewColumn({ api }: { api: BoardApi }) { const [ isCreatingNewColumn, setIsCreatingNewColumn ] = useState(false); - const columnNameRef = useRef(null); const addColumnCallback = useCallback(() => { setIsCreatingNewColumn(true); }, []); - const finishEdit = useCallback((save: boolean) => { - const columnName = columnNameRef.current?.value; - if (!columnName || !save) { - setIsCreatingNewColumn(false); - return; - } - - // Add the new column to persisted data if it doesn't exist - if (!viewConfig) { - viewConfig = {}; - } - - if (!viewConfig.columns) { - viewConfig.columns = []; - } - - const existingColumn = viewConfig.columns.find(col => col.value === columnName); - if (!existingColumn) { - viewConfig.columns.push({ value: columnName }); - saveConfig(viewConfig); - } - - setIsCreatingNewColumn(false); - }, [ viewConfig, saveConfig ]); - return (
{!isCreatingNewColumn @@ -236,33 +210,25 @@ function AddNewColumn({ viewConfig, saveConfig }: { viewConfig?: BoardViewData, {" "} {t("board_view.add-column")} - : <> - finishEdit(true)} - onKeyDown={(e: KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - finishEdit(true); - } else if (e.key === "Escape") { - e.preventDefault(); - finishEdit(false); - } - }} + save={(columnName) => api.addNewColumn(columnName)} + dismiss={() => setIsCreatingNewColumn(false)} + isNewItem /> - } + )}
) } -export function TitleEditor({ currentValue, save, dismiss, multiline, isNewItem }: { - currentValue: string, - save: (newValue: string) => void, - dismiss: () => void, - multiline?: boolean, - isNewItem?: boolean +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(null); @@ -276,7 +242,8 @@ export function TitleEditor({ currentValue, save, dismiss, multiline, isNewItem return ( { if (e.key === "Enter") { @@ -298,5 +265,5 @@ export function TitleEditor({ currentValue, save, dismiss, multiline, isNewItem dismiss(); }} /> - ) + ); } From 8ad00084e1ce2017b55ccd704edc5cc8371e2980 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 17:08:36 +0300 Subject: [PATCH 173/233] style(collections/board): slightly bigger card padding --- apps/client/src/widgets/collections/board/index.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/widgets/collections/board/index.css b/apps/client/src/widgets/collections/board/index.css index 18f9bf76be..054ab82a7f 100644 --- a/apps/client/src/widgets/collections/board/index.css +++ b/apps/client/src/widgets/collections/board/index.css @@ -6,7 +6,7 @@ --card-font-size: 0.9em; --card-line-height: 1.2; - --card-padding: 0.5em; + --card-padding: 0.6em; } .board-view-container { From a08bc79ae4a35af75e2224b0b83cc8a9bd8dc889 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 17:20:22 +0300 Subject: [PATCH 174/233] feat(collections/board): add option to archive note --- apps/client/src/entities/fnote.ts | 4 +++- apps/client/src/translations/en/translation.json | 1 + apps/client/src/widgets/collections/NoteList.tsx | 4 +++- apps/client/src/widgets/collections/board/context_menu.ts | 6 ++++++ apps/client/src/widgets/collections/board/data.ts | 4 +--- 5 files changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/client/src/entities/fnote.ts b/apps/client/src/entities/fnote.ts index b80e8e3fbc..354a2f213f 100644 --- a/apps/client/src/entities/fnote.ts +++ b/apps/client/src/entities/fnote.ts @@ -259,6 +259,8 @@ export default class FNote { async getSubtreeNoteIds() { let noteIds: (string | string[])[] = []; for (const child of await this.getChildNotes()) { + if (child.isArchived) continue; + noteIds.push(child.noteId); noteIds.push(await child.getSubtreeNoteIds()); } @@ -267,7 +269,7 @@ export default class FNote { async getSubtreeNotes() { const noteIds = await this.getSubtreeNoteIds(); - return this.froca.getNotes(noteIds); + return (await this.froca.getNotes(noteIds)).filter(note => !note.isArchived) } async getChildNotes() { diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 7e09326169..bba8036bf9 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1994,6 +1994,7 @@ }, "board_view": { "delete-note": "Delete Note", + "archive-note": "Archive Note", "move-to": "Move to", "insert-above": "Insert above", "insert-below": "Insert below", diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index fe62d02543..1d642c3b25 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -129,7 +129,9 @@ function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOptions | useTriliumEvent("entitiesReloaded", ({ loadResults }) => { if (note && loadResults.getBranchRows().some(branch => branch.parentNoteId === note.noteId - || noteIds.includes(branch.parentNoteId ?? ""))) { + || noteIds.includes(branch.parentNoteId ?? "")) + || loadResults.getAttributeRows().some(attr => attr.name === "archived" && attr.noteId && noteIds.includes(attr.noteId)) + ) { refreshNoteIds(); } }) diff --git a/apps/client/src/widgets/collections/board/context_menu.ts b/apps/client/src/widgets/collections/board/context_menu.ts index 303db332a9..a2489fcf2d 100644 --- a/apps/client/src/widgets/collections/board/context_menu.ts +++ b/apps/client/src/widgets/collections/board/context_menu.ts @@ -1,5 +1,6 @@ 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"; @@ -65,6 +66,11 @@ export function openNoteContextMenu(api: Api, event: ContextMenuEvent, noteId: s title: t("board_view.delete-note"), uiIcon: "bx bx-trash", handler: () => branches.deleteNotes([ branchId ], false, false) + }, + { + title: t("board_view.archive-note"), + uiIcon: "bx bx-archive", + handler: () => attributes.addLabel(noteId, "archived") } ], selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, noteId), diff --git a/apps/client/src/widgets/collections/board/data.ts b/apps/client/src/widgets/collections/board/data.ts index 2a59e82b78..9f55b26b54 100644 --- a/apps/client/src/widgets/collections/board/data.ts +++ b/apps/client/src/widgets/collections/board/data.ts @@ -64,9 +64,7 @@ export async function getBoardData(parentNote: FNote, groupByColumn: string, per async function recursiveGroupBy(branches: FBranch[], byColumn: ColumnMap, groupByColumn: string) { for (const branch of branches) { const note = await branch.getNote(); - if (!note) { - continue; - } + if (!note || note.isArchived) continue; if (note.hasChildren()) { await recursiveGroupBy(note.getChildBranches(), byColumn, groupByColumn); From f300b6c8a2b97ff0f6c3d166c7ea3374d44bf669 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 17:39:52 +0300 Subject: [PATCH 175/233] refactor(collections/board): use API to reorder column --- .../src/widgets/collections/board/api.ts | 17 ++++ .../src/widgets/collections/board/index.tsx | 16 +--- .../widgets/view_widgets/board_view/api.ts | 84 ------------------- 3 files changed, 19 insertions(+), 98 deletions(-) delete mode 100644 apps/client/src/widgets/view_widgets/board_view/api.ts diff --git a/apps/client/src/widgets/collections/board/api.ts b/apps/client/src/widgets/collections/board/api.ts index 7188ef699f..c4cca678ae 100644 --- a/apps/client/src/widgets/collections/board/api.ts +++ b/apps/client/src/widgets/collections/board/api.ts @@ -99,6 +99,23 @@ export default class BoardApi { 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); + newColumns.splice(toIndex, 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, diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 4837035529..2dc71b6473 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -91,23 +91,11 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC useEffect(refresh, [ parentNote, noteIds, viewConfig ]); const handleColumnDrop = useCallback((fromIndex: number, toIndex: number) => { - if (!columns || fromIndex === toIndex) return; - - const newColumns = [...columns]; - const [movedColumn] = newColumns.splice(fromIndex, 1); - newColumns.splice(toIndex, 0, movedColumn); - - // Update view config with new column order - const newViewConfig = { - ...viewConfig, - columns: newColumns.map(col => ({ value: col })) - }; - - saveConfig(newViewConfig); + const newColumns = api.reorderColumn(fromIndex, toIndex); setColumns(newColumns); setDraggedColumn(null); setColumnDropPosition(null); - }, [columns, viewConfig, saveConfig]); + }, [api]); useTriliumEvent("entitiesReloaded", ({ loadResults }) => { // Check if any changes affect our board diff --git a/apps/client/src/widgets/view_widgets/board_view/api.ts b/apps/client/src/widgets/view_widgets/board_view/api.ts deleted file mode 100644 index 42eda932ea..0000000000 --- a/apps/client/src/widgets/view_widgets/board_view/api.ts +++ /dev/null @@ -1,84 +0,0 @@ -import appContext from "../../../components/app_context"; -import FNote from "../../../entities/fnote"; -import attributes from "../../../services/attributes"; -import { executeBulkActions } from "../../../services/bulk_action"; -import note_create from "../../../services/note_create"; -import ViewModeStorage from "../view_mode_storage"; -import { BoardData } from "./config"; -import { ColumnMap, getBoardData } from "./data"; - -export default class BoardApi { - - private constructor( - private _columns: string[], - private _parentNoteId: string, - private viewStorage: ViewModeStorage, - private byColumn: ColumnMap, - private persistedData: BoardData, - private _statusAttribute: string) {} - - get columns() { - return this._columns; - } - - get statusAttribute() { - return this._statusAttribute; - } - - getColumn(column: string) { - return this.byColumn.get(column); - } - - async reorderColumns(newColumnOrder: string[]) { - // Update the co lumn order in persisted data - if (!this.persistedData.columns) { - this.persistedData.columns = []; - } - - // Create a map of existing column data - const columnDataMap = new Map(); - this.persistedData.columns.forEach(col => { - columnDataMap.set(col.value, col); - }); - - // Reorder columns based on new order - this.persistedData.columns = newColumnOrder.map(columnValue => { - return columnDataMap.get(columnValue) || { value: columnValue }; - }); - - // Update internal columns array - this._columns = newColumnOrder; - - await this.viewStorage.store(this.persistedData); - } - - async refresh(parentNote: FNote) { - // Refresh the API data by re-fetching from the parent note - - // Use the current in-memory persisted data instead of restoring from storage - // This ensures we don't lose recent updates like column renames - - // Update internal state - this.byColumn = byColumn; - - if (newPersistedData) { - this.persistedData = newPersistedData; - this.viewStorage.store(this.persistedData); - } - - // Use the order from persistedData.columns, then add any new columns found - const orderedColumns = this.persistedData.columns?.map(col => col.value) || []; - const allColumns = Array.from(byColumn.keys()); - const newColumns = allColumns.filter(col => !orderedColumns.includes(col)); - this._columns = [...orderedColumns, ...newColumns]; - } - - static async build(parentNote: FNote, viewStorage: ViewModeStorage) { - const statusAttribute = parentNote.getLabelValue("board:groupBy") ?? "status"; - - let persistedData = await viewStorage.restore() ?? {}; - - return new BoardApi(columns, parentNote.noteId, viewStorage, byColumn, persistedData, statusAttribute); - } - -} From d1e57e85b6a5816836fc4ab2907b0fd9ca76d0b7 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 17:57:58 +0300 Subject: [PATCH 176/233] feat(collections): add label to show archived notes --- apps/client/src/entities/fnote.ts | 8 ++++---- apps/client/src/widgets/collections/NoteList.tsx | 7 ++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/client/src/entities/fnote.ts b/apps/client/src/entities/fnote.ts index 354a2f213f..3fff82afae 100644 --- a/apps/client/src/entities/fnote.ts +++ b/apps/client/src/entities/fnote.ts @@ -256,20 +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) continue; + 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 (await this.froca.getNotes(noteIds)).filter(note => !note.isArchived) + return (await this.froca.getNotes(noteIds)); } async getChildNotes() { diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index 1d642c3b25..7f3da0c151 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -1,5 +1,5 @@ import { allViewTypes, ViewModeProps, ViewTypeOptions } from "./interface"; -import { useNoteContext, useNoteLabel, useTriliumEvent } from "../react/hooks"; +import { useNoteContext, useNoteLabel, useNoteLabelBoolean, useTriliumEvent } from "../react/hooks"; import FNote from "../../entities/fnote"; import "./NoteList.css"; import { ListView, GridView } from "./legacy/ListOrGridView"; @@ -109,6 +109,7 @@ function useNoteViewType(note?: FNote | null): ViewTypeOptions | undefined { function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOptions | undefined) { const [ noteIds, setNoteIds ] = useState([]); + const [ includeArchived ] = useNoteLabelBoolean(note, "includeArchived"); async function refreshNoteIds() { if (!note) { @@ -118,12 +119,12 @@ function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOptions | setNoteIds(note.getChildNoteIds()); } else { console.log("Refreshed note IDs"); - setNoteIds(await note.getSubtreeNoteIds()); + setNoteIds(await note.getSubtreeNoteIds(includeArchived)); } } // Refresh on note switch. - useEffect(() => { refreshNoteIds() }, [ note ]); + useEffect(() => { refreshNoteIds() }, [ note, includeArchived ]); // Refresh on alterations to the note subtree. useTriliumEvent("entitiesReloaded", ({ loadResults }) => { From bf92280ed907529fcff5e3c3c26aff71afd385fd Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 18:03:07 +0300 Subject: [PATCH 177/233] feat(collections): add book property to include archived notes --- .../src/translations/en/translation.json | 3 ++- .../widgets/ribbon/CollectionPropertiesTab.tsx | 18 ++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index bba8036bf9..41616154bd 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -764,7 +764,8 @@ "calendar": "Calendar", "table": "Table", "geo-map": "Geo Map", - "board": "Board" + "board": "Board", + "include_archived_notes": "Include archived notes" }, "edited_notes": { "no_edited_notes_found": "No edited notes on this day yet...", diff --git a/apps/client/src/widgets/ribbon/CollectionPropertiesTab.tsx b/apps/client/src/widgets/ribbon/CollectionPropertiesTab.tsx index ebacd85d30..c58bbc02b7 100644 --- a/apps/client/src/widgets/ribbon/CollectionPropertiesTab.tsx +++ b/apps/client/src/widgets/ribbon/CollectionPropertiesTab.tsx @@ -24,7 +24,7 @@ const VIEW_TYPE_MAPPINGS: Record = { 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 && ( <> - + )}
@@ -54,7 +54,7 @@ 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 (
{properties.map(property => ( @@ -62,6 +62,16 @@ function BookProperties({ note, properties }: { note: FNote, properties: BookPro {mapPropertyView({ note, property })}
))} + + {viewType !== "list" && viewType !== "grid" && ( + + )}
) } @@ -146,4 +156,4 @@ function LabelledEntry({ label, children }: { label: string, children: Component ) -} \ No newline at end of file +} From ff422d112ba9687ea77f2b1ed4f9787b1690fdd4 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 18:08:55 +0300 Subject: [PATCH 178/233] feat(collections/geomap): react to archived notes --- apps/client/src/widgets/collections/geomap/index.css | 4 ++++ apps/client/src/widgets/collections/geomap/index.tsx | 7 ++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/client/src/widgets/collections/geomap/index.css b/apps/client/src/widgets/collections/geomap/index.css index 45dbf33f6b..6b8b27d457 100644 --- a/apps/client/src/widgets/collections/geomap/index.css +++ b/apps/client/src/widgets/collections/geomap/index.css @@ -68,6 +68,10 @@ 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; diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index 7dfc5de58a..d53158b4a5 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -154,11 +154,12 @@ function NoteMarker({ note, editable, latLng }: { note: FNote, editable: boolean // React to changes useNoteLabel(note, "color"); useNoteLabel(note, "iconClass"); + const [ archived ] = useNoteLabelBoolean(note, "archived"); const title = useNoteProperty(note, "title"); const colorClass = note.getColorClass(); const iconClass = note.getIcon(); - const icon = useMemo(() => buildIcon(iconClass, colorClass ?? undefined, title, note.noteId), [ iconClass, colorClass, title, note.noteId]); + const icon = useMemo(() => buildIcon(iconClass, colorClass ?? undefined, title, note.noteId, archived), [ iconClass, colorClass, title, note.noteId, archived]); const onClick = useCallback(() => { appContext.triggerCommand("openInPopup", { noteIdOrPath: note.noteId }); @@ -223,7 +224,7 @@ function NoteGpxTrack({ note }: { note: FNote }) { return xmlString && } -function buildIcon(bxIconClass: string, colorClass?: string, title?: string, noteIdLink?: string) { +function buildIcon(bxIconClass: string, colorClass?: string, title?: string, noteIdLink?: string, archived?: boolean) { let html = /*html*/`\ @@ -231,7 +232,7 @@ function buildIcon(bxIconClass: string, colorClass?: string, title?: string, not ${title ?? ""}`; if (noteIdLink) { - html = `
${html}
`; + html = `
${html}
`; } return divIcon({ From f5378524695124af8e693f4799a26bf4868309b9 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 18:20:17 +0300 Subject: [PATCH 179/233] fix(ribbon): book properties overlapping --- .../ribbon/CollectionPropertiesTab.tsx | 4 ++-- apps/client/src/widgets/ribbon/style.css | 21 +++++++------------ 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/apps/client/src/widgets/ribbon/CollectionPropertiesTab.tsx b/apps/client/src/widgets/ribbon/CollectionPropertiesTab.tsx index c58bbc02b7..6180b23212 100644 --- a/apps/client/src/widgets/ribbon/CollectionPropertiesTab.tsx +++ b/apps/client/src/widgets/ribbon/CollectionPropertiesTab.tsx @@ -56,7 +56,7 @@ function CollectionTypeSwitcher({ viewType, setViewType }: { viewType: string, s function BookProperties({ viewType, note, properties }: { viewType: ViewTypeOptions, note: FNote, properties: BookProperty[] }) { return ( -
+ <> {properties.map(property => (
{mapPropertyView({ note, property })} @@ -72,7 +72,7 @@ function BookProperties({ viewType, note, properties }: { viewType: ViewTypeOpti }} /> )} -
+ ) } diff --git a/apps/client/src/widgets/ribbon/style.css b/apps/client/src/widgets/ribbon/style.css index 02438d4e35..0dc3f4ffec 100644 --- a/apps/client/src/widgets/ribbon/style.css +++ b/apps/client/src/widgets/ribbon/style.css @@ -336,31 +336,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; From 0c0bcb87f9e07c97b24114bff9d8aa6b8940f04b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 18:35:15 +0300 Subject: [PATCH 180/233] feat(collections/calendar): support archived notes --- .../src/widgets/collections/calendar/event_builder.ts | 11 +++++++---- .../client/src/widgets/collections/calendar/index.css | 4 ++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/client/src/widgets/collections/calendar/event_builder.ts b/apps/client/src/widgets/collections/calendar/event_builder.ts index 3ea4c10013..8687dc6d90 100644 --- a/apps/client/src/widgets/collections/calendar/event_builder.ts +++ b/apps/client/src/widgets/collections/calendar/event_builder.ts @@ -8,7 +8,8 @@ interface Event { startDate: string, endDate?: string | null, startTime?: string | null, - endTime?: string | null + endTime?: string | null, + isArchived?: boolean; } export async function buildEvents(noteIds: string[]) { @@ -25,7 +26,8 @@ export async function buildEvents(noteIds: string[]) { const endDate = getCustomisableLabel(note, "endDate", "calendar:endDate"); const startTime = getCustomisableLabel(note, "startTime", "calendar:startTime"); const endTime = getCustomisableLabel(note, "endTime", "calendar:endTime"); - events.push(await buildEvent(note, { startDate, endDate, startTime, endTime })); + const isArchived = note.hasLabel("archived"); + events.push(await buildEvent(note, { startDate, endDate, startTime, endTime, isArchived })); } return events.flat(); @@ -75,7 +77,7 @@ export async function buildEventsForCalendar(note: FNote, e: EventSourceFuncArg) return events.flat(); } -export async function buildEvent(note: FNote, { startDate, endDate, startTime, endTime }: Event) { +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"); @@ -108,7 +110,8 @@ export async function buildEvent(note: FNote, { startDate, endDate, startTime, e noteId: note.noteId, color: color ?? undefined, iconClass: note.getLabelValue("iconClass"), - promotedAttributes: displayedAttributesData + promotedAttributes: displayedAttributesData, + className: isArchived ? "archived" : "" }; if (endDate) { eventData.end = endDate; diff --git a/apps/client/src/widgets/collections/calendar/index.css b/apps/client/src/widgets/collections/calendar/index.css index c568586106..2255cc1d0a 100644 --- a/apps/client/src/widgets/collections/calendar/index.css +++ b/apps/client/src/widgets/collections/calendar/index.css @@ -34,6 +34,10 @@ text-decoration: none; } +.calendar-container a.fc-event.archived { + opacity: 0.5; +} + .calendar-container .fc-button { padding: 0.2em 0.5em; } From 0a813f9b5361fc336dcc332fda2bd93741f21f9d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 19:02:10 +0300 Subject: [PATCH 181/233] feat(collections/table): support archived notes --- .../src/widgets/collections/table/index.css | 4 ++++ .../src/widgets/collections/table/index.tsx | 15 +++++++++++---- apps/client/src/widgets/collections/table/rows.ts | 10 ++++++---- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/apps/client/src/widgets/collections/table/index.css b/apps/client/src/widgets/collections/table/index.css index cc1eb13298..48249b0c90 100644 --- a/apps/client/src/widgets/collections/table/index.css +++ b/apps/client/src/widgets/collections/table/index.css @@ -36,6 +36,10 @@ border-right-width: 1px; } +.tabulator .tabulator-row.archived { + opacity: 0.5; +} + .tabulator .tabulator-footer { background-color: unset; padding: 5px 0; diff --git a/apps/client/src/widgets/collections/table/index.tsx b/apps/client/src/widgets/collections/table/index.tsx index fc2e71a7bd..0c4e3e8f88 100644 --- a/apps/client/src/widgets/collections/table/index.tsx +++ b/apps/client/src/widgets/collections/table/index.tsx @@ -4,7 +4,7 @@ import { buildColumnDefinitions } from "./columns"; import getAttributeDefinitionInformation, { buildRowDefinitions, TableData } from "./rows"; import { useLegacyWidget, useNoteLabel, 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} from 'tabulator-tables'; +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"; @@ -46,6 +46,11 @@ export default function TableView({ note, noteIds, notePath, viewConfig, saveCon } }, [ hasChildren ]); + const rowFormatter = useCallback((row: RowComponent) => { + const data = row.getData() as TableData; + row.getElement().classList.toggle("archived", !!data.isArchived); + }, []); + return (
{columnDefs && ( @@ -66,7 +71,7 @@ export default function TableView({ note, noteIds, notePath, viewConfig, saveCon index="branchId" movableColumns movableRows={movableRows} - + rowFormatter={rowFormatter} {...dataTreeProps} /> @@ -110,6 +115,7 @@ function usePersistence(initialConfig: TableConfig | null | undefined, saveConfi function useData(note: FNote, noteIds: string[], viewConfig: TableConfig | undefined, newAttributePosition: RefObject, resetNewAttributePosition: () => void) { const [ maxDepth ] = useNoteLabelInt(note, "maxNestingDepth") ?? -1; + const [ includeArchived ] = useNoteLabelBoolean(note, "includeArchived"); const [ columnDefs, setColumnDefs ] = useState(); const [ rowData, setRowData ] = useState(); @@ -119,7 +125,7 @@ function useData(note: FNote, noteIds: string[], viewConfig: TableConfig | undef function refresh() { const info = getAttributeDefinitionInformation(note); - buildRowDefinitions(note, info, maxDepth).then(({ definitions: rowData, hasSubtree: hasChildren, rowNumber }) => { + buildRowDefinitions(note, info, includeArchived, maxDepth).then(({ definitions: rowData, hasSubtree: hasChildren, rowNumber }) => { const columnDefs = buildColumnDefinitions({ info, movableRows, @@ -149,7 +155,8 @@ function useData(note: FNote, noteIds: string[], viewConfig: TableConfig | undef // 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 => noteIds.includes(attr.noteId!)) + || loadResults.getAttributeRows().some(attr => attr.name === "archived" && attr.noteId && noteIds.includes(attr.noteId))) { refresh(); return; } diff --git a/apps/client/src/widgets/collections/table/rows.ts b/apps/client/src/widgets/collections/table/rows.ts index 460f169acb..84b4c5882d 100644 --- a/apps/client/src/widgets/collections/table/rows.ts +++ b/apps/client/src/widgets/collections/table/rows.ts @@ -10,10 +10,11 @@ export type TableData = { relations: Record; 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; From 7e5069c7d16318a0630be10e191634215a4c4b96 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 19:34:54 +0300 Subject: [PATCH 182/233] feat(collections/board): support archived notes --- apps/client/src/widgets/collections/board/card.tsx | 3 ++- apps/client/src/widgets/collections/board/data.ts | 10 +++++----- apps/client/src/widgets/collections/board/index.css | 4 ++++ apps/client/src/widgets/collections/board/index.tsx | 5 +++-- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/client/src/widgets/collections/board/card.tsx b/apps/client/src/widgets/collections/board/card.tsx index 165eccc751..ffd3d0b67f 100644 --- a/apps/client/src/widgets/collections/board/card.tsx +++ b/apps/client/src/widgets/collections/board/card.tsx @@ -26,6 +26,7 @@ export default function Card({ const isEditing = branch.branchId === branchIdToEdit; const colorClass = note.getColorClass() || ''; const editorRef = useRef(null); + const isArchived = note.isArchived; const [ title, setTitle ] = useState(note.title); const handleDragStart = useCallback((e: DragEvent) => { @@ -61,7 +62,7 @@ export default function Card({ return (
; -export async function getBoardData(parentNote: FNote, groupByColumn: string, persistedData: BoardViewData) { +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()]; @@ -61,13 +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 || note.isArchived) 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); diff --git a/apps/client/src/widgets/collections/board/index.css b/apps/client/src/widgets/collections/board/index.css index 054ab82a7f..fa259ccb44 100644 --- a/apps/client/src/widgets/collections/board/index.css +++ b/apps/client/src/widgets/collections/board/index.css @@ -132,6 +132,10 @@ 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; diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 2dc71b6473..765ffb1f8d 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -2,7 +2,7 @@ import { Dispatch, StateUpdater, useCallback, useEffect, useMemo, useRef, useSta import { ViewModeProps } from "../interface"; import "./index.css"; import { ColumnMap, getBoardData } from "./data"; -import { useNoteLabelWithDefault, useTriliumEvent } from "../../react/hooks"; +import { useNoteLabelBoolean, useNoteLabelWithDefault, useTriliumEvent } from "../../react/hooks"; import Icon from "../../react/Icon"; import { t } from "../../../services/i18n"; import Api from "./api"; @@ -41,6 +41,7 @@ export const BoardViewContext = createContext({}); export default function BoardView({ note: parentNote, noteIds, viewConfig, saveConfig }: ViewModeProps) { const [ statusAttribute ] = useNoteLabelWithDefault(parentNote, "board:groupBy", "status"); + const [ includeArchived ] = useNoteLabelBoolean(parentNote, "includeArchived"); const [ byColumn, setByColumn ] = useState(); const [ columns, setColumns ] = useState(); const [ draggedCard, setDraggedCard ] = useState<{ noteId: string, branchId: string, fromColumn: string, index: number } | null>(null); @@ -72,7 +73,7 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC ]); function refresh() { - getBoardData(parentNote, statusAttribute, viewConfig ?? {}).then(({ byColumn, newPersistedData }) => { + getBoardData(parentNote, statusAttribute, viewConfig ?? {}, includeArchived).then(({ byColumn, newPersistedData }) => { setByColumn(byColumn); if (newPersistedData) { From 27804384dbf343f3806b4cbb21ed44922d15ec50 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 19:48:35 +0300 Subject: [PATCH 183/233] feat(ribbon): improve display of note ID --- apps/client/src/widgets/ribbon/style.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/client/src/widgets/ribbon/style.css b/apps/client/src/widgets/ribbon/style.css index 0dc3f4ffec..276256e252 100644 --- a/apps/client/src/widgets/ribbon/style.css +++ b/apps/client/src/widgets/ribbon/style.css @@ -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 */ From 338f3d536ffc70ffb5faaf03e7ac88a2b64ee1a0 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 19:51:53 +0300 Subject: [PATCH 184/233] chore(ribbon): use "show" instead of "include" for archived notes --- apps/client/src/translations/en/translation.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 41616154bd..574b70688c 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -765,7 +765,7 @@ "table": "Table", "geo-map": "Geo Map", "board": "Board", - "include_archived_notes": "Include archived notes" + "include_archived_notes": "Show archived notes" }, "edited_notes": { "no_edited_notes_found": "No edited notes on this day yet...", From dd6003172dce5110a2e80384d11304d75ea1d423 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 21:06:54 +0300 Subject: [PATCH 185/233] feat(collections/geomap): show toast if drag not enabled --- .../src/translations/en/translation.json | 4 +- .../src/widgets/collections/geomap/index.tsx | 38 +++++++++++-------- apps/client/src/widgets/react/hooks.tsx | 31 ++++++++++++++- 3 files changed, 55 insertions(+), 18 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 574b70688c..c6d0bdac3a 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -966,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 wiki for details." + "no_children_help": "This collection doesn't have any child notes so there's nothing to display. See wiki 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..." diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index d53158b4a5..4f1784850e 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -91,24 +91,32 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM // Dragging const containerRef = useRef(null); const apiRef = useRef(null); - useNoteTreeDrag(containerRef, async (treeData, e) => { - const api = apiRef.current; - if (!note || !api || isReadOnly) return; + 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 { 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 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); + 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); + } } }); diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 48a9590e59..06ea554ec1 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -18,6 +18,7 @@ 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(eventName: T, handler: (data: EventData) => void) { const parentComponent = useContext(ParentComponent); @@ -588,17 +589,35 @@ export function useImperativeSearchHighlighlighting(highlightedTokens: string[] }; } -export function useNoteTreeDrag(containerRef: MutableRef, callback: (data: DragData[], e: DragEvent) => void) { +export function useNoteTreeDrag(containerRef: MutableRef, { dragEnabled, dragNotEnabledMessage, callback }: { + dragEnabled: boolean, + dragNotEnabledMessage: Omit; + 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) { - // Allow drag. e.preventDefault(); } function onDrop(e: DragEvent) { + toast.closePersistent("drag-not-enabled"); + if (!dragEnabled) { + return; + } + const data = e.dataTransfer?.getData('text'); if (!data) { return; @@ -612,12 +631,20 @@ export function useNoteTreeDrag(containerRef: MutableRef { + container.removeEventListener("dragenter", onDragEnter); container.removeEventListener("dragover", onDragOver); container.removeEventListener("drop", onDrop); + container.removeEventListener("dragleave", onDragLeave); }; }, [ containerRef, callback ]); } From 7a61bbc297973786a80dc516dfe75e93c2813491 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 21:42:25 +0300 Subject: [PATCH 186/233] feat(collections/board): allow dragging from note tree --- .../src/widgets/collections/board/card.tsx | 20 +++-- .../src/widgets/collections/board/column.tsx | 88 +++++++++++++------ .../src/widgets/collections/board/index.tsx | 6 +- 3 files changed, 77 insertions(+), 37 deletions(-) diff --git a/apps/client/src/widgets/collections/board/card.tsx b/apps/client/src/widgets/collections/board/card.tsx index ffd3d0b67f..b80510f615 100644 --- a/apps/client/src/widgets/collections/board/card.tsx +++ b/apps/client/src/widgets/collections/board/card.tsx @@ -7,6 +7,13 @@ import { ContextMenuEvent } from "../../../menus/context_menu"; import { openNoteContextMenu } from "./context_menu"; import { t } from "../../../services/i18n"; +export interface CardDragData { + noteId: string; + branchId: string; + index: number; + fromColumn: string; +} + export default function Card({ api, note, @@ -22,7 +29,7 @@ export default function Card({ index: number, isDragging: boolean }) { - const { branchIdToEdit, setBranchIdToEdit, setDraggedCard } = useContext(BoardViewContext); + const { branchIdToEdit, setBranchIdToEdit } = useContext(BoardViewContext); const isEditing = branch.branchId === branchIdToEdit; const colorClass = note.getColorClass() || ''; const editorRef = useRef(null); @@ -31,13 +38,9 @@ export default function Card({ const handleDragStart = useCallback((e: DragEvent) => { e.dataTransfer!.effectAllowed = 'move'; - e.dataTransfer!.setData('text/plain', note.noteId); - setDraggedCard({ noteId: note.noteId, branchId: branch.branchId, fromColumn: column, index }); - }, [note.noteId, branch.branchId, column, index, setDraggedCard]); - - const handleDragEnd = useCallback(() => { - setDraggedCard(null); - }, [setDraggedCard]); + const data: CardDragData = { noteId: note.noteId, branchId: branch.branchId, fromColumn: column, index }; + e.dataTransfer!.setData('text/plain', JSON.stringify(data)); + }, [note.noteId, branch.branchId, column, index]); const handleContextMenu = useCallback((e: ContextMenuEvent) => { openNoteContextMenu(api, e, note.noteId, branch.branchId, column); @@ -65,7 +68,6 @@ export default function Card({ className={`board-note ${colorClass} ${isDragging ? 'dragging' : ''} ${isEditing ? "editing" : ""} ${isArchived ? "archived" : ""}`} draggable="true" onDragStart={handleDragStart} - onDragEnd={handleDragEnd} onContextMenu={handleContextMenu} onClick={!isEditing ? handleOpen : undefined} > diff --git a/apps/client/src/widgets/collections/board/column.tsx b/apps/client/src/widgets/collections/board/column.tsx index 44aca3fd97..c38b49ffea 100644 --- a/apps/client/src/widgets/collections/board/column.tsx +++ b/apps/client/src/widgets/collections/board/column.tsx @@ -8,8 +8,10 @@ import { ContextMenuEvent } from "../../../menus/context_menu"; import Icon from "../../react/Icon"; import { t } from "../../../services/i18n"; import BoardApi from "./api"; -import Card from "./card"; +import Card, { CardDragData } from "./card"; import { JSX } from "preact/jsx-runtime"; +import froca from "../../../services/froca"; +import { DragData } from "../../note_tree"; interface DragContext { column: string; @@ -149,7 +151,7 @@ function AddNewItem({ column, api }: { column: string, api: BoardApi }) { } function useDragging({ column, columnIndex, columnItems }: DragContext) { - const { api, draggedColumn, setDraggedColumn, setDropTarget, setDropPosition, draggedCard, dropPosition, setDraggedCard } = useContext(BoardViewContext); + const { api, parentNote, draggedColumn, setDraggedColumn, setDropTarget, setDropPosition, dropPosition } = useContext(BoardViewContext); const handleColumnDragStart = useCallback((e: DragEvent) => { e.dataTransfer!.effectAllowed = 'move'; @@ -204,39 +206,73 @@ function useDragging({ column, columnIndex, columnItems }: DragContext) { setDropTarget(null); setDropPosition(null); - if (draggedCard && dropPosition) { - const targetIndex = dropPosition.index; + const data = e.dataTransfer?.getData("text"); + if (!data) return; + const draggedCard = JSON.parse(data) as CardDragData | DragData[]; + + 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; - if (draggedCard.fromColumn !== column) { - // Moving to a different column - await api?.changeColumn(draggedCard.noteId, column); + await api?.changeColumn(noteId, column); - // If there are items in the target column, reorder - if (targetItems.length > 0 && targetIndex < targetItems.length) { - const targetBranch = targetItems[targetIndex].branch; - await branches.moveBeforeBranch([ draggedCard.branchId ], targetBranch.branchId); + 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 (draggedCard.index !== targetIndex) { - // Reordering within the same column - let targetBranchId: string | null = null; + } else if (targetBranch) { + await branches.moveAfterBranch([ branchId ], targetBranch.branchId); + } + } else { + // From within the board. + if (draggedCard && dropPosition) { + const targetIndex = dropPosition.index; + const targetItems = columnItems || []; - if (targetIndex < targetItems.length) { - // Moving before an existing item - const adjustedIndex = draggedCard.index < targetIndex ? targetIndex : targetIndex; - if (adjustedIndex < targetItems.length) { - targetBranchId = targetItems[adjustedIndex].branch.branchId; - await branches.moveBeforeBranch([ draggedCard.branchId ], targetBranchId); + const note = froca.getNoteFromCache(draggedCard.noteId); + if (!note) return; + + if (draggedCard.fromColumn !== column || !draggedCard.index) { + // Moving to a different column + await api?.changeColumn(draggedCard.noteId, column); + + // If there are items in the target column, reorder + if (targetItems.length > 0 && targetIndex < targetItems.length) { + const targetBranch = targetItems[targetIndex].branch; + await branches.moveBeforeBranch([ draggedCard.branchId ], targetBranch.branchId); + } + } else if (draggedCard.index !== targetIndex) { + // Reordering within the same column + let targetBranchId: string | null = null; + + if (targetIndex < targetItems.length) { + // Moving before an existing item + const adjustedIndex = draggedCard.index < targetIndex ? targetIndex : targetIndex; + if (adjustedIndex < targetItems.length) { + targetBranchId = targetItems[adjustedIndex].branch.branchId; + await branches.moveBeforeBranch([ draggedCard.branchId ], targetBranchId); + } + } else if (targetIndex > 0) { + // Moving to the end - place after the last item + const lastItem = targetItems[targetItems.length - 1]; + await branches.moveAfterBranch([ draggedCard.branchId ], lastItem.branch.branchId); } - } else if (targetIndex > 0) { - // Moving to the end - place after the last item - const lastItem = targetItems[targetItems.length - 1]; - await branches.moveAfterBranch([ draggedCard.branchId ], lastItem.branch.branchId); } } } - setDraggedCard(null); - }, [ api, draggedCard, draggedColumn, dropPosition, columnItems, column, setDraggedCard, setDropTarget, setDropPosition ]); + + }, [ api, draggedColumn, dropPosition, columnItems, column, setDropTarget, setDropPosition ]); return { handleColumnDragStart, handleColumnDragEnd, handleDragOver, handleDragLeave, handleDrop }; } diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 765ffb1f8d..79f4003558 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -12,6 +12,7 @@ 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[]; @@ -23,6 +24,7 @@ export interface BoardColumnData { interface BoardViewContextData { api?: BoardApi; + parentNote?: FNote; branchIdToEdit?: string; columnNameToEdit?: string; setColumnNameToEdit?: Dispatch>; @@ -31,8 +33,6 @@ interface BoardViewContextData { setDraggedColumn: (column: { column: string, index: number } | null) => void; dropPosition: { column: string, index: number } | null; setDropPosition: (position: { column: string, index: number } | null) => void; - draggedCard: { noteId: string, branchId: string, fromColumn: string, index: number } | null; - setDraggedCard: (card: { noteId: string, branchId: string, fromColumn: string, index: number } | null) => void; setDropTarget: (target: string | null) => void, dropTarget: string | null } @@ -56,6 +56,7 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC }, [ byColumn, columns, parentNote, statusAttribute, viewConfig, saveConfig, setBranchIdToEdit ]); const boardViewContext = useMemo(() => ({ api, + parentNote, branchIdToEdit, setBranchIdToEdit, columnNameToEdit, setColumnNameToEdit, draggedColumn, setDraggedColumn, @@ -64,6 +65,7 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC dropTarget, setDropTarget }), [ api, + parentNote, branchIdToEdit, setBranchIdToEdit, columnNameToEdit, setColumnNameToEdit, draggedColumn, setDraggedColumn, From 6703b7845798f4b142a756268650a59b8377f2d6 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 21:50:56 +0300 Subject: [PATCH 187/233] refactor(collections/board): move within board to API --- .../src/widgets/collections/board/api.ts | 38 +++++++++++++++++++ .../src/widgets/collections/board/column.tsx | 38 +------------------ 2 files changed, 40 insertions(+), 36 deletions(-) diff --git a/apps/client/src/widgets/collections/board/api.ts b/apps/client/src/widgets/collections/board/api.ts index c4cca678ae..86936bc67f 100644 --- a/apps/client/src/widgets/collections/board/api.ts +++ b/apps/client/src/widgets/collections/board/api.ts @@ -2,7 +2,9 @@ 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"; @@ -154,5 +156,41 @@ export default class BoardApi { return server.put(`notes/${noteId}/title`, { title: newTitle.trim() }); } + 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); + } + } + } + } diff --git a/apps/client/src/widgets/collections/board/column.tsx b/apps/client/src/widgets/collections/board/column.tsx index c38b49ffea..b9036c7f59 100644 --- a/apps/client/src/widgets/collections/board/column.tsx +++ b/apps/client/src/widgets/collections/board/column.tsx @@ -234,42 +234,8 @@ function useDragging({ column, columnIndex, columnItems }: DragContext) { } else if (targetBranch) { await branches.moveAfterBranch([ branchId ], targetBranch.branchId); } - } else { - // From within the board. - if (draggedCard && dropPosition) { - const targetIndex = dropPosition.index; - const targetItems = columnItems || []; - - const note = froca.getNoteFromCache(draggedCard.noteId); - if (!note) return; - - if (draggedCard.fromColumn !== column || !draggedCard.index) { - // Moving to a different column - await api?.changeColumn(draggedCard.noteId, column); - - // If there are items in the target column, reorder - if (targetItems.length > 0 && targetIndex < targetItems.length) { - const targetBranch = targetItems[targetIndex].branch; - await branches.moveBeforeBranch([ draggedCard.branchId ], targetBranch.branchId); - } - } else if (draggedCard.index !== targetIndex) { - // Reordering within the same column - let targetBranchId: string | null = null; - - if (targetIndex < targetItems.length) { - // Moving before an existing item - const adjustedIndex = draggedCard.index < targetIndex ? targetIndex : targetIndex; - if (adjustedIndex < targetItems.length) { - targetBranchId = targetItems[adjustedIndex].branch.branchId; - await branches.moveBeforeBranch([ draggedCard.branchId ], targetBranchId); - } - } else if (targetIndex > 0) { - // Moving to the end - place after the last item - const lastItem = targetItems[targetItems.length - 1]; - await branches.moveAfterBranch([ draggedCard.branchId ], lastItem.branch.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 ]); From 3175b75192dc6c068b01e867eaf55a8e02d1cef0 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 22:08:32 +0300 Subject: [PATCH 188/233] feat(collections/board): unarchive note --- .../src/translations/en/translation.json | 1 + .../src/widgets/collections/board/card.tsx | 2 +- .../widgets/collections/board/context_menu.ts | 31 ++++++++++++++----- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index c6d0bdac3a..98797051bc 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1998,6 +1998,7 @@ "board_view": { "delete-note": "Delete Note", "archive-note": "Archive Note", + "unarchive-note": "Unarchive Note", "move-to": "Move to", "insert-above": "Insert above", "insert-below": "Insert below", diff --git a/apps/client/src/widgets/collections/board/card.tsx b/apps/client/src/widgets/collections/board/card.tsx index b80510f615..68c1c1a377 100644 --- a/apps/client/src/widgets/collections/board/card.tsx +++ b/apps/client/src/widgets/collections/board/card.tsx @@ -43,7 +43,7 @@ export default function Card({ }, [note.noteId, branch.branchId, column, index]); const handleContextMenu = useCallback((e: ContextMenuEvent) => { - openNoteContextMenu(api, e, note.noteId, branch.branchId, column); + openNoteContextMenu(api, e, note, branch.branchId, column); }, [ api, note, branch, column ]); const handleOpen = useCallback(() => { diff --git a/apps/client/src/widgets/collections/board/context_menu.ts b/apps/client/src/widgets/collections/board/context_menu.ts index a2489fcf2d..9e4a98465c 100644 --- a/apps/client/src/widgets/collections/board/context_menu.ts +++ b/apps/client/src/widgets/collections/board/context_menu.ts @@ -1,3 +1,4 @@ +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"; @@ -31,7 +32,7 @@ export function openColumnContextMenu(api: Api, event: ContextMenuEvent, column: }); } -export function openNoteContextMenu(api: Api, event: ContextMenuEvent, noteId: string, branchId: string, column: string) { +export function openNoteContextMenu(api: Api, event: ContextMenuEvent, note: FNote, branchId: string, column: string) { event.preventDefault(); event.stopPropagation(); @@ -47,7 +48,7 @@ export function openNoteContextMenu(api: Api, event: ContextMenuEvent, noteId: s items: api.columns.map(columnToMoveTo => ({ title: columnToMoveTo, enabled: columnToMoveTo !== column, - handler: () => api.changeColumn(noteId, columnToMoveTo) + handler: () => api.changeColumn(note.noteId, columnToMoveTo) })) }, { title: "----" }, @@ -67,12 +68,26 @@ export function openNoteContextMenu(api: Api, event: ContextMenuEvent, noteId: s uiIcon: "bx bx-trash", handler: () => branches.deleteNotes([ branchId ], false, false) }, - { - title: t("board_view.archive-note"), - uiIcon: "bx bx-archive", - handler: () => attributes.addLabel(noteId, "archived") - } + getArchiveMenuItem(note) ], - selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, noteId), + 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") + } + } + } +} From 0dddcbcfa1b2ff0484b1d5be2de50970b51b0d48 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 22:25:33 +0300 Subject: [PATCH 189/233] feat(collections/board): remove note from board --- apps/client/src/translations/en/translation.json | 7 ++++--- apps/client/src/widgets/collections/board/api.ts | 6 ++++++ .../client/src/widgets/collections/board/context_menu.ts | 9 +++++++-- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 98797051bc..634fe3c351 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1996,9 +1996,10 @@ "delete_row": "Delete row" }, "board_view": { - "delete-note": "Delete Note", - "archive-note": "Archive Note", - "unarchive-note": "Unarchive 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", diff --git a/apps/client/src/widgets/collections/board/api.ts b/apps/client/src/widgets/collections/board/api.ts index 86936bc67f..61bcbe339a 100644 --- a/apps/client/src/widgets/collections/board/api.ts +++ b/apps/client/src/widgets/collections/board/api.ts @@ -156,6 +156,12 @@ export default class BoardApi { 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) ?? []; diff --git a/apps/client/src/widgets/collections/board/context_menu.ts b/apps/client/src/widgets/collections/board/context_menu.ts index 9e4a98465c..d3f74fde40 100644 --- a/apps/client/src/widgets/collections/board/context_menu.ts +++ b/apps/client/src/widgets/collections/board/context_menu.ts @@ -49,8 +49,9 @@ export function openNoteContextMenu(api: Api, event: ContextMenuEvent, note: FNo title: columnToMoveTo, enabled: columnToMoveTo !== column, handler: () => api.changeColumn(note.noteId, columnToMoveTo) - })) + })), }, + getArchiveMenuItem(note), { title: "----" }, { title: t("board_view.insert-above"), @@ -63,12 +64,16 @@ export function openNoteContextMenu(api: Api, event: ContextMenuEvent, note: FNo 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) }, - getArchiveMenuItem(note) ], selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, note.noteId), }); From 7bbb15a53522c3167e74ff4c647a4f6a1aa7b60b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 22:49:58 +0300 Subject: [PATCH 190/233] fix(react/collections/board): no columns if dragging column onto itself --- apps/client/src/widgets/collections/board/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 79f4003558..3475e3be9f 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -95,8 +95,11 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC const handleColumnDrop = useCallback((fromIndex: number, toIndex: number) => { const newColumns = api.reorderColumn(fromIndex, toIndex); - setColumns(newColumns); + if (newColumns) { + setColumns(newColumns); + } setDraggedColumn(null); + setDraggedCard(null); setColumnDropPosition(null); }, [api]); From c53e927a55b3b2db806970d321fd6d3a8d34809e Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 23:14:15 +0300 Subject: [PATCH 191/233] fix(react/collections/board): column and card drag mixing --- apps/client/src/widgets/collections/board/column.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/client/src/widgets/collections/board/column.tsx b/apps/client/src/widgets/collections/board/column.tsx index b9036c7f59..269a612601 100644 --- a/apps/client/src/widgets/collections/board/column.tsx +++ b/apps/client/src/widgets/collections/board/column.tsx @@ -152,8 +152,11 @@ function AddNewItem({ column, api }: { column: string, api: BoardApi }) { function useDragging({ column, columnIndex, columnItems }: DragContext) { 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) => { + isDraggingRef.current = true; e.dataTransfer!.effectAllowed = 'move'; e.dataTransfer!.setData('text/plain', column); setDraggedColumn({ column, index: columnIndex }); @@ -161,11 +164,12 @@ function useDragging({ column, columnIndex, columnItems }: DragContext) { }, [column, columnIndex, setDraggedColumn]); const handleColumnDragEnd = useCallback(() => { + isDraggingRef.current = false; setDraggedColumn(null); }, [setDraggedColumn]); const handleDragOver = useCallback((e: DragEvent) => { - if (draggedColumn) return; // Don't handle card drops when dragging columns + if (draggedColumn || isDraggingRef.current) return; // Don't handle card drops when dragging columns e.preventDefault(); setDropTarget(column); From cd3663e041c065e77d3fa0a30dba511632e59a29 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 23:29:13 +0300 Subject: [PATCH 192/233] chore(react/collections/board): fix add on blur if value not changed --- apps/client/src/widgets/collections/board/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 3475e3be9f..552f71476f 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -253,7 +253,7 @@ export function TitleEditor({ currentValue, placeholder, save, dismiss, multilin } }} onBlur={(newValue) => { - if (newValue !== currentValue) { + if (newValue !== currentValue || isNewItem) { save(newValue); } dismiss(); From b361cc06300894809b2f60ea2eab1cfeab27ceca Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 23:40:40 +0300 Subject: [PATCH 193/233] chore(react/collections/board): start with no name for new notes --- apps/client/src/translations/en/translation.json | 1 + apps/client/src/widgets/collections/board/column.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 634fe3c351..c084cf7ec5 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -2006,6 +2006,7 @@ "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", + "new-item-placeholder": "Enter note title...", "add-column": "Add Column", "add-column-placeholder": "Enter column name...", "edit-note-title": "Click to edit note title", diff --git a/apps/client/src/widgets/collections/board/column.tsx b/apps/client/src/widgets/collections/board/column.tsx index 269a612601..88fec26aaf 100644 --- a/apps/client/src/widgets/collections/board/column.tsx +++ b/apps/client/src/widgets/collections/board/column.tsx @@ -140,7 +140,7 @@ function AddNewItem({ column, api }: { column: string, api: BoardApi }) { ) : ( api.createNewItem(column, title)} dismiss={() => setIsCreatingNewItem(false)} multiline isNewItem From d908a1b0d2611e58200e5f9baba734d46e8da512 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 12 Sep 2025 23:41:38 +0300 Subject: [PATCH 194/233] chore(react/collections/board): ignore empty titles --- apps/client/src/widgets/collections/board/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 552f71476f..5bbfed9adc 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -242,7 +242,7 @@ export function TitleEditor({ currentValue, placeholder, save, dismiss, multilin onKeyDown={(e) => { if (e.key === "Enter") { const newValue = e.currentTarget.value; - if (newValue !== currentValue || isNewItem) { + if (newValue.trim() && (newValue !== currentValue || isNewItem)) { save(newValue); } dismiss(); @@ -253,7 +253,7 @@ export function TitleEditor({ currentValue, placeholder, save, dismiss, multilin } }} onBlur={(newValue) => { - if (newValue !== currentValue || isNewItem) { + if (newValue.trim() && (newValue !== currentValue || isNewItem)) { save(newValue); } dismiss(); From 220858926fa9c40eb846fe379c35ab21ea288f99 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Sep 2025 09:13:41 +0300 Subject: [PATCH 195/233] feat(react/collections/board): flickerless add new item --- .../src/widgets/collections/board/index.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 5bbfed9adc..2e77069fc7 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -7,7 +7,7 @@ import Icon from "../../react/Icon"; import { t } from "../../../services/i18n"; import Api from "./api"; import FormTextBox from "../../react/FormTextBox"; -import { createContext } from "preact"; +import { createContext, JSX } from "preact"; import { onWheelHorizontalScroll } from "../../widget_utils"; import Column from "./column"; import BoardApi from "./api"; @@ -225,6 +225,7 @@ export function TitleEditor({ currentValue, placeholder, save, dismiss, multilin isNewItem?: boolean; }) { const inputRef = useRef(null); + const dismissOnNextRefreshRef = useRef(false); useEffect(() => { inputRef.current?.focus(); @@ -233,19 +234,26 @@ export function TitleEditor({ currentValue, placeholder, save, dismiss, multilin const Element = multiline ? FormTextArea : FormTextBox; + useEffect(() => { + if (dismissOnNextRefreshRef.current) { + dismiss(); + dismissOnNextRefreshRef.current = false; + } + }); + return ( { + onKeyDown={(e: JSX.TargetedKeyboardEvent) => { if (e.key === "Enter") { - const newValue = e.currentTarget.value; + const newValue = e.currentTarget?.value; if (newValue.trim() && (newValue !== currentValue || isNewItem)) { save(newValue); + dismissOnNextRefreshRef.current = true; } - dismiss(); } if (e.key === "Escape") { @@ -255,8 +263,8 @@ export function TitleEditor({ currentValue, placeholder, save, dismiss, multilin onBlur={(newValue) => { if (newValue.trim() && (newValue !== currentValue || isNewItem)) { save(newValue); + dismissOnNextRefreshRef.current = true; } - dismiss(); }} /> ); From 3ce6b43018cada9a502865451e9ebbf57680e29d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Sep 2025 09:18:52 +0300 Subject: [PATCH 196/233] feat(react/collections/board): disable autofill when entering note title --- apps/client/src/widgets/collections/board/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 2e77069fc7..3b623f7cb3 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -246,6 +246,7 @@ export function TitleEditor({ currentValue, placeholder, save, dismiss, multilin 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) => { if (e.key === "Enter") { From 92a0faf47558865ac1fee6021c67eab84e8f1ee7 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Sep 2025 09:20:18 +0300 Subject: [PATCH 197/233] feat(react/collections/board): title editor not dismissing on blur --- apps/client/src/widgets/collections/board/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 3b623f7cb3..a71a117a39 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -265,6 +265,8 @@ export function TitleEditor({ currentValue, placeholder, save, dismiss, multilin if (newValue.trim() && (newValue !== currentValue || isNewItem)) { save(newValue); dismissOnNextRefreshRef.current = true; + } else { + dismiss(); } }} /> From dd930261bf04fe1f614979efd2a6dc6761b44c06 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Sep 2025 09:21:33 +0300 Subject: [PATCH 198/233] feat(react/collections/board): improve multiline in "New item" --- apps/client/src/widgets/collections/board/index.css | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/client/src/widgets/collections/board/index.css b/apps/client/src/widgets/collections/board/index.css index fa259ccb44..cf269eabc9 100644 --- a/apps/client/src/widgets/collections/board/index.css +++ b/apps/client/src/widgets/collections/board/index.css @@ -203,13 +203,10 @@ outline: none; font-family: inherit; font-size: inherit; - color: inherit; + color: var(--main-text-color); width: 100%; padding: var(--card-padding); line-height: var(--card-line-height); -} - -.board-view-container .board-note.editing textarea { height: auto; field-sizing: content; resize: none; From 679abc6e3e98670174854d8d326c7e83b09bd3a0 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Sep 2025 09:29:29 +0300 Subject: [PATCH 199/233] chore(react/collections/board): drag interfering with column title editing --- .../src/widgets/collections/board/column.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/client/src/widgets/collections/board/column.tsx b/apps/client/src/widgets/collections/board/column.tsx index 88fec26aaf..c9b6448527 100644 --- a/apps/client/src/widgets/collections/board/column.tsx +++ b/apps/client/src/widgets/collections/board/column.tsx @@ -16,7 +16,8 @@ import { DragData } from "../../note_tree"; interface DragContext { column: string; columnIndex: number, - columnItems?: { note: FNote, branch: FBranch }[] + columnItems?: { note: FNote, branch: FBranch }[]; + isEditing: boolean; } export default function Column({ @@ -34,7 +35,7 @@ export default function Column({ const isEditing = (columnNameToEdit === column); const editorRef = useRef(null); const { handleColumnDragStart, handleColumnDragEnd, handleDragOver, handleDragLeave, handleDrop } = useDragging({ - column, columnIndex, columnItems + column, columnIndex, columnItems, isEditing }); const handleEdit = useCallback(() => { @@ -150,18 +151,20 @@ function AddNewItem({ column, api }: { column: string, api: BoardApi }) { ); } -function useDragging({ column, columnIndex, columnItems }: DragContext) { +function useDragging({ column, columnIndex, columnItems, isEditing }: DragContext) { 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]); + }, [column, columnIndex, setDraggedColumn, isEditing]); const handleColumnDragEnd = useCallback(() => { isDraggingRef.current = false; @@ -169,7 +172,7 @@ function useDragging({ column, columnIndex, columnItems }: DragContext) { }, [setDraggedColumn]); const handleDragOver = useCallback((e: DragEvent) => { - if (draggedColumn || isDraggingRef.current) return; // Don't handle card drops when dragging columns + if (isEditing || draggedColumn || isDraggingRef.current) return; // Don't handle card drops when dragging columns e.preventDefault(); setDropTarget(column); @@ -192,7 +195,7 @@ function useDragging({ column, columnIndex, columnItems }: DragContext) { if (!(dropPosition?.column === column && dropPosition.index === newIndex)) { setDropPosition({ column, index: newIndex }); } - }, [column, setDropTarget, dropPosition, setDropPosition]); + }, [column, setDropTarget, dropPosition, setDropPosition, isEditing]); const handleDragLeave = useCallback((e: DragEvent) => { const relatedTarget = e.relatedTarget as HTMLElement; From 87648f340b00c98daef5b0a86e16ad2525678f22 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Sep 2025 09:31:37 +0300 Subject: [PATCH 200/233] chore(react/collections/board): prevent crash if dragging wrong JSON --- apps/client/src/widgets/collections/board/column.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/client/src/widgets/collections/board/column.tsx b/apps/client/src/widgets/collections/board/column.tsx index c9b6448527..ab211ca1bd 100644 --- a/apps/client/src/widgets/collections/board/column.tsx +++ b/apps/client/src/widgets/collections/board/column.tsx @@ -215,7 +215,13 @@ function useDragging({ column, columnIndex, columnItems, isEditing }: DragContex const data = e.dataTransfer?.getData("text"); if (!data) return; - const draggedCard = JSON.parse(data) as CardDragData | DragData[]; + + let draggedCard: CardDragData | DragData[]; + try { + draggedCard = JSON.parse(data); + } catch (e) { + return; + } if (Array.isArray(draggedCard)) { // From note tree. From b934b2b6cac5dc0001a837f117fc491966c71159 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Sep 2025 09:41:54 +0300 Subject: [PATCH 201/233] chore(react/collections/board): use custom type for dragging cards --- apps/client/src/widgets/collections/board/card.tsx | 4 +++- apps/client/src/widgets/collections/board/column.tsx | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/client/src/widgets/collections/board/card.tsx b/apps/client/src/widgets/collections/board/card.tsx index 68c1c1a377..bd377f2ec7 100644 --- a/apps/client/src/widgets/collections/board/card.tsx +++ b/apps/client/src/widgets/collections/board/card.tsx @@ -7,6 +7,8 @@ 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; @@ -39,7 +41,7 @@ export default function Card({ const handleDragStart = useCallback((e: DragEvent) => { e.dataTransfer!.effectAllowed = 'move'; const data: CardDragData = { noteId: note.noteId, branchId: branch.branchId, fromColumn: column, index }; - e.dataTransfer!.setData('text/plain', JSON.stringify(data)); + e.dataTransfer!.setData(CARD_CLIPBOARD_TYPE, JSON.stringify(data)); }, [note.noteId, branch.branchId, column, index]); const handleContextMenu = useCallback((e: ContextMenuEvent) => { diff --git a/apps/client/src/widgets/collections/board/column.tsx b/apps/client/src/widgets/collections/board/column.tsx index ab211ca1bd..37e095a443 100644 --- a/apps/client/src/widgets/collections/board/column.tsx +++ b/apps/client/src/widgets/collections/board/column.tsx @@ -8,7 +8,7 @@ import { ContextMenuEvent } from "../../../menus/context_menu"; import Icon from "../../react/Icon"; import { t } from "../../../services/i18n"; import BoardApi from "./api"; -import Card, { CardDragData } from "./card"; +import Card, { CARD_CLIPBOARD_TYPE, CardDragData } from "./card"; import { JSX } from "preact/jsx-runtime"; import froca from "../../../services/froca"; import { DragData } from "../../note_tree"; @@ -173,6 +173,8 @@ function useDragging({ column, columnIndex, columnItems, isEditing }: DragContex 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)) return; + e.preventDefault(); setDropTarget(column); @@ -213,7 +215,7 @@ function useDragging({ column, columnIndex, columnItems, isEditing }: DragContex setDropTarget(null); setDropPosition(null); - const data = e.dataTransfer?.getData("text"); + const data = e.dataTransfer?.getData(CARD_CLIPBOARD_TYPE); if (!data) return; let draggedCard: CardDragData | DragData[]; From ae5576f2a39a27e3e2ed58a30b3e62450b0df066 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Sep 2025 09:46:09 +0300 Subject: [PATCH 202/233] chore(react/collections/board): fix dragging from tree --- apps/client/src/widgets/collections/board/column.tsx | 6 +++--- apps/client/src/widgets/note_tree.ts | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/client/src/widgets/collections/board/column.tsx b/apps/client/src/widgets/collections/board/column.tsx index 37e095a443..f912439237 100644 --- a/apps/client/src/widgets/collections/board/column.tsx +++ b/apps/client/src/widgets/collections/board/column.tsx @@ -11,7 +11,7 @@ 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 } from "../../note_tree"; +import { DragData, TREE_CLIPBOARD_TYPE } from "../../note_tree"; interface DragContext { column: string; @@ -173,7 +173,7 @@ function useDragging({ column, columnIndex, columnItems, isEditing }: DragContex 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)) return; + if (!e.dataTransfer?.types.includes(CARD_CLIPBOARD_TYPE) && !e.dataTransfer.types.includes(TREE_CLIPBOARD_TYPE)) return; e.preventDefault(); setDropTarget(column); @@ -215,7 +215,7 @@ function useDragging({ column, columnIndex, columnItems, isEditing }: DragContex setDropTarget(null); setDropPosition(null); - const data = e.dataTransfer?.getData(CARD_CLIPBOARD_TYPE); + const data = e.dataTransfer?.getData(CARD_CLIPBOARD_TYPE) || e.dataTransfer?.getData("text"); if (!data) return; let draggedCard: CardDragData | DragData[]; diff --git a/apps/client/src/widgets/note_tree.ts b/apps/client/src/widgets/note_tree.ts index 2bbee7b36a..4636cd60d1 100644 --- a/apps/client/src/widgets/note_tree.ts +++ b/apps/client/src/widgets/note_tree.ts @@ -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; private $treeActions!: JQuery; From 7edfaad04ede49d5f73ff67996b5ad756d77143c Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Sep 2025 09:59:01 +0300 Subject: [PATCH 203/233] chore(react/collections/board): note not properly marked as dragged --- apps/client/src/widgets/collections/board/card.tsx | 8 +++++++- apps/client/src/widgets/collections/board/index.tsx | 6 ++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/client/src/widgets/collections/board/card.tsx b/apps/client/src/widgets/collections/board/card.tsx index bd377f2ec7..342f781ad9 100644 --- a/apps/client/src/widgets/collections/board/card.tsx +++ b/apps/client/src/widgets/collections/board/card.tsx @@ -31,7 +31,7 @@ export default function Card({ index: number, isDragging: boolean }) { - const { branchIdToEdit, setBranchIdToEdit } = useContext(BoardViewContext); + const { branchIdToEdit, setBranchIdToEdit, setDraggedCard } = useContext(BoardViewContext); const isEditing = branch.branchId === branchIdToEdit; const colorClass = note.getColorClass() || ''; const editorRef = useRef(null); @@ -41,9 +41,14 @@ export default function Card({ 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 ]); @@ -70,6 +75,7 @@ export default function Card({ className={`board-note ${colorClass} ${isDragging ? 'dragging' : ''} ${isEditing ? "editing" : ""} ${isArchived ? "archived" : ""}`} draggable="true" onDragStart={handleDragStart} + onDragEnd={handleDragEnd} onContextMenu={handleContextMenu} onClick={!isEditing ? handleOpen : undefined} > diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index a71a117a39..8b95da30b2 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -34,10 +34,12 @@ interface BoardViewContextData { dropPosition: { column: string, index: number } | null; setDropPosition: (position: { column: string, index: number } | null) => void; setDropTarget: (target: string | null) => void, - dropTarget: string | null + dropTarget: string | null; + draggedCard: { noteId: string, branchId: string, fromColumn: string, index: number } | null; + setDraggedCard: Dispatch>; } -export const BoardViewContext = createContext({}); +export const BoardViewContext = createContext(undefined); export default function BoardView({ note: parentNote, noteIds, viewConfig, saveConfig }: ViewModeProps) { const [ statusAttribute ] = useNoteLabelWithDefault(parentNote, "board:groupBy", "status"); From 8bde2092c6d5ff0cf9700a373537986d0f3e4d16 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Sep 2025 10:13:37 +0300 Subject: [PATCH 204/233] chore(react/collections/board): improve note dragging experience --- apps/client/src/widgets/collections/board/card.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/client/src/widgets/collections/board/card.tsx b/apps/client/src/widgets/collections/board/card.tsx index 342f781ad9..917fecefdc 100644 --- a/apps/client/src/widgets/collections/board/card.tsx +++ b/apps/client/src/widgets/collections/board/card.tsx @@ -36,6 +36,7 @@ export default function Card({ const colorClass = note.getColorClass() || ''; const editorRef = useRef(null); const isArchived = note.isArchived; + const [ isVisible, setVisible ] = useState(true); const [ title, setTitle ] = useState(note.title); const handleDragStart = useCallback((e: DragEvent) => { @@ -70,6 +71,10 @@ export default function Card({ setTitle(note.title); }, [ note ]); + useEffect(() => { + setVisible(!isDragging); + }, [ isDragging ]); + return (
{!isEditing ? ( <> From e77a49ace639e67eae5ffc2b2f15a049bd218417 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Sep 2025 10:42:10 +0300 Subject: [PATCH 205/233] chore(react/collections/board): improve column dragging experience slightly --- apps/client/src/widgets/collections/board/column.tsx | 10 +++++++++- apps/client/src/widgets/collections/board/index.css | 7 ------- apps/client/src/widgets/collections/board/index.tsx | 3 ++- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/client/src/widgets/collections/board/column.tsx b/apps/client/src/widgets/collections/board/column.tsx index f912439237..23b4813587 100644 --- a/apps/client/src/widgets/collections/board/column.tsx +++ b/apps/client/src/widgets/collections/board/column.tsx @@ -31,6 +31,7 @@ export default function Column({ isDraggingColumn: boolean, api: BoardApi } & DragContext) { + const [ isVisible, setVisible ] = useState(true); const { columnNameToEdit, setColumnNameToEdit, dropTarget, draggedCard, dropPosition } = useContext(BoardViewContext); const isEditing = (columnNameToEdit === column); const editorRef = useRef(null); @@ -61,13 +62,20 @@ export default function Column({ editorRef.current?.focus(); }, [ isEditing ]); + useEffect(() => { + setVisible(!isDraggingColumn); + }, [ isDraggingColumn ]); + return (

{ e.preventDefault(); if (draggedColumn && columnDropPosition !== null) { + console.log("Move ", draggedColumn.index, "at", columnDropPosition); handleColumnDrop(draggedColumn.index, columnDropPosition); } }, [draggedColumn, columnDropPosition, handleColumnDrop]); @@ -169,7 +170,7 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC > {byColumn && columns?.map((column, index) => ( <> - {columnDropPosition === index && draggedColumn?.column !== column && ( + {columnDropPosition === index && (
)} Date: Sat, 13 Sep 2025 11:01:39 +0300 Subject: [PATCH 206/233] chore(react/collections/board): fix column dragging offset --- .../src/widgets/collections/board/api.ts | 10 ++- .../src/widgets/collections/board/column.tsx | 15 ++++- .../src/widgets/collections/board/index.tsx | 66 +++++++++++-------- 3 files changed, 60 insertions(+), 31 deletions(-) diff --git a/apps/client/src/widgets/collections/board/api.ts b/apps/client/src/widgets/collections/board/api.ts index 61bcbe339a..90d41d7c49 100644 --- a/apps/client/src/widgets/collections/board/api.ts +++ b/apps/client/src/widgets/collections/board/api.ts @@ -106,7 +106,15 @@ export default class BoardApi { const newColumns = [...this.columns]; const [movedColumn] = newColumns.splice(fromIndex, 1); - newColumns.splice(toIndex, 0, movedColumn); + + // 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 = { diff --git a/apps/client/src/widgets/collections/board/column.tsx b/apps/client/src/widgets/collections/board/column.tsx index 23b4813587..5845373871 100644 --- a/apps/client/src/widgets/collections/board/column.tsx +++ b/apps/client/src/widgets/collections/board/column.tsx @@ -26,10 +26,14 @@ export default function Column({ isDraggingColumn, columnItems, api, + onColumnHover, + isAnyColumnDragging, }: { columnItems?: { note: FNote, branch: FBranch }[]; isDraggingColumn: boolean, - api: BoardApi + 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); @@ -66,10 +70,17 @@ export default function Column({ 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 (
(null); const [ draggedColumn, setDraggedColumn ] = useState<{ column: string, index: number } | null>(null); const [ columnDropPosition, setColumnDropPosition ] = useState(null); + const [ columnHoverIndex, setColumnHoverIndex ] = useState(null); const [ branchIdToEdit, setBranchIdToEdit ] = useState(); const [ columnNameToEdit, setColumnNameToEdit ] = useState(); const api = useMemo(() => { @@ -129,32 +130,31 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC const handleColumnDragOver = useCallback((e: DragEvent) => { if (!draggedColumn) return; e.preventDefault(); + }, [draggedColumn]); - const container = e.currentTarget as HTMLElement; - const columns = Array.from(container.querySelectorAll('.board-column')); - const mouseX = e.clientX; + const handleColumnHover = useCallback((visualIndex: number, mouseX: number, columnRect: DOMRect) => { + if (!draggedColumn) return; - let newIndex = columns.length; - for (let i = 0; i < columns.length; i++) { - const col = columns[i] as HTMLElement; - const rect = col.getBoundingClientRect(); - const colMiddle = rect.left + rect.width / 2; + const columnMiddle = columnRect.left + columnRect.width / 2; - if (mouseX < colMiddle) { - newIndex = i; - break; - } + // Determine drop position based on mouse position relative to column center + let dropIndex = mouseX < columnMiddle ? visualIndex : visualIndex + 1; + + // Convert visual index back to actual array index + if (draggedColumn.index <= visualIndex) { + // Add 1 because the dragged column (which is hidden) comes before this position + dropIndex += 1; } - setColumnDropPosition(newIndex); + setColumnDropPosition(dropIndex); }, [draggedColumn]); const handleContainerDrop = useCallback((e: DragEvent) => { e.preventDefault(); if (draggedColumn && columnDropPosition !== null) { - console.log("Move ", draggedColumn.index, "at", columnDropPosition); handleColumnDrop(draggedColumn.index, columnDropPosition); } + setColumnHoverIndex(null); }, [draggedColumn, columnDropPosition, handleColumnDrop]); return ( @@ -168,20 +168,30 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC onDragOver={handleColumnDragOver} onDrop={handleContainerDrop} > - {byColumn && columns?.map((column, index) => ( - <> - {columnDropPosition === index && ( -
- )} - - - ))} + {byColumn && columns?.map((column, index) => { + // Calculate visual index (skipping hidden dragged column) + let visualIndex = index; + if (draggedColumn && draggedColumn.index < index) { + visualIndex = index - 1; + } + + return ( + <> + {columnDropPosition === index && ( +
+ )} + handleColumnHover(visualIndex, mouseX, rect)} + isAnyColumnDragging={!!draggedColumn} + /> + + ); + })} {columnDropPosition === columns?.length && draggedColumn && (
)} From cbc2ee3cd1ff36fa51c1c545580f1dc33a24c176 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Sep 2025 11:02:56 +0300 Subject: [PATCH 207/233] chore(react/collections/board): simply column dragging slightly --- .../src/widgets/collections/board/index.tsx | 55 ++++++++----------- 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index b4055d6f8e..e11beb2032 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -132,21 +132,18 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC e.preventDefault(); }, [draggedColumn]); - const handleColumnHover = useCallback((visualIndex: number, mouseX: number, columnRect: DOMRect) => { + const handleColumnHover = useCallback((index: number, mouseX: number, columnRect: DOMRect) => { if (!draggedColumn) return; const columnMiddle = columnRect.left + columnRect.width / 2; - // Determine drop position based on mouse position relative to column center - let dropIndex = mouseX < columnMiddle ? visualIndex : visualIndex + 1; + // Determine if we should insert before or after this column + const insertBefore = mouseX < columnMiddle; - // Convert visual index back to actual array index - if (draggedColumn.index <= visualIndex) { - // Add 1 because the dragged column (which is hidden) comes before this position - dropIndex += 1; - } + // Calculate the target position + let targetIndex = insertBefore ? index : index + 1; - setColumnDropPosition(dropIndex); + setColumnDropPosition(targetIndex); }, [draggedColumn]); const handleContainerDrop = useCallback((e: DragEvent) => { @@ -168,30 +165,22 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC onDragOver={handleColumnDragOver} onDrop={handleContainerDrop} > - {byColumn && columns?.map((column, index) => { - // Calculate visual index (skipping hidden dragged column) - let visualIndex = index; - if (draggedColumn && draggedColumn.index < index) { - visualIndex = index - 1; - } - - return ( - <> - {columnDropPosition === index && ( -
- )} - handleColumnHover(visualIndex, mouseX, rect)} - isAnyColumnDragging={!!draggedColumn} - /> - - ); - })} + {byColumn && columns?.map((column, index) => ( + <> + {columnDropPosition === index && ( +
+ )} + + + ))} {columnDropPosition === columns?.length && draggedColumn && (
)} From f281e9691d4b8d9601af621ede3ee6ad2fb361c0 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Sep 2025 11:05:37 +0300 Subject: [PATCH 208/233] fix(react/ribbon/collection): default property not working --- apps/client/src/widgets/ribbon/CollectionPropertiesTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/widgets/ribbon/CollectionPropertiesTab.tsx b/apps/client/src/widgets/ribbon/CollectionPropertiesTab.tsx index 6180b23212..2594dd6c20 100644 --- a/apps/client/src/widgets/ribbon/CollectionPropertiesTab.tsx +++ b/apps/client/src/widgets/ribbon/CollectionPropertiesTab.tsx @@ -140,7 +140,7 @@ function ComboBoxPropertyView({ note, property }: { note: FNote, property: Combo ) From a162d697da426db02a41d26d625939d9fa4c3388 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Sep 2025 11:14:46 +0300 Subject: [PATCH 209/233] fix(react/collections/geomap): note shifting on its own randomly --- .../src/widgets/collections/geomap/index.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index 4f1784850e..b8e80ab794 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -38,6 +38,8 @@ enum State { export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewModeProps) { 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"); @@ -50,6 +52,12 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM 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({ @@ -122,10 +130,10 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM return (
- { if (!viewConfig) viewConfig = {}; @@ -137,7 +145,7 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM scale={hasScale} > {notes.map(note => )} - + }
); From a6833f5a6f4ff757d675d8e620356fc7a1bd8cb6 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Sep 2025 11:46:17 +0300 Subject: [PATCH 210/233] fix(react/notelist): normal list/grid not showing if text --- apps/client/src/widgets/collections/NoteList.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index 7f3da0c151..af5d831c8a 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -24,7 +24,7 @@ export default function NoteList({ note: providedNote, highlig const note = providedNote ?? contextNote; const viewType = useNoteViewType(note); const noteIds = useNoteIds(note, viewType); - const isFullHeight = (viewType !== "list" && viewType !== "grid"); + 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); @@ -39,8 +39,8 @@ export default function NoteList({ note: providedNote, highlig (entries) => { if (!isIntersecting) { setIsIntersecting(entries[0].isIntersecting); + observer.disconnect(); } - observer.disconnect(); }, { rootMargin: "50px", @@ -52,7 +52,7 @@ export default function NoteList({ note: providedNote, highlig // (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 | undefined | null = null; From 998688573dc8826b5a97f91d2d8166dc4d1d2229 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Sep 2025 12:00:20 +0300 Subject: [PATCH 211/233] refactor(server): integrate entity types changes into commons --- apps/server/src/routes/api/sync.ts | 3 +- apps/server/src/services/cls.ts | 2 +- .../server/src/services/consistency_checks.ts | 2 +- apps/server/src/services/entity_changes.ts | 2 +- .../src/services/entity_changes_interface.ts | 27 ------------------ apps/server/src/services/erase.ts | 2 +- apps/server/src/services/sync.ts | 2 +- apps/server/src/services/sync_update.ts | 2 +- apps/server/src/services/ws.ts | 2 +- packages/commons/src/lib/server_api.ts | 28 +++++++++++++++++++ 10 files changed, 36 insertions(+), 36 deletions(-) delete mode 100644 apps/server/src/services/entity_changes_interface.ts diff --git a/apps/server/src/routes/api/sync.ts b/apps/server/src/routes/api/sync.ts index 0e4ae2678f..5e1c53041c 100644 --- a/apps/server/src/routes/api/sync.ts +++ b/apps/server/src/routes/api/sync.ts @@ -12,11 +12,10 @@ import syncOptions from "../../services/sync_options.js"; import utils, { safeExtractMessageAndStackFromError } from "../../services/utils.js"; import ws from "../../services/ws.js"; import type { Request } from "express"; -import type { EntityChange } from "../../services/entity_changes_interface.js"; import ValidationError from "../../errors/validation_error.js"; import consistencyChecksService from "../../services/consistency_checks.js"; import { t } from "i18next"; -import { SyncTestResponse } from "@triliumnext/commons"; +import { SyncTestResponse, type EntityChange } from "@triliumnext/commons"; async function testSync(): Promise { try { diff --git a/apps/server/src/services/cls.ts b/apps/server/src/services/cls.ts index cd213748b0..7636be7dd0 100644 --- a/apps/server/src/services/cls.ts +++ b/apps/server/src/services/cls.ts @@ -1,5 +1,5 @@ import clsHooked from "cls-hooked"; -import type { EntityChange } from "./entity_changes_interface.js"; +import type { EntityChange } from "@triliumnext/commons"; const namespace = clsHooked.createNamespace("trilium"); type Callback = (...args: any[]) => any; diff --git a/apps/server/src/services/consistency_checks.ts b/apps/server/src/services/consistency_checks.ts index ec78505728..7b4ba72adf 100644 --- a/apps/server/src/services/consistency_checks.ts +++ b/apps/server/src/services/consistency_checks.ts @@ -15,7 +15,7 @@ import eraseService from "../services/erase.js"; import sanitizeAttributeName from "./sanitize_attribute_name.js"; import noteTypesService from "../services/note_types.js"; import type { BranchRow } from "@triliumnext/commons"; -import type { EntityChange } from "./entity_changes_interface.js"; +import type { EntityChange } from "@triliumnext/commons"; import becca_loader from "../becca/becca_loader.js"; const noteTypes = noteTypesService.getNoteTypeNames(); diff --git a/apps/server/src/services/entity_changes.ts b/apps/server/src/services/entity_changes.ts index 66c2613ced..c0a97c7d6b 100644 --- a/apps/server/src/services/entity_changes.ts +++ b/apps/server/src/services/entity_changes.ts @@ -6,7 +6,7 @@ import { randomString } from "./utils.js"; import instanceId from "./instance_id.js"; import becca from "../becca/becca.js"; import blobService from "../services/blob.js"; -import type { EntityChange } from "./entity_changes_interface.js"; +import type { EntityChange } from "@triliumnext/commons"; import type { Blob } from "./blob-interface.js"; import eventService from "./events.js"; diff --git a/apps/server/src/services/entity_changes_interface.ts b/apps/server/src/services/entity_changes_interface.ts deleted file mode 100644 index e69eb5c37e..0000000000 --- a/apps/server/src/services/entity_changes_interface.ts +++ /dev/null @@ -1,27 +0,0 @@ -export interface EntityChange { - id?: number | null; - noteId?: string; - entityName: string; - entityId: string; - entity?: any; - positions?: Record; - hash: string; - utcDateChanged?: string; - utcDateModified?: string; - utcDateCreated?: string; - isSynced: boolean | 1 | 0; - isErased: boolean | 1 | 0; - componentId?: string | null; - changeId?: string | null; - instanceId?: string | null; -} - -export interface EntityRow { - isDeleted?: boolean; - content?: Buffer | string; -} - -export interface EntityChangeRecord { - entityChange: EntityChange; - entity?: EntityRow; -} diff --git a/apps/server/src/services/erase.ts b/apps/server/src/services/erase.ts index d5f4d2b1d1..92b28e5735 100644 --- a/apps/server/src/services/erase.ts +++ b/apps/server/src/services/erase.ts @@ -5,7 +5,7 @@ import optionService from "./options.js"; import dateUtils from "./date_utils.js"; import sqlInit from "./sql_init.js"; import cls from "./cls.js"; -import type { EntityChange } from "./entity_changes_interface.js"; +import type { EntityChange } from "@triliumnext/commons"; function eraseNotes(noteIdsToErase: string[]) { if (noteIdsToErase.length === 0) { diff --git a/apps/server/src/services/sync.ts b/apps/server/src/services/sync.ts index a26fabf822..ef3bd6cba9 100644 --- a/apps/server/src/services/sync.ts +++ b/apps/server/src/services/sync.ts @@ -17,7 +17,7 @@ import ws from "./ws.js"; import entityChangesService from "./entity_changes.js"; import entityConstructor from "../becca/entity_constructor.js"; import becca from "../becca/becca.js"; -import type { EntityChange, EntityChangeRecord, EntityRow } from "./entity_changes_interface.js"; +import type { EntityChange, EntityChangeRecord, EntityRow } from "@triliumnext/commons"; import type { CookieJar, ExecOpts } from "./request_interface.js"; import setupService from "./setup.js"; import consistency_checks from "./consistency_checks.js"; diff --git a/apps/server/src/services/sync_update.ts b/apps/server/src/services/sync_update.ts index a22ba67173..9d4ff5c4c0 100644 --- a/apps/server/src/services/sync_update.ts +++ b/apps/server/src/services/sync_update.ts @@ -4,7 +4,7 @@ import entityChangesService from "./entity_changes.js"; import eventService from "./events.js"; import entityConstructor from "../becca/entity_constructor.js"; import ws from "./ws.js"; -import type { EntityChange, EntityChangeRecord, EntityRow } from "./entity_changes_interface.js"; +import type { EntityChange, EntityChangeRecord, EntityRow } from "@triliumnext/commons"; interface UpdateContext { alreadyErased: number; diff --git a/apps/server/src/services/ws.ts b/apps/server/src/services/ws.ts index c37cf7550d..71e94707e8 100644 --- a/apps/server/src/services/ws.ts +++ b/apps/server/src/services/ws.ts @@ -10,7 +10,7 @@ import becca from "../becca/becca.js"; import AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js"; import type { IncomingMessage, Server as HttpServer } from "http"; -import type { EntityChange } from "./entity_changes_interface.js"; +import { WebSocketMessage, type EntityChange } from "@triliumnext/commons"; let webSocketServer!: WebSocketServer; let lastSyncedPush: number | null = null; diff --git a/packages/commons/src/lib/server_api.ts b/packages/commons/src/lib/server_api.ts index 74570c75f2..0d56685cce 100644 --- a/packages/commons/src/lib/server_api.ts +++ b/packages/commons/src/lib/server_api.ts @@ -242,3 +242,31 @@ export interface SchemaResponse { type: string; }[]; } + +export interface EntityChange { + id?: number | null; + noteId?: string; + entityName: string; + entityId: string; + entity?: any; + positions?: Record; + hash: string; + utcDateChanged?: string; + utcDateModified?: string; + utcDateCreated?: string; + isSynced: boolean | 1 | 0; + isErased: boolean | 1 | 0; + componentId?: string | null; + changeId?: string | null; + instanceId?: string | null; +} + +export interface EntityRow { + isDeleted?: boolean; + content?: Buffer | string; +} + +export interface EntityChangeRecord { + entityChange: EntityChange; + entity?: EntityRow; +} From 4cd0702cbbe075b4d3a142c7b508ed9210d84e94 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Sep 2025 12:54:53 +0300 Subject: [PATCH 212/233] refactor: proper websocket message types --- apps/client/src/components/app_context.ts | 2 +- apps/client/src/services/branches.ts | 4 +- apps/client/src/services/file_watcher.ts | 14 +-- apps/client/src/services/import.ts | 11 ++- apps/client/src/services/protected_session.ts | 2 +- apps/client/src/services/ws.ts | 3 +- apps/client/src/widgets/dialogs/export.tsx | 4 +- apps/client/src/widgets/sync_status.ts | 4 +- .../src/widgets/watched_file_update_status.ts | 8 +- apps/server/src/routes/api/llm.ts | 45 ++++----- apps/server/src/services/import/mime.ts | 3 +- .../llm/chat/handlers/stream_handler.ts | 29 +++--- apps/server/src/services/llm/chat/index.ts | 4 +- .../services/llm/chat/rest_chat_service.ts | 19 ++-- .../llm/interfaces/chat_ws_messages.ts | 24 ----- apps/server/src/services/task_context.ts | 6 +- .../src/services/task_context_interface.ts | 7 -- apps/server/src/services/ws.ts | 52 +---------- packages/commons/src/lib/server_api.ts | 93 +++++++++++++++++++ 19 files changed, 164 insertions(+), 170 deletions(-) delete mode 100644 apps/server/src/services/llm/interfaces/chat_ws_messages.ts delete mode 100644 apps/server/src/services/task_context_interface.ts diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index cba5c91af3..ce33d14470 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -116,7 +116,7 @@ export type CommandMappings = { openedFileUpdated: CommandData & { entityType: string; entityId: string; - lastModifiedMs: number; + lastModifiedMs?: number; filePath: string; }; focusAndSelectTitle: CommandData & { diff --git a/apps/client/src/services/branches.ts b/apps/client/src/services/branches.ts index 86ec8e9a81..b1231e5987 100644 --- a/apps/client/src/services/branches.ts +++ b/apps/client/src/services/branches.ts @@ -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; } diff --git a/apps/client/src/services/file_watcher.ts b/apps/client/src/services/file_watcher.ts index cda3c2852c..c3df01b0e4 100644 --- a/apps/client/src/services/file_watcher.ts +++ b/apps/client/src/services/file_watcher.ts @@ -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> = { +const fileModificationStatus: Record> = { 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; } diff --git a/apps/client/src/services/import.ts b/apps/client/src/services/import.ts index 035bed6a69..6121ab422d 100644 --- a/apps/client/src/services/import.ts +++ b/apps/client/src/services/import.ts @@ -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; } @@ -81,14 +82,14 @@ ws.subscribeToMessages(async (message) => { toastService.showPersistent(toast); - if (message.result.importedNoteId) { + if (typeof message.result === "object" && message.result.importedNoteId) { await appContext.tabManager.getActiveContext()?.setNote(message.result.importedNoteId); } } }); -ws.subscribeToMessages(async (message) => { - if (message.taskType !== "importAttachments") { +ws.subscribeToMessages(async (message: WebSocketMessage) => { + if (!("taskType" in message) || message.taskType !== "importAttachments") { return; } @@ -103,7 +104,7 @@ ws.subscribeToMessages(async (message) => { toastService.showPersistent(toast); - if (message.result.parentNoteId) { + if (typeof message.result === "object" && message.result.parentNoteId) { await appContext.tabManager.getActiveContext()?.setNote(message.result.importedNoteId, { viewScope: { viewMode: "attachments" diff --git a/apps/client/src/services/protected_session.ts b/apps/client/src/services/protected_session.ts index fc34a805f3..94148a4557 100644 --- a/apps/client/src/services/protected_session.ts +++ b/apps/client/src/services/protected_session.ts @@ -107,7 +107,7 @@ 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; } diff --git a/apps/client/src/services/ws.ts b/apps/client/src/services/ws.ts index dcd63e5772..b873289b45 100644 --- a/apps/client/src/services/ws.ts +++ b/apps/client/src/services/ws.ts @@ -6,8 +6,9 @@ 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; +type MessageHandler = (message: WebSocketMessage) => void; const messageHandlers: MessageHandler[] = []; let ws: WebSocket; diff --git a/apps/client/src/widgets/dialogs/export.tsx b/apps/client/src/widgets/dialogs/export.tsx index 594104b663..441b863158 100644 --- a/apps/client/src/widgets/dialogs/export.tsx +++ b/apps/client/src/widgets/dialogs/export.tsx @@ -140,7 +140,7 @@ ws.subscribeToMessages(async (message) => { }; } - if (message.taskType !== "export") { + if (!("taskType" in message) || message.taskType !== "export") { return; } @@ -155,4 +155,4 @@ ws.subscribeToMessages(async (message) => { toastService.showPersistent(toast); } -}); \ No newline at end of file +}); diff --git a/apps/client/src/widgets/sync_status.ts b/apps/client/src/widgets/sync_status.ts index ce26b60786..d6bd8b6756 100644 --- a/apps/client/src/widgets/sync_status.ts +++ b/apps/client/src/widgets/sync_status.ts @@ -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*/`
@@ -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; diff --git a/apps/client/src/widgets/watched_file_update_status.ts b/apps/client/src/widgets/watched_file_update_status.ts index 5181c333b3..efec3d2af7 100644 --- a/apps/client/src/widgets/watched_file_update_status.ts +++ b/apps/client/src/widgets/watched_file_update_status.ts @@ -73,13 +73,13 @@ export default class WatchedFileUpdateStatusWidget extends NoteContextAwareWidge async refreshWithNote(note: FNote) { const { entityType, entityId } = this.getEntity(); - if (!entityType || !entityId) { - return; - } + if (!entityType || !entityId) return; const status = fileWatcher.getFileModificationStatus(entityType, entityId); this.$filePath.text(status.filePath); - this.$fileLastModified.text(dayjs.unix(status.lastModifiedMs / 1000).format("HH:mm:ss")); + if (status.lastModifiedMs) { + this.$fileLastModified.text(dayjs.unix(status.lastModifiedMs / 1000).format("HH:mm:ss")); + } } getEntity() { diff --git a/apps/server/src/routes/api/llm.ts b/apps/server/src/routes/api/llm.ts index a156606765..78573cedaf 100644 --- a/apps/server/src/routes/api/llm.ts +++ b/apps/server/src/routes/api/llm.ts @@ -1,18 +1,9 @@ import type { Request, Response } from "express"; import log from "../../services/log.js"; -import options from "../../services/options.js"; import restChatService from "../../services/llm/rest_chat_service.js"; import chatStorageService from '../../services/llm/chat_storage_service.js'; - -// Define basic interfaces -interface ChatMessage { - role: 'user' | 'assistant' | 'system'; - content: string; - timestamp?: Date; -} - - +import { WebSocketMessage } from "@triliumnext/commons"; /** * @swagger @@ -419,7 +410,7 @@ async function sendMessage(req: Request, res: Response) { */ async function streamMessage(req: Request, res: Response) { log.info("=== Starting streamMessage ==="); - + try { const chatNoteId = req.params.chatNoteId; const { content, useAdvancedContext, showThinking, mentions } = req.body; @@ -434,7 +425,7 @@ async function streamMessage(req: Request, res: Response) { (res as any).triliumResponseHandled = true; return; } - + // Send immediate success response res.status(200).json({ success: true, @@ -442,12 +433,12 @@ async function streamMessage(req: Request, res: Response) { }); // Mark response as handled to prevent further processing (res as any).triliumResponseHandled = true; - + // Start background streaming process after sending response handleStreamingProcess(chatNoteId, content, useAdvancedContext, showThinking, mentions) .catch(error => { log.error(`Background streaming error: ${error.message}`); - + // Send error via WebSocket since HTTP response was already sent import('../../services/ws.js').then(wsModule => { wsModule.default.sendMessageToAllClients({ @@ -460,11 +451,11 @@ async function streamMessage(req: Request, res: Response) { log.error(`Could not send WebSocket error: ${wsError}`); }); }); - + } catch (error) { // Handle any synchronous errors log.error(`Synchronous error in streamMessage: ${error}`); - + if (!res.headersSent) { res.status(500).json({ success: false, @@ -481,21 +472,21 @@ async function streamMessage(req: Request, res: Response) { * This is separate from the HTTP request/response cycle */ async function handleStreamingProcess( - chatNoteId: string, - content: string, - useAdvancedContext: boolean, - showThinking: boolean, + chatNoteId: string, + content: string, + useAdvancedContext: boolean, + showThinking: boolean, mentions: any[] ) { log.info("=== Starting background streaming process ==="); - + // Get or create chat directly from storage let chat = await chatStorageService.getChat(chatNoteId); if (!chat) { chat = await chatStorageService.createChat('New Chat'); log.info(`Created new chat with ID: ${chat.id} for stream request`); } - + // Add the user message to the chat immediately chat.messages.push({ role: 'user', @@ -544,9 +535,9 @@ async function handleStreamingProcess( thinking: showThinking ? 'Initializing streaming LLM response...' : undefined }); - // Instead of calling the complex handleSendMessage service, + // Instead of calling the complex handleSendMessage service, // let's implement streaming directly to avoid response conflicts - + try { // Check if AI is enabled const optionsModule = await import('../../services/options.js'); @@ -570,7 +561,7 @@ async function handleStreamingProcess( // Get selected model const { getSelectedModelConfig } = await import('../../services/llm/config/configuration_helpers.js'); const modelConfig = await getSelectedModelConfig(); - + if (!modelConfig) { throw new Error("No valid AI model configuration found"); } @@ -590,7 +581,7 @@ async function handleStreamingProcess( chatNoteId: chatNoteId }, streamCallback: (data, done, rawChunk) => { - const message = { + const message: WebSocketMessage = { type: 'llm-stream' as const, chatNoteId: chatNoteId, done: done @@ -634,7 +625,7 @@ async function handleStreamingProcess( // Execute the pipeline await pipeline.execute(pipelineInput); - + } catch (error: any) { log.error(`Error in direct streaming: ${error.message}`); wsService.sendMessageToAllClients({ diff --git a/apps/server/src/services/import/mime.ts b/apps/server/src/services/import/mime.ts index 479bd34941..cce580a088 100644 --- a/apps/server/src/services/import/mime.ts +++ b/apps/server/src/services/import/mime.ts @@ -2,8 +2,7 @@ import mimeTypes from "mime-types"; import path from "path"; -import type { TaskData } from "../task_context_interface.js"; -import type { NoteType } from "@triliumnext/commons"; +import type { NoteType, TaskData } from "@triliumnext/commons"; const CODE_MIME_TYPES = new Set([ "application/json", diff --git a/apps/server/src/services/llm/chat/handlers/stream_handler.ts b/apps/server/src/services/llm/chat/handlers/stream_handler.ts index 3aeb26d833..1ebca5d5a9 100644 --- a/apps/server/src/services/llm/chat/handlers/stream_handler.ts +++ b/apps/server/src/services/llm/chat/handlers/stream_handler.ts @@ -4,7 +4,6 @@ import log from "../../../log.js"; import type { Response } from "express"; import type { StreamChunk } from "../../ai_interface.js"; -import type { LLMStreamMessage } from "../../interfaces/chat_ws_messages.js"; import type { ChatSession } from "../../interfaces/chat_session.js"; /** @@ -46,7 +45,7 @@ export class StreamHandler { type: 'llm-stream', chatNoteId, thinking: 'Preparing response...' - } as LLMStreamMessage); + }); try { // Import the tool handler @@ -66,7 +65,7 @@ export class StreamHandler { type: 'llm-stream', chatNoteId, thinking: 'Analyzing tools needed for this request...' - } as LLMStreamMessage); + }); try { // Execute the tools @@ -82,7 +81,7 @@ export class StreamHandler { tool: toolResult.name, result: toolResult.content.substring(0, 100) + (toolResult.content.length > 100 ? '...' : '') } - } as LLMStreamMessage); + }); } // Make follow-up request with tool results @@ -123,7 +122,7 @@ export class StreamHandler { chatNoteId, error: `Error executing tools: ${toolError instanceof Error ? toolError.message : 'Unknown error'}`, done: true - } as LLMStreamMessage); + }); } } else if (response.stream) { // Handle standard streaming through the stream() method @@ -152,7 +151,7 @@ export class StreamHandler { chatNoteId, content: messageContent, done: true - } as LLMStreamMessage); + }); log.info(`Complete response sent`); @@ -174,14 +173,14 @@ export class StreamHandler { type: 'llm-stream', chatNoteId, error: `Error generating response: ${streamingError instanceof Error ? streamingError.message : 'Unknown error'}` - } as LLMStreamMessage); + }); // Signal completion wsService.sendMessageToAllClients({ type: 'llm-stream', chatNoteId, done: true - } as LLMStreamMessage); + }); } } @@ -218,7 +217,7 @@ export class StreamHandler { done: !!chunk.done, // Include done flag with each chunk // Include any raw data from the provider that might contain thinking/tool info ...(chunk.raw ? { raw: chunk.raw } : {}) - } as LLMStreamMessage); + }); // Log the first chunk (useful for debugging) if (messageContent.length === chunk.text.length) { @@ -232,7 +231,7 @@ export class StreamHandler { type: 'llm-stream', chatNoteId, thinking: chunk.raw.thinking - } as LLMStreamMessage); + }); } // If the provider indicates tool execution, relay that @@ -241,7 +240,7 @@ export class StreamHandler { type: 'llm-stream', chatNoteId, toolExecution: chunk.raw.toolExecution - } as LLMStreamMessage); + }); } // Handle direct tool_calls in the response (for OpenAI) @@ -252,7 +251,7 @@ export class StreamHandler { wsService.sendMessageToAllClients({ type: 'tool_execution_start', chatNoteId - } as LLMStreamMessage); + }); // Process each tool call for (const toolCall of chunk.tool_calls) { @@ -277,7 +276,7 @@ export class StreamHandler { toolCallId: toolCall.id, args: args } - } as LLMStreamMessage); + }); } } @@ -337,7 +336,7 @@ export class StreamHandler { type: 'llm-stream', chatNoteId, done: true - } as LLMStreamMessage); + }); } // Store the full response in the session @@ -360,7 +359,7 @@ export class StreamHandler { chatNoteId, error: `Error during streaming: ${streamError instanceof Error ? streamError.message : 'Unknown error'}`, done: true - } as LLMStreamMessage); + }); throw streamError; } diff --git a/apps/server/src/services/llm/chat/index.ts b/apps/server/src/services/llm/chat/index.ts index 79b587a098..622f65374f 100644 --- a/apps/server/src/services/llm/chat/index.ts +++ b/apps/server/src/services/llm/chat/index.ts @@ -7,7 +7,6 @@ import { ToolHandler } from './handlers/tool_handler.js'; import { StreamHandler } from './handlers/stream_handler.js'; import * as messageFormatter from './utils/message_formatter.js'; import type { ChatSession, ChatMessage, NoteSource } from '../interfaces/chat_session.js'; -import type { LLMStreamMessage } from '../interfaces/chat_ws_messages.js'; // Export components export { @@ -22,6 +21,5 @@ export { export type { ChatSession, ChatMessage, - NoteSource, - LLMStreamMessage + NoteSource }; diff --git a/apps/server/src/services/llm/chat/rest_chat_service.ts b/apps/server/src/services/llm/chat/rest_chat_service.ts index 5bf57c0424..45af7e944d 100644 --- a/apps/server/src/services/llm/chat/rest_chat_service.ts +++ b/apps/server/src/services/llm/chat/rest_chat_service.ts @@ -4,18 +4,15 @@ */ import log from "../../log.js"; import type { Request, Response } from "express"; -import type { Message, ChatCompletionOptions } from "../ai_interface.js"; +import type { Message } from "../ai_interface.js"; import aiServiceManager from "../ai_service_manager.js"; import { ChatPipeline } from "../pipeline/chat_pipeline.js"; import type { ChatPipelineInput } from "../pipeline/interfaces.js"; import options from "../../options.js"; import { ToolHandler } from "./handlers/tool_handler.js"; -import type { LLMStreamMessage } from "../interfaces/chat_ws_messages.js"; import chatStorageService from '../chat_storage_service.js'; -import { - isAIEnabled, - getSelectedModelConfig, -} from '../config/configuration_helpers.js'; +import { getSelectedModelConfig } from '../config/configuration_helpers.js'; +import { WebSocketMessage } from "@triliumnext/commons"; /** * Simplified service to handle chat API interactions @@ -79,7 +76,7 @@ class RestChatService { throw new Error("Database is not initialized"); } - // Get or create AI service - will throw meaningful error if not possible + // Get or create AI service - will throw meaningful error if not possible await aiServiceManager.getOrCreateAnyService(); // Load or create chat directly from storage @@ -204,7 +201,7 @@ class RestChatService { accumulatedContentRef: { value: string }, chat: { id: string; messages: Message[]; title: string } ) { - const message: LLMStreamMessage = { + const message: WebSocketMessage = { type: 'llm-stream', chatNoteId: chatNoteId, done: done @@ -237,7 +234,7 @@ class RestChatService { // Send WebSocket message wsService.sendMessageToAllClients(message); - + // When streaming is complete, save the accumulated content to the chat note if (done) { try { @@ -248,7 +245,7 @@ class RestChatService { role: 'assistant', content: accumulatedContentRef.value }); - + // Save the updated chat back to storage await chatStorageService.updateChat(chat.id, chat.messages, chat.title); log.info(`Saved streaming assistant response: ${accumulatedContentRef.value.length} characters`); @@ -257,7 +254,7 @@ class RestChatService { // Log error but don't break the response flow log.error(`Error saving streaming response: ${error}`); } - + // Note: For WebSocket-only streaming, we don't end the HTTP response here // since it was already handled by the calling endpoint } diff --git a/apps/server/src/services/llm/interfaces/chat_ws_messages.ts b/apps/server/src/services/llm/interfaces/chat_ws_messages.ts deleted file mode 100644 index f75d399f4c..0000000000 --- a/apps/server/src/services/llm/interfaces/chat_ws_messages.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Interfaces for WebSocket LLM streaming messages - */ - -/** - * Interface for WebSocket LLM streaming messages - */ -export interface LLMStreamMessage { - type: 'llm-stream' | 'tool_execution_start' | 'tool_result' | 'tool_execution_error' | 'tool_completion_processing'; - chatNoteId: string; - content?: string; - thinking?: string; - toolExecution?: { - action?: string; - tool?: string; - toolCallId?: string; - result?: string | Record; - error?: string; - args?: Record; - }; - done?: boolean; - error?: string; - raw?: unknown; -} diff --git a/apps/server/src/services/task_context.ts b/apps/server/src/services/task_context.ts index f83154147e..7cef303f83 100644 --- a/apps/server/src/services/task_context.ts +++ b/apps/server/src/services/task_context.ts @@ -1,6 +1,6 @@ "use strict"; -import type { TaskData } from "./task_context_interface.js"; +import type { TaskData } from "@triliumnext/commons"; import ws from "./ws.js"; // taskId => TaskContext @@ -61,7 +61,7 @@ class TaskContext { taskId: this.taskId, taskType: this.taskType, data: this.data, - message: message + message }); } @@ -71,7 +71,7 @@ class TaskContext { taskId: this.taskId, taskType: this.taskType, data: this.data, - result: result + result }); } } diff --git a/apps/server/src/services/task_context_interface.ts b/apps/server/src/services/task_context_interface.ts deleted file mode 100644 index 3c359d7423..0000000000 --- a/apps/server/src/services/task_context_interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface TaskData { - safeImport?: boolean; - textImportedAsText?: boolean; - codeImportedAsCode?: boolean; - shrinkImages?: boolean; - replaceUnderscoresWithSpaces?: boolean; -} diff --git a/apps/server/src/services/ws.ts b/apps/server/src/services/ws.ts index 71e94707e8..f89b44869b 100644 --- a/apps/server/src/services/ws.ts +++ b/apps/server/src/services/ws.ts @@ -1,5 +1,5 @@ import { WebSocketServer as WebSocketServer, WebSocket } from "ws"; -import { isDev, isElectron, randomString } from "./utils.js"; +import { isElectron, randomString } from "./utils.js"; import log from "./log.js"; import sql from "./sql.js"; import cls from "./cls.js"; @@ -15,52 +15,6 @@ import { WebSocketMessage, type EntityChange } from "@triliumnext/commons"; let webSocketServer!: WebSocketServer; let lastSyncedPush: number | null = null; -interface Message { - type: string; - data?: { - lastSyncedPush?: number | null; - entityChanges?: any[]; - shrinkImages?: boolean; - } | null; - lastSyncedPush?: number | null; - - progressCount?: number; - taskId?: string; - taskType?: string | null; - message?: string; - reason?: string; - result?: string | Record; - - script?: string; - params?: any[]; - noteId?: string; - messages?: string[]; - startNoteId?: string; - currentNoteId?: string; - entityType?: string; - entityId?: string; - originEntityName?: "notes"; - originEntityId?: string | null; - lastModifiedMs?: number; - filePath?: string; - - // LLM streaming specific fields - chatNoteId?: string; - content?: string; - thinking?: string; - toolExecution?: { - action?: string; - tool?: string; - toolCallId?: string; - result?: string | Record; - error?: string; - args?: Record; - }; - done?: boolean; - error?: string; - raw?: unknown; -} - type SessionParser = (req: IncomingMessage, params: {}, cb: () => void) => void; function init(httpServer: HttpServer, sessionParser: SessionParser) { webSocketServer = new WebSocketServer({ @@ -106,7 +60,7 @@ Stack: ${message.stack}`); }); } -function sendMessage(client: WebSocket, message: Message) { +function sendMessage(client: WebSocket, message: WebSocketMessage) { const jsonStr = JSON.stringify(message); if (client.readyState === WebSocket.OPEN) { @@ -114,7 +68,7 @@ function sendMessage(client: WebSocket, message: Message) { } } -function sendMessageToAllClients(message: Message) { +function sendMessageToAllClients(message: WebSocketMessage) { const jsonStr = JSON.stringify(message); if (webSocketServer) { diff --git a/packages/commons/src/lib/server_api.ts b/packages/commons/src/lib/server_api.ts index 0d56685cce..2454fbf1bb 100644 --- a/packages/commons/src/lib/server_api.ts +++ b/packages/commons/src/lib/server_api.ts @@ -270,3 +270,96 @@ export interface EntityChangeRecord { entityChange: EntityChange; entity?: EntityRow; } + +type TaskStatus = { + type: "taskProgressCount", + taskId: string; + taskType: TypeT; + data: DataT, + progressCount: number +} | { + type: "taskError", + taskId: string; + taskType: TypeT; + data: DataT; + message: string; +} | { + type: "taskSucceeded", + taskId: string; + taskType: TypeT; + data: DataT; + result?: string | Record +} + +type TaskDefinitions = + TaskStatus<"protectNotes", { protect: boolean; }> + | TaskStatus<"importNotes", null> + | TaskStatus<"importAttachments", null> + | TaskStatus<"deleteNotes", null> + | TaskStatus<"undeleteNotes", null> + | TaskStatus<"export", null> +; + +export interface OpenedFileUpdateStatus { + entityType: string; + entityId: string; + lastModifiedMs?: number; + filePath: string; +} + +export type WebSocketMessage = TaskDefinitions | { + type: "ping" +} | { + type: "frontend-update", + data: { + lastSyncedPush: number, + entityChanges: EntityChange[] + } +} | { + type: "openNote", + noteId: string +} | OpenedFileUpdateStatus & { + type: "openedFileUpdated" +} | { + type: "protectedSessionLogin" +} | { + type: "protectedSessionLogout" +} | { + type: "toast", + message: string; +} | { + type: "api-log-messages", + noteId: string, + messages: string[] +} | { + type: "execute-script"; + script: string; + params: unknown[]; + startNoteId?: string; + currentNoteId: string; + originEntityName: string; + originEntityId?: string | null; +} | { + type: "reload-frontend"; + reason: string; +} | { + type: "sync-pull-in-progress" | "sync-push-in-progress" | "sync-finished" | "sync-failed"; + lastSyncedPush: number; +} | { + type: "consistency-checks-failed" +} | { + type: "llm-stream", + chatNoteId: string; + done?: boolean; + error?: string; + thinking?: string; + content?: string; + toolExecution?: { + action?: string; + tool?: string; + toolCallId?: string; + result?: string | Record; + error?: string; + args?: Record; + } +} From 39fecb3ffed624dab67ae827f00ffb151bf772f4 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Sep 2025 13:06:28 +0300 Subject: [PATCH 213/233] refactor: further improve task context types --- apps/client/src/services/import.ts | 4 ++-- apps/server/src/services/task_context.ts | 12 ++++++------ packages/commons/src/lib/server_api.ts | 18 ++++++++++-------- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/apps/client/src/services/import.ts b/apps/client/src/services/import.ts index 6121ab422d..2300ca101f 100644 --- a/apps/client/src/services/import.ts +++ b/apps/client/src/services/import.ts @@ -82,7 +82,7 @@ ws.subscribeToMessages(async (message) => { toastService.showPersistent(toast); - if (typeof message.result === "object" && message.result.importedNoteId) { + if (message.result.importedNoteId) { await appContext.tabManager.getActiveContext()?.setNote(message.result.importedNoteId); } } @@ -104,7 +104,7 @@ ws.subscribeToMessages(async (message: WebSocketMessage) => { toastService.showPersistent(toast); - if (typeof message.result === "object" && message.result.parentNoteId) { + if (message.result.parentNoteId) { await appContext.tabManager.getActiveContext()?.setNote(message.result.importedNoteId, { viewScope: { viewMode: "attachments" diff --git a/apps/server/src/services/task_context.ts b/apps/server/src/services/task_context.ts index 7cef303f83..60ece320b1 100644 --- a/apps/server/src/services/task_context.ts +++ b/apps/server/src/services/task_context.ts @@ -1,20 +1,20 @@ "use strict"; -import type { TaskData } from "@triliumnext/commons"; +import type { TaskType } from "@triliumnext/commons"; import ws from "./ws.js"; // taskId => TaskContext -const taskContexts: Record = {}; +const taskContexts: Record> = {}; -class TaskContext { +class TaskContext { private taskId: string; - private taskType: string | null; + private taskType: TaskType; private progressCount: number; private lastSentCountTs: number; data: TaskData | null; noteDeletionHandlerTriggered: boolean; - constructor(taskId: string, taskType: string | null = null, data: {} | null = {}) { + constructor(taskId: string, taskType: TaskTypeT, data: {} | null = {}) { this.taskId = taskId; this.taskType = taskType; this.data = data; @@ -31,7 +31,7 @@ class TaskContext { this.increaseProgressCount(); } - static getInstance(taskId: string, taskType: string, data: {} | null = null): TaskContext { + static getInstance(taskId: string, taskType: TaskTypeT, data: {} | null = null): TaskContext { if (!taskContexts[taskId]) { taskContexts[taskId] = new TaskContext(taskId, taskType, data); } diff --git a/packages/commons/src/lib/server_api.ts b/packages/commons/src/lib/server_api.ts index 2454fbf1bb..114ff4eea0 100644 --- a/packages/commons/src/lib/server_api.ts +++ b/packages/commons/src/lib/server_api.ts @@ -271,7 +271,7 @@ export interface EntityChangeRecord { entity?: EntityRow; } -type TaskStatus = { +type TaskStatus = { type: "taskProgressCount", taskId: string; taskType: TypeT; @@ -288,18 +288,20 @@ type TaskStatus = { taskId: string; taskType: TypeT; data: DataT; - result?: string | Record + result: ResultT; } type TaskDefinitions = - TaskStatus<"protectNotes", { protect: boolean; }> - | TaskStatus<"importNotes", null> - | TaskStatus<"importAttachments", null> - | TaskStatus<"deleteNotes", null> - | TaskStatus<"undeleteNotes", null> - | TaskStatus<"export", null> + TaskStatus<"protectNotes", { protect: boolean; }, null> + | TaskStatus<"importNotes", null, { importedNoteId: string }> + | TaskStatus<"importAttachments", null, { parentNoteId?: string; importedNoteId: string }> + | TaskStatus<"deleteNotes", null, null> + | TaskStatus<"undeleteNotes", null, null> + | TaskStatus<"export", null, null> ; +export type TaskType = TaskDefinitions["taskType"]; + export interface OpenedFileUpdateStatus { entityType: string; entityId: string; From 777d5ab3b7a277716eb6cf1f1b840f08d2dc20c6 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Sep 2025 13:07:31 +0300 Subject: [PATCH 214/233] refactor: extract WS API into separate file --- packages/commons/src/index.ts | 1 + packages/commons/src/lib/server_api.ts | 123 ------------------------- packages/commons/src/lib/ws_api.ts | 122 ++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 123 deletions(-) create mode 100644 packages/commons/src/lib/ws_api.ts diff --git a/packages/commons/src/index.ts b/packages/commons/src/index.ts index 432990bc00..7bbde59ffc 100644 --- a/packages/commons/src/index.ts +++ b/packages/commons/src/index.ts @@ -8,3 +8,4 @@ export * from "./lib/mime_type.js"; export * from "./lib/bulk_actions.js"; export * from "./lib/server_api.js"; export * from "./lib/shared_constants.js"; +export * from "./lib/ws_api.js"; diff --git a/packages/commons/src/lib/server_api.ts b/packages/commons/src/lib/server_api.ts index 114ff4eea0..74570c75f2 100644 --- a/packages/commons/src/lib/server_api.ts +++ b/packages/commons/src/lib/server_api.ts @@ -242,126 +242,3 @@ export interface SchemaResponse { type: string; }[]; } - -export interface EntityChange { - id?: number | null; - noteId?: string; - entityName: string; - entityId: string; - entity?: any; - positions?: Record; - hash: string; - utcDateChanged?: string; - utcDateModified?: string; - utcDateCreated?: string; - isSynced: boolean | 1 | 0; - isErased: boolean | 1 | 0; - componentId?: string | null; - changeId?: string | null; - instanceId?: string | null; -} - -export interface EntityRow { - isDeleted?: boolean; - content?: Buffer | string; -} - -export interface EntityChangeRecord { - entityChange: EntityChange; - entity?: EntityRow; -} - -type TaskStatus = { - type: "taskProgressCount", - taskId: string; - taskType: TypeT; - data: DataT, - progressCount: number -} | { - type: "taskError", - taskId: string; - taskType: TypeT; - data: DataT; - message: string; -} | { - type: "taskSucceeded", - taskId: string; - taskType: TypeT; - data: DataT; - result: ResultT; -} - -type TaskDefinitions = - TaskStatus<"protectNotes", { protect: boolean; }, null> - | TaskStatus<"importNotes", null, { importedNoteId: string }> - | TaskStatus<"importAttachments", null, { parentNoteId?: string; importedNoteId: string }> - | TaskStatus<"deleteNotes", null, null> - | TaskStatus<"undeleteNotes", null, null> - | TaskStatus<"export", null, null> -; - -export type TaskType = TaskDefinitions["taskType"]; - -export interface OpenedFileUpdateStatus { - entityType: string; - entityId: string; - lastModifiedMs?: number; - filePath: string; -} - -export type WebSocketMessage = TaskDefinitions | { - type: "ping" -} | { - type: "frontend-update", - data: { - lastSyncedPush: number, - entityChanges: EntityChange[] - } -} | { - type: "openNote", - noteId: string -} | OpenedFileUpdateStatus & { - type: "openedFileUpdated" -} | { - type: "protectedSessionLogin" -} | { - type: "protectedSessionLogout" -} | { - type: "toast", - message: string; -} | { - type: "api-log-messages", - noteId: string, - messages: string[] -} | { - type: "execute-script"; - script: string; - params: unknown[]; - startNoteId?: string; - currentNoteId: string; - originEntityName: string; - originEntityId?: string | null; -} | { - type: "reload-frontend"; - reason: string; -} | { - type: "sync-pull-in-progress" | "sync-push-in-progress" | "sync-finished" | "sync-failed"; - lastSyncedPush: number; -} | { - type: "consistency-checks-failed" -} | { - type: "llm-stream", - chatNoteId: string; - done?: boolean; - error?: string; - thinking?: string; - content?: string; - toolExecution?: { - action?: string; - tool?: string; - toolCallId?: string; - result?: string | Record; - error?: string; - args?: Record; - } -} diff --git a/packages/commons/src/lib/ws_api.ts b/packages/commons/src/lib/ws_api.ts new file mode 100644 index 0000000000..dca860784d --- /dev/null +++ b/packages/commons/src/lib/ws_api.ts @@ -0,0 +1,122 @@ +export interface EntityChange { + id?: number | null; + noteId?: string; + entityName: string; + entityId: string; + entity?: any; + positions?: Record; + hash: string; + utcDateChanged?: string; + utcDateModified?: string; + utcDateCreated?: string; + isSynced: boolean | 1 | 0; + isErased: boolean | 1 | 0; + componentId?: string | null; + changeId?: string | null; + instanceId?: string | null; +} + +export interface EntityRow { + isDeleted?: boolean; + content?: Buffer | string; +} + +export interface EntityChangeRecord { + entityChange: EntityChange; + entity?: EntityRow; +} + +type TaskStatus = { + type: "taskProgressCount", + taskId: string; + taskType: TypeT; + data: DataT, + progressCount: number +} | { + type: "taskError", + taskId: string; + taskType: TypeT; + data: DataT; + message: string; +} | { + type: "taskSucceeded", + taskId: string; + taskType: TypeT; + data: DataT; + result: ResultT; +} + +type TaskDefinitions = + TaskStatus<"protectNotes", { protect: boolean; }, null> + | TaskStatus<"importNotes", null, { importedNoteId: string }> + | TaskStatus<"importAttachments", null, { parentNoteId?: string; importedNoteId: string }> + | TaskStatus<"deleteNotes", null, null> + | TaskStatus<"undeleteNotes", null, null> + | TaskStatus<"export", null, null> +; + +export type TaskType = TaskDefinitions["taskType"]; + +export interface OpenedFileUpdateStatus { + entityType: string; + entityId: string; + lastModifiedMs?: number; + filePath: string; +} + +export type WebSocketMessage = TaskDefinitions | { + type: "ping" +} | { + type: "frontend-update", + data: { + lastSyncedPush: number, + entityChanges: EntityChange[] + } +} | { + type: "openNote", + noteId: string +} | OpenedFileUpdateStatus & { + type: "openedFileUpdated" +} | { + type: "protectedSessionLogin" +} | { + type: "protectedSessionLogout" +} | { + type: "toast", + message: string; +} | { + type: "api-log-messages", + noteId: string, + messages: string[] +} | { + type: "execute-script"; + script: string; + params: unknown[]; + startNoteId?: string; + currentNoteId: string; + originEntityName: string; + originEntityId?: string | null; +} | { + type: "reload-frontend"; + reason: string; +} | { + type: "sync-pull-in-progress" | "sync-push-in-progress" | "sync-finished" | "sync-failed"; + lastSyncedPush: number; +} | { + type: "consistency-checks-failed" +} | { + type: "llm-stream", + chatNoteId: string; + done?: boolean; + error?: string; + thinking?: string; + content?: string; + toolExecution?: { + action?: string; + tool?: string; + toolCallId?: string; + result?: string | Record; + error?: string; + args?: Record; + } +} From 9c8b0611eac90299911e11d60ccec76abc207754 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Sep 2025 13:44:23 +0300 Subject: [PATCH 215/233] refactor: add typesafety to TaskContext --- apps/client/src/services/protected_session.ts | 2 +- apps/server/src/becca/entities/bbranch.ts | 4 +- apps/server/src/becca/entities/bnote.ts | 4 +- apps/server/src/etapi/notes.ts | 6 +- apps/server/src/routes/api/branches.ts | 4 +- apps/server/src/routes/api/export.ts | 2 +- apps/server/src/routes/api/import.ts | 2 +- apps/server/src/routes/api/notes.ts | 10 +-- apps/server/src/services/export/opml.ts | 4 +- apps/server/src/services/export/single.ts | 4 +- apps/server/src/services/export/zip.ts | 6 +- apps/server/src/services/import/enex.ts | 2 +- apps/server/src/services/import/mime.ts | 6 +- apps/server/src/services/import/opml.ts | 2 +- apps/server/src/services/import/single.ts | 18 ++--- apps/server/src/services/import/zip.ts | 4 +- apps/server/src/services/notes.ts | 6 +- apps/server/src/services/sql_init.ts | 2 +- apps/server/src/services/task_context.ts | 20 ++--- apps/server/src/services/ws.ts | 2 +- packages/commons/src/lib/ws_api.ts | 76 ++++++++++++++----- 21 files changed, 111 insertions(+), 75 deletions(-) diff --git a/apps/client/src/services/protected_session.ts b/apps/client/src/services/protected_session.ts index 94148a4557..1e1984ae5d 100644 --- a/apps/client/src/services/protected_session.ts +++ b/apps/client/src/services/protected_session.ts @@ -111,7 +111,7 @@ ws.subscribeToMessages(async (message) => { 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") { diff --git a/apps/server/src/becca/entities/bbranch.ts b/apps/server/src/becca/entities/bbranch.ts index 00e3ec4b75..cd50fe09bb 100644 --- a/apps/server/src/becca/entities/bbranch.ts +++ b/apps/server/src/becca/entities/bbranch.ts @@ -137,13 +137,13 @@ class BBranch extends AbstractBeccaEntity { * * @returns true if note has been deleted, false otherwise */ - deleteBranch(deleteId?: string, taskContext?: TaskContext): boolean { + deleteBranch(deleteId?: string, taskContext?: TaskContext<"deleteNotes">): boolean { if (!deleteId) { deleteId = utils.randomString(10); } if (!taskContext) { - taskContext = new TaskContext("no-progress-reporting"); + taskContext = new TaskContext("no-progress-reporting", "deleteNotes", null); } taskContext.increaseProgressCount(); diff --git a/apps/server/src/becca/entities/bnote.ts b/apps/server/src/becca/entities/bnote.ts index 68c82702b4..1a724b1b06 100644 --- a/apps/server/src/becca/entities/bnote.ts +++ b/apps/server/src/becca/entities/bnote.ts @@ -1512,7 +1512,7 @@ class BNote extends AbstractBeccaEntity { * * @param deleteId - optional delete identified */ - deleteNote(deleteId: string | null = null, taskContext: TaskContext | null = null) { + deleteNote(deleteId: string | null = null, taskContext: TaskContext<"deleteNotes"> | null = null) { if (this.isDeleted) { return; } @@ -1522,7 +1522,7 @@ class BNote extends AbstractBeccaEntity { } if (!taskContext) { - taskContext = new TaskContext("no-progress-reporting"); + taskContext = new TaskContext("no-progress-reporting", "deleteNotes", null); } // needs to be run before branches and attributes are deleted and thus attached relations disappear diff --git a/apps/server/src/etapi/notes.ts b/apps/server/src/etapi/notes.ts index e7a1c0a1ce..07d6a3e68d 100644 --- a/apps/server/src/etapi/notes.ts +++ b/apps/server/src/etapi/notes.ts @@ -108,7 +108,7 @@ function register(router: Router) { return res.sendStatus(204); } - note.deleteNote(null, new TaskContext("no-progress-reporting")); + note.deleteNote(null, new TaskContext("no-progress-reporting", "deleteNotes", null)); res.sendStatus(204); }); @@ -153,7 +153,7 @@ function register(router: Router) { throw new eu.EtapiError(400, "UNRECOGNIZED_EXPORT_FORMAT", `Unrecognized export format '${format}', supported values are 'html' (default) or 'markdown'.`); } - const taskContext = new TaskContext("no-progress-reporting"); + const taskContext = new TaskContext("no-progress-reporting", "export", null); // technically a branch is being exported (includes prefix), but it's such a minor difference yet usability pain // (e.g. branchIds are not seen in UI), that we export "note export" instead. @@ -164,7 +164,7 @@ function register(router: Router) { eu.route(router, "post", "/etapi/notes/:noteId/import", (req, res, next) => { const note = eu.getAndCheckNote(req.params.noteId); - const taskContext = new TaskContext("no-progress-reporting"); + const taskContext = new TaskContext("no-progress-reporting", "importNotes", null); zipImportService.importZip(taskContext, req.body, note).then((importedNote) => { res.status(201).json({ diff --git a/apps/server/src/routes/api/branches.ts b/apps/server/src/routes/api/branches.ts index bed1c93b7a..ac6da765f2 100644 --- a/apps/server/src/routes/api/branches.ts +++ b/apps/server/src/routes/api/branches.ts @@ -236,7 +236,7 @@ function deleteBranch(req: Request) { const eraseNotes = req.query.eraseNotes === "true"; const branch = becca.getBranchOrThrow(req.params.branchId); - const taskContext = TaskContext.getInstance(req.query.taskId as string, "deleteNotes"); + const taskContext = TaskContext.getInstance(req.query.taskId as string, "deleteNotes", null); const deleteId = utils.randomString(10); let noteDeleted; @@ -251,7 +251,7 @@ function deleteBranch(req: Request) { } if (last) { - taskContext.taskSucceeded(); + taskContext.taskSucceeded(null); } return { diff --git a/apps/server/src/routes/api/export.ts b/apps/server/src/routes/api/export.ts index 7433cd5525..4bc0c21773 100644 --- a/apps/server/src/routes/api/export.ts +++ b/apps/server/src/routes/api/export.ts @@ -23,7 +23,7 @@ function exportBranch(req: Request, res: Response) { return; } - const taskContext = new TaskContext(taskId, "export"); + const taskContext = new TaskContext(taskId, "export", null); try { if (type === "subtree" && (format === "html" || format === "markdown")) { diff --git a/apps/server/src/routes/api/import.ts b/apps/server/src/routes/api/import.ts index c7253f2d63..273dc1e1da 100644 --- a/apps/server/src/routes/api/import.ts +++ b/apps/server/src/routes/api/import.ts @@ -116,7 +116,7 @@ function importAttachmentsToNote(req: Request) { } const parentNote = becca.getNoteOrThrow(parentNoteId); - const taskContext = TaskContext.getInstance(taskId, "importAttachment", options); + const taskContext = TaskContext.getInstance(taskId, "importNotes", options); // unlike in note import, we let the events run, because a huge number of attachments is not likely diff --git a/apps/server/src/routes/api/notes.ts b/apps/server/src/routes/api/notes.ts index 8a426dea30..3c6db40549 100644 --- a/apps/server/src/routes/api/notes.ts +++ b/apps/server/src/routes/api/notes.ts @@ -184,7 +184,7 @@ function deleteNote(req: Request) { if (typeof taskId !== "string") { throw new ValidationError("Missing or incorrect type for task ID."); } - const taskContext = TaskContext.getInstance(taskId, "deleteNotes"); + const taskContext = TaskContext.getInstance(taskId, "deleteNotes", null); note.deleteNote(deleteId, taskContext); @@ -193,16 +193,16 @@ function deleteNote(req: Request) { } if (last) { - taskContext.taskSucceeded(); + taskContext.taskSucceeded(null); } } function undeleteNote(req: Request) { - const taskContext = TaskContext.getInstance(utils.randomString(10), "undeleteNotes"); + const taskContext = TaskContext.getInstance(utils.randomString(10), "undeleteNotes", null); noteService.undeleteNote(req.params.noteId, taskContext); - taskContext.taskSucceeded(); + taskContext.taskSucceeded(null); } function sortChildNotes(req: Request) { @@ -226,7 +226,7 @@ function protectNote(req: Request) { noteService.protectNoteRecursively(note, protect, includingSubTree, taskContext); - taskContext.taskSucceeded(); + taskContext.taskSucceeded(null); } function setNoteTypeMime(req: Request) { diff --git a/apps/server/src/services/export/opml.ts b/apps/server/src/services/export/opml.ts index 74d2b2c4e7..52e60a60f0 100644 --- a/apps/server/src/services/export/opml.ts +++ b/apps/server/src/services/export/opml.ts @@ -6,7 +6,7 @@ import type TaskContext from "../task_context.js"; import type BBranch from "../../becca/entities/bbranch.js"; import type { Response } from "express"; -function exportToOpml(taskContext: TaskContext, branch: BBranch, version: string, res: Response) { +function exportToOpml(taskContext: TaskContext<"export">, branch: BBranch, version: string, res: Response) { if (!["1.0", "2.0"].includes(version)) { throw new Error(`Unrecognized OPML version ${version}`); } @@ -77,7 +77,7 @@ function exportToOpml(taskContext: TaskContext, branch: BBranch, version: string `); res.end(); - taskContext.taskSucceeded(); + taskContext.taskSucceeded(null); } function prepareText(text: string) { diff --git a/apps/server/src/services/export/single.ts b/apps/server/src/services/export/single.ts index b626bf9193..678fb39e50 100644 --- a/apps/server/src/services/export/single.ts +++ b/apps/server/src/services/export/single.ts @@ -10,7 +10,7 @@ import type BBranch from "../../becca/entities/bbranch.js"; import type { Response } from "express"; import type BNote from "../../becca/entities/bnote.js"; -function exportSingleNote(taskContext: TaskContext, branch: BBranch, format: "html" | "markdown", res: Response) { +function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, format: "html" | "markdown", res: Response) { const note = branch.getNote(); if (note.type === "image" || note.type === "file") { @@ -30,7 +30,7 @@ function exportSingleNote(taskContext: TaskContext, branch: BBranch, format: "ht res.send(payload); taskContext.increaseProgressCount(); - taskContext.taskSucceeded(); + taskContext.taskSucceeded(null); } export function mapByNoteType(note: BNote, content: string | Buffer, format: "html" | "markdown") { diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index 91d01c8c74..116a841b25 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -40,7 +40,7 @@ export interface AdvancedExportOptions { customRewriteLinks?: (originalRewriteLinks: RewriteLinksFn, getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null) => RewriteLinksFn; } -async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "html" | "markdown", res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) { +async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, format: "html" | "markdown", res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) { if (!["html", "markdown"].includes(format)) { throw new ValidationError(`Only 'html' and 'markdown' allowed as export format, '${format}' given`); } @@ -611,7 +611,7 @@ ${markdownContent}`; archive.pipe(res); await archive.finalize(); - taskContext.taskSucceeded(); + taskContext.taskSucceeded(null); } catch (e: unknown) { const message = `Export failed with error: ${e instanceof Error ? e.message : String(e)}`; log.error(message); @@ -627,7 +627,7 @@ ${markdownContent}`; async function exportToZipFile(noteId: string, format: "markdown" | "html", zipFilePath: string, zipExportOptions?: AdvancedExportOptions) { const fileOutputStream = fs.createWriteStream(zipFilePath); - const taskContext = new TaskContext("no-progress-reporting"); + const taskContext = new TaskContext("no-progress-reporting", "export", null); const note = becca.getNote(noteId); diff --git a/apps/server/src/services/import/enex.ts b/apps/server/src/services/import/enex.ts index 4699ca32e3..5a13e0960c 100644 --- a/apps/server/src/services/import/enex.ts +++ b/apps/server/src/services/import/enex.ts @@ -55,7 +55,7 @@ interface Note { let note: Partial = {}; let resource: Resource; -function importEnex(taskContext: TaskContext, file: File, parentNote: BNote): Promise { +function importEnex(taskContext: TaskContext<"importNotes">, file: File, parentNote: BNote): Promise { const saxStream = sax.createStream(true); const rootNoteTitle = file.originalname.toLowerCase().endsWith(".enex") ? file.originalname.substr(0, file.originalname.length - 5) : file.originalname; diff --git a/apps/server/src/services/import/mime.ts b/apps/server/src/services/import/mime.ts index cce580a088..0a129ae1e0 100644 --- a/apps/server/src/services/import/mime.ts +++ b/apps/server/src/services/import/mime.ts @@ -91,14 +91,14 @@ function getMime(fileName: string) { return mimeFromExt || mimeTypes.lookup(fileNameLc); } -function getType(options: TaskData, mime: string): NoteType { +function getType(options: TaskData<"importNotes">, mime: string): NoteType { const mimeLc = mime?.toLowerCase(); switch (true) { - case options.textImportedAsText && ["text/html", "text/markdown", "text/x-markdown", "text/mdx"].includes(mimeLc): + case options?.textImportedAsText && ["text/html", "text/markdown", "text/x-markdown", "text/mdx"].includes(mimeLc): return "text"; - case options.codeImportedAsCode && CODE_MIME_TYPES.has(mimeLc): + case options?.codeImportedAsCode && CODE_MIME_TYPES.has(mimeLc): return "code"; case mime.startsWith("image/"): diff --git a/apps/server/src/services/import/opml.ts b/apps/server/src/services/import/opml.ts index 934578c70b..130eb81974 100644 --- a/apps/server/src/services/import/opml.ts +++ b/apps/server/src/services/import/opml.ts @@ -28,7 +28,7 @@ interface OpmlOutline { outline: OpmlOutline[]; } -async function importOpml(taskContext: TaskContext, fileBuffer: string | Buffer, parentNote: BNote) { +async function importOpml(taskContext: TaskContext<"importNotes">, fileBuffer: string | Buffer, parentNote: BNote) { const xml = await new Promise(function (resolve, reject) { parseString(fileBuffer, function (err: any, result: OpmlXml) { if (err) { diff --git a/apps/server/src/services/import/single.ts b/apps/server/src/services/import/single.ts index 7603cd6255..ac52a43f49 100644 --- a/apps/server/src/services/import/single.ts +++ b/apps/server/src/services/import/single.ts @@ -14,7 +14,7 @@ import htmlSanitizer from "../html_sanitizer.js"; import type { File } from "./common.js"; import type { NoteType } from "@triliumnext/commons"; -function importSingleFile(taskContext: TaskContext, file: File, parentNote: BNote) { +function importSingleFile(taskContext: TaskContext<"importNotes">, file: File, parentNote: BNote) { const mime = mimeService.getMime(file.originalname) || file.mimetype; if (taskContext?.data?.textImportedAsText) { @@ -42,7 +42,7 @@ function importSingleFile(taskContext: TaskContext, file: File, parentNote: BNot return importFile(taskContext, file, parentNote); } -function importImage(file: File, parentNote: BNote, taskContext: TaskContext) { +function importImage(file: File, parentNote: BNote, taskContext: TaskContext<"importNotes">) { if (typeof file.buffer === "string") { throw new Error("Invalid file content for image."); } @@ -53,7 +53,7 @@ function importImage(file: File, parentNote: BNote, taskContext: TaskContext) { return note; } -function importFile(taskContext: TaskContext, file: File, parentNote: BNote) { +function importFile(taskContext: TaskContext<"importNotes">, file: File, parentNote: BNote) { const originalName = file.originalname; const { note } = noteService.createNewNote({ @@ -72,7 +72,7 @@ function importFile(taskContext: TaskContext, file: File, parentNote: BNote) { return note; } -function importCodeNote(taskContext: TaskContext, file: File, parentNote: BNote) { +function importCodeNote(taskContext: TaskContext<"importNotes">, file: File, parentNote: BNote) { const title = getNoteTitle(file.originalname, !!taskContext.data?.replaceUnderscoresWithSpaces); const content = processStringOrBuffer(file.buffer); const detectedMime = mimeService.getMime(file.originalname) || file.mimetype; @@ -97,7 +97,7 @@ function importCodeNote(taskContext: TaskContext, file: File, parentNote: BNote) return note; } -function importCustomType(taskContext: TaskContext, file: File, parentNote: BNote, type: NoteType, mime: string) { +function importCustomType(taskContext: TaskContext<"importNotes">, file: File, parentNote: BNote, type: NoteType, mime: string) { const title = getNoteTitle(file.originalname, !!taskContext.data?.replaceUnderscoresWithSpaces); const content = processStringOrBuffer(file.buffer); @@ -115,7 +115,7 @@ function importCustomType(taskContext: TaskContext, file: File, parentNote: BNot return note; } -function importPlainText(taskContext: TaskContext, file: File, parentNote: BNote) { +function importPlainText(taskContext: TaskContext<"importNotes">, file: File, parentNote: BNote) { const title = getNoteTitle(file.originalname, !!taskContext.data?.replaceUnderscoresWithSpaces); const plainTextContent = processStringOrBuffer(file.buffer); const htmlContent = convertTextToHtml(plainTextContent); @@ -150,7 +150,7 @@ function convertTextToHtml(text: string) { return text; } -function importMarkdown(taskContext: TaskContext, file: File, parentNote: BNote) { +function importMarkdown(taskContext: TaskContext<"importNotes">, file: File, parentNote: BNote) { const title = getNoteTitle(file.originalname, !!taskContext.data?.replaceUnderscoresWithSpaces); const markdownContent = processStringOrBuffer(file.buffer); @@ -174,7 +174,7 @@ function importMarkdown(taskContext: TaskContext, file: File, parentNote: BNote) return note; } -function importHtml(taskContext: TaskContext, file: File, parentNote: BNote) { +function importHtml(taskContext: TaskContext<"importNotes">, file: File, parentNote: BNote) { let content = processStringOrBuffer(file.buffer); // Try to get title from HTML first, fall back to filename @@ -202,7 +202,7 @@ function importHtml(taskContext: TaskContext, file: File, parentNote: BNote) { return note; } -function importAttachment(taskContext: TaskContext, file: File, parentNote: BNote) { +function importAttachment(taskContext: TaskContext<"importNotes">, file: File, parentNote: BNote) { const mime = mimeService.getMime(file.originalname) || file.mimetype; if (mime.startsWith("image/") && typeof file.buffer !== "string") { diff --git a/apps/server/src/services/import/zip.ts b/apps/server/src/services/import/zip.ts index b2d83bdc69..c1ac90b913 100644 --- a/apps/server/src/services/import/zip.ts +++ b/apps/server/src/services/import/zip.ts @@ -30,7 +30,7 @@ interface ImportZipOpts { preserveIds?: boolean; } -async function importZip(taskContext: TaskContext, fileBuffer: Buffer, importRootNote: BNote, opts?: ImportZipOpts): Promise { +async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Buffer, importRootNote: BNote, opts?: ImportZipOpts): Promise { /** maps from original noteId (in ZIP file) to newly generated noteId */ const noteIdMap: Record = {}; /** type maps from original attachmentId (in ZIP file) to newly generated attachmentId */ @@ -174,7 +174,7 @@ async function importZip(taskContext: TaskContext, fileBuffer: Buffer, importRoo return noteId; } - function detectFileTypeAndMime(taskContext: TaskContext, filePath: string) { + function detectFileTypeAndMime(taskContext: TaskContext<"importNotes">, filePath: string) { const mime = mimeService.getMime(filePath) || "application/octet-stream"; const type = mimeService.getType(taskContext.data || {}, mime); diff --git a/apps/server/src/services/notes.ts b/apps/server/src/services/notes.ts index e225cdb525..3ecf98e0a7 100644 --- a/apps/server/src/services/notes.ts +++ b/apps/server/src/services/notes.ts @@ -296,7 +296,7 @@ function createNewNoteWithTarget(target: "into" | "after" | "before", targetBran } } -function protectNoteRecursively(note: BNote, protect: boolean, includingSubTree: boolean, taskContext: TaskContext) { +function protectNoteRecursively(note: BNote, protect: boolean, includingSubTree: boolean, taskContext: TaskContext<"protectNotes">) { protectNote(note, protect); taskContext.increaseProgressCount(); @@ -765,7 +765,7 @@ function updateNoteData(noteId: string, content: string, attachments: Attachment } } -function undeleteNote(noteId: string, taskContext: TaskContext) { +function undeleteNote(noteId: string, taskContext: TaskContext<"undeleteNotes">) { const noteRow = sql.getRow("SELECT * FROM notes WHERE noteId = ?", [noteId]); if (!noteRow.isDeleted || !noteRow.deleteId) { @@ -785,7 +785,7 @@ function undeleteNote(noteId: string, taskContext: TaskContext) { } } -function undeleteBranch(branchId: string, deleteId: string, taskContext: TaskContext) { +function undeleteBranch(branchId: string, deleteId: string, taskContext: TaskContext<"undeleteNotes">) { const branchRow = sql.getRow("SELECT * FROM branches WHERE branchId = ?", [branchId]); if (!branchRow.isDeleted) { diff --git a/apps/server/src/services/sql_init.ts b/apps/server/src/services/sql_init.ts index 541e487a06..926d61bba6 100644 --- a/apps/server/src/services/sql_init.ts +++ b/apps/server/src/services/sql_init.ts @@ -122,7 +122,7 @@ async function createInitialDatabase(skipDemoDb?: boolean) { log.info("Importing demo content ..."); - const dummyTaskContext = new TaskContext("no-progress-reporting", "import", false); + const dummyTaskContext = new TaskContext("no-progress-reporting", "importNotes", null); if (demoFile) { await zipImportService.importZip(dummyTaskContext, demoFile, rootNote); diff --git a/apps/server/src/services/task_context.ts b/apps/server/src/services/task_context.ts index 60ece320b1..79122895bd 100644 --- a/apps/server/src/services/task_context.ts +++ b/apps/server/src/services/task_context.ts @@ -1,20 +1,20 @@ "use strict"; -import type { TaskType } from "@triliumnext/commons"; +import type { TaskData, TaskResult, TaskType, WebSocketMessage } from "@triliumnext/commons"; import ws from "./ws.js"; // taskId => TaskContext -const taskContexts: Record> = {}; +const taskContexts: Record> = {}; -class TaskContext { +class TaskContext { private taskId: string; private taskType: TaskType; private progressCount: number; private lastSentCountTs: number; - data: TaskData | null; + data: TaskData; noteDeletionHandlerTriggered: boolean; - constructor(taskId: string, taskType: TaskTypeT, data: {} | null = {}) { + constructor(taskId: string, taskType: T, data: TaskData) { this.taskId = taskId; this.taskType = taskType; this.data = data; @@ -31,7 +31,7 @@ class TaskContext { this.increaseProgressCount(); } - static getInstance(taskId: string, taskType: TaskTypeT, data: {} | null = null): TaskContext { + static getInstance(taskId: string, taskType: T, data: TaskData): TaskContext { if (!taskContexts[taskId]) { taskContexts[taskId] = new TaskContext(taskId, taskType, data); } @@ -51,7 +51,7 @@ class TaskContext { taskType: this.taskType, data: this.data, progressCount: this.progressCount - }); + } as WebSocketMessage); } } @@ -62,17 +62,17 @@ class TaskContext { taskType: this.taskType, data: this.data, message - }); + } as WebSocketMessage); } - taskSucceeded(result?: string | Record) { + taskSucceeded(result: TaskResult) { ws.sendMessageToAllClients({ type: "taskSucceeded", taskId: this.taskId, taskType: this.taskType, data: this.data, result - }); + } as WebSocketMessage); } } diff --git a/apps/server/src/services/ws.ts b/apps/server/src/services/ws.ts index f89b44869b..9dfcbc0198 100644 --- a/apps/server/src/services/ws.ts +++ b/apps/server/src/services/ws.ts @@ -13,7 +13,7 @@ import type { IncomingMessage, Server as HttpServer } from "http"; import { WebSocketMessage, type EntityChange } from "@triliumnext/commons"; let webSocketServer!: WebSocketServer; -let lastSyncedPush: number | null = null; +let lastSyncedPush: number; type SessionParser = (req: IncomingMessage, params: {}, cb: () => void) => void; function init(httpServer: HttpServer, sessionParser: SessionParser) { diff --git a/packages/commons/src/lib/ws_api.ts b/packages/commons/src/lib/ws_api.ts index dca860784d..67beb0b42e 100644 --- a/packages/commons/src/lib/ws_api.ts +++ b/packages/commons/src/lib/ws_api.ts @@ -26,37 +26,64 @@ export interface EntityChangeRecord { entity?: EntityRow; } -type TaskStatus = { +type TaskDataDefinitions = { + empty: null, + deleteNotes: null, + undeleteNotes: null, + export: null, + protectNotes: { + protect: boolean; + } + importNotes: { + textImportedAsText?: boolean; + codeImportedAsCode?: boolean; + replaceUnderscoresWithSpaces?: boolean; + shrinkImages?: boolean; + safeImport?: boolean; + } | null, + importAttachments: null +} + +type TaskResultDefinitions = { + empty: null, + deleteNotes: null, + undeleteNotes: null, + export: null, + protectNotes: null, + importNotes: { + parentNoteId?: string; + importedNoteId?: string + }; + importAttachments: { + parentNoteId?: string; + importedNoteId?: string + }; +} + +export type TaskType = keyof TaskDataDefinitions | keyof TaskResultDefinitions; +export type TaskData = TaskDataDefinitions[T]; +export type TaskResult = TaskResultDefinitions[T]; + +type TaskDefinition = { type: "taskProgressCount", taskId: string; - taskType: TypeT; - data: DataT, + taskType: T; + data: TaskData, progressCount: number } | { type: "taskError", taskId: string; - taskType: TypeT; - data: DataT; + taskType: T; + data: TaskData, message: string; } | { type: "taskSucceeded", taskId: string; - taskType: TypeT; - data: DataT; - result: ResultT; + taskType: T; + data: TaskData, + result: TaskResult; } -type TaskDefinitions = - TaskStatus<"protectNotes", { protect: boolean; }, null> - | TaskStatus<"importNotes", null, { importedNoteId: string }> - | TaskStatus<"importAttachments", null, { parentNoteId?: string; importedNoteId: string }> - | TaskStatus<"deleteNotes", null, null> - | TaskStatus<"undeleteNotes", null, null> - | TaskStatus<"export", null, null> -; - -export type TaskType = TaskDefinitions["taskType"]; - export interface OpenedFileUpdateStatus { entityType: string; entityId: string; @@ -64,7 +91,16 @@ export interface OpenedFileUpdateStatus { filePath: string; } -export type WebSocketMessage = TaskDefinitions | { +type AllTaskDefinitions = + | TaskDefinition<"empty"> + | TaskDefinition<"deleteNotes"> + | TaskDefinition<"undeleteNotes"> + | TaskDefinition<"export"> + | TaskDefinition<"protectNotes"> + | TaskDefinition<"importNotes"> + | TaskDefinition<"importAttachments">; + +export type WebSocketMessage = AllTaskDefinitions | { type: "ping" } | { type: "frontend-update", From 050ff5d8cd39430c89d5617db36b6f5178b937cc Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Sep 2025 14:42:02 +0300 Subject: [PATCH 216/233] fix(collections): not updating on import --- apps/client/src/services/ws.ts | 8 ++++++-- .../src/widgets/collections/NoteList.tsx | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/client/src/services/ws.ts b/apps/client/src/services/ws.ts index b873289b45..79f64c5984 100644 --- a/apps/client/src/services/ws.ts +++ b/apps/client/src/services/ws.ts @@ -9,7 +9,7 @@ import type { EntityChange } from "../server_types.js"; import { WebSocketMessage } from "@triliumnext/commons"; type MessageHandler = (message: WebSocketMessage) => void; -const messageHandlers: MessageHandler[] = []; +let messageHandlers: MessageHandler[] = []; let ws: WebSocket; let lastAcceptedEntityChangeId = window.glob.maxEntityChangeIdAtLoad; @@ -48,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 | null = null; diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index af5d831c8a..1c82abe902 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -9,6 +9,8 @@ import ViewModeStorage from "../view_widgets/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"; interface NoteListProps { note?: FNote | null; @@ -137,6 +139,23 @@ function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOptions | } }) + // Refresh on import. + useEffect(() => { + function onImport(message: WebSocketMessage) { + if (!("taskType" in message) || message.taskType !== "importNotes" || message.type !== "taskSucceeded") return; + const { parentNoteId, importedNoteId } = message.result; + if (parentNoteId && importedNoteId && (parentNoteId === note?.noteId || noteIds.includes(parentNoteId))) { + setNoteIds([ + ...noteIds, + importedNoteId + ]) + } + } + + subscribeToMessages(onImport); + return () => unsubscribeFromMessage(onImport); + }, [ note, noteIds, setNoteIds ]) + return noteIds; } From 6ba494999c4d042f180603d6f17b49a946327153 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Sep 2025 14:47:40 +0300 Subject: [PATCH 217/233] chore(collections): support child notes on import as well --- .../src/widgets/collections/NoteList.tsx | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index 1c82abe902..3f0231b0df 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -11,6 +11,7 @@ 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 { note?: FNote | null; @@ -116,12 +117,17 @@ function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOptions | async function refreshNoteIds() { if (!note) { setNoteIds([]); - } else if (viewType === "list" || viewType === "grid") { - console.log("Refreshed note IDs"); - setNoteIds(note.getChildNoteIds()); } else { - console.log("Refreshed note IDs"); - setNoteIds(await note.getSubtreeNoteIds(includeArchived)); + setNoteIds(await getNoteIds(note)); + } + } + + async function getNoteIds(note: FNote) { + console.log("Refreshed note IDs"); + if (viewType === "list" || viewType === "grid") { + return note.getChildNoteIds(); + } else { + return await note.getSubtreeNoteIds(includeArchived); } } @@ -141,12 +147,16 @@ function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOptions | // Refresh on import. useEffect(() => { - function onImport(message: WebSocketMessage) { + async function onImport(message: WebSocketMessage) { if (!("taskType" in message) || message.taskType !== "importNotes" || message.type !== "taskSucceeded") return; const { parentNoteId, importedNoteId } = message.result; - if (parentNoteId && importedNoteId && (parentNoteId === note?.noteId || noteIds.includes(parentNoteId))) { + 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 ]) } From 3128f2dace0fb3bbd416de8782b8a11ae38e9a14 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Sep 2025 15:12:26 +0300 Subject: [PATCH 218/233] fix(react/collections/geomap): corrupted map after closing split --- apps/client/src/widgets/collections/geomap/map.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/client/src/widgets/collections/geomap/map.tsx b/apps/client/src/widgets/collections/geomap/map.tsx index 85c7b9b340..06ce63dc28 100644 --- a/apps/client/src/widgets/collections/geomap/map.tsx +++ b/apps/client/src/widgets/collections/geomap/map.tsx @@ -3,7 +3,7 @@ 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 { useSyncedRef } from "../../react/hooks"; +import { useElementSize, useSyncedRef } from "../../react/hooks"; export const ParentMap = createContext(null); @@ -125,6 +125,12 @@ export default function Map({ coordinates, zoom, layerName, viewportChanged, chi return () => scaleControl.remove(); }, [ mapRef, scale ]); + // Adapt to container size changes. + const size = useElementSize(containerRef); + useEffect(() => { + mapRef.current?.invalidateSize(); + }, [ size?.width, size?.height ]); + return (
Date: Sat, 13 Sep 2025 15:16:58 +0300 Subject: [PATCH 219/233] fix(react/collections/geomap): react to icon & color changes --- apps/client/src/widgets/collections/geomap/index.tsx | 10 +++++----- apps/client/src/widgets/collections/geomap/marker.tsx | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index b8e80ab794..23432084d4 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -168,14 +168,14 @@ function NoteWrapper({ note, isReadOnly }: { note: FNote, isReadOnly: boolean }) function NoteMarker({ note, editable, latLng }: { note: FNote, editable: boolean, latLng: [number, number] }) { // React to changes - useNoteLabel(note, "color"); - useNoteLabel(note, "iconClass"); + const [ color ] = useNoteLabel(note, "color"); + const [ iconClass ] = useNoteLabel(note, "iconClass"); const [ archived ] = useNoteLabelBoolean(note, "archived"); const title = useNoteProperty(note, "title"); - const colorClass = note.getColorClass(); - const iconClass = note.getIcon(); - const icon = useMemo(() => buildIcon(iconClass, colorClass ?? undefined, title, note.noteId, archived), [ iconClass, colorClass, title, note.noteId, archived]); + 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 }); diff --git a/apps/client/src/widgets/collections/geomap/marker.tsx b/apps/client/src/widgets/collections/geomap/marker.tsx index 2a2142d1c2..8b6cb13719 100644 --- a/apps/client/src/widgets/collections/geomap/marker.tsx +++ b/apps/client/src/widgets/collections/geomap/marker.tsx @@ -50,7 +50,7 @@ export default function Marker({ coordinates, icon, draggable, onClick, onDragge newMarker.addTo(parentMap); return () => newMarker.removeFrom(parentMap); - }, [ parentMap, coordinates, onMouseDown, onDragged ]); + }, [ parentMap, coordinates, onMouseDown, onDragged, icon ]); return (
) } From 5bb14324502d295c63c213ef13fe30af05b28319 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Sep 2025 15:34:32 +0300 Subject: [PATCH 220/233] fix(react/collections/geomap): "note not found" when deleting GPX --- apps/client/src/entities/fnote.ts | 4 ++-- .../src/widgets/collections/geomap/index.tsx | 1 + apps/client/src/widgets/react/hooks.tsx | 15 ++++++++++++--- .../src/widgets/ribbon/FilePropertiesTab.tsx | 6 +++--- .../src/widgets/ribbon/ImagePropertiesTab.tsx | 10 +++++----- 5 files changed, 23 insertions(+), 13 deletions(-) diff --git a/apps/client/src/entities/fnote.ts b/apps/client/src/entities/fnote.ts index 3fff82afae..dcb768dd71 100644 --- a/apps/client/src/entities/fnote.ts +++ b/apps/client/src/entities/fnote.ts @@ -907,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() { diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index 23432084d4..416aadbe31 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -212,6 +212,7 @@ function NoteGpxTrack({ note }: { note: FNote }) { const blob = useNoteBlob(note); useEffect(() => { + if (!blob) return; server.get(`notes/${note.noteId}/open`, undefined, true).then(xmlResponse => { if (xmlResponse instanceof Uint8Array) { setXmlString(new TextDecoder().decode(xmlResponse)); diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 06ea554ec1..1b218bb336 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -367,7 +367,7 @@ export function useNoteLabelInt(note: FNote | undefined | null, labelName: strin ] } -export function useNoteBlob(note: FNote | null | undefined): [ FBlob | null | undefined ] { +export function useNoteBlob(note: FNote | null | undefined): FBlob | null | undefined { const [ blob, setBlob ] = useState(); function refresh() { @@ -376,14 +376,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(widgetFactory: () => T, { noteContext, containerClassName, containerStyle }: { diff --git a/apps/client/src/widgets/ribbon/FilePropertiesTab.tsx b/apps/client/src/widgets/ribbon/FilePropertiesTab.tsx index c65b9ab3ea..4b42699d38 100644 --- a/apps/client/src/widgets/ribbon/FilePropertiesTab.tsx +++ b/apps/client/src/widgets/ribbon/FilePropertiesTab.tsx @@ -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 (
@@ -52,7 +52,7 @@ export default function FilePropertiesTab({ note }: { note?: FNote | null }) { { if (!fileToUpload) { return; @@ -74,4 +74,4 @@ export default function FilePropertiesTab({ note }: { note?: FNote | null }) { )}
); -} \ No newline at end of file +} diff --git a/apps/client/src/widgets/ribbon/ImagePropertiesTab.tsx b/apps/client/src/widgets/ribbon/ImagePropertiesTab.tsx index 824040b8aa..cb747d6b48 100644 --- a/apps/client/src/widgets/ribbon/ImagePropertiesTab.tsx +++ b/apps/client/src/widgets/ribbon/ImagePropertiesTab.tsx @@ -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); @@ -25,12 +25,12 @@ export default function ImagePropertiesTab({ note, ntxId }: TabContext) { {t("image_properties.original_file_name")}:{" "} {originalFileName ?? "?"} - + {t("image_properties.file_type")}:{" "} {note.mime} - + {t("image_properties.file_size")}:{" "} {formatSize(blob?.contentLength)} @@ -48,7 +48,7 @@ export default function ImagePropertiesTab({ note, ntxId }: TabContext) {
+
)} {(iconData?.icons ?? []).map(({className, name}) => ( @@ -181,4 +181,4 @@ function getIconLabels(note: FNote) { return note.getOwnedLabels() .filter((label) => ["workspaceIconClass", "iconClass"] .includes(label.name)); -} \ No newline at end of file +} diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 1b218bb336..76ea6077eb 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -2,7 +2,7 @@ import { Inputs, MutableRef, useCallback, useContext, useDebugValue, useEffect, 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 } from "@triliumnext/commons"; import options, { type OptionValue } from "../../services/options"; import utils, { escapeRegExp, reloadFrontendApp } from "../../services/utils"; import NoteContext from "../../components/note_context"; @@ -13,7 +13,7 @@ import FBlob from "../../entities/fblob"; import NoteContextAwareWidget from "../note_context_aware_widget"; import { RefObject, VNode } from "preact"; import { Tooltip } from "bootstrap"; -import { CSSProperties, DragEventHandler } from "preact/compat"; +import { CSSProperties } from "preact/compat"; import keyboard_actions from "../../services/keyboard_actions"; import Mark from "mark.js"; import { DragData } from "../note_tree"; @@ -291,7 +291,7 @@ 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] { +export function useNoteLabel(note: FNote | undefined | null, labelName: FilterLabelsByType): [string | null | undefined, (newValue: string | null | undefined) => void] { const [ , setLabelValue ] = useState(); useEffect(() => setLabelValue(note?.getLabelValue(labelName) ?? null), [ note ]); @@ -325,12 +325,12 @@ export function useNoteLabel(note: FNote | undefined | null, labelName: string): ] as const; } -export function useNoteLabelWithDefault(note: FNote | undefined | null, labelName: string, defaultValue: string): [string, (newValue: string | null | undefined) => void] { +export function useNoteLabelWithDefault(note: FNote | undefined | null, labelName: FilterLabelsByType, 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: string): [ boolean, (newValue: boolean) => void] { +export function useNoteLabelBoolean(note: FNote | undefined | null, labelName: FilterLabelsByType): [ boolean, (newValue: boolean) => void] { const [ labelValue, setLabelValue ] = useState(!!note?.hasLabel(labelName)); useEffect(() => setLabelValue(!!note?.hasLabel(labelName)), [ note ]); @@ -358,7 +358,8 @@ export function useNoteLabelBoolean(note: FNote | undefined | null, labelName: s return [ labelValue, setter ] as const; } -export function useNoteLabelInt(note: FNote | undefined | null, labelName: string): [ number | undefined, (newValue: number) => void] { +export function useNoteLabelInt(note: FNote | undefined | null, labelName: FilterLabelsByType): [ 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 [ diff --git a/apps/client/src/widgets/ribbon/BasicPropertiesTab.tsx b/apps/client/src/widgets/ribbon/BasicPropertiesTab.tsx index d5e3332c1b..2232503db3 100644 --- a/apps/client/src/widgets/ribbon/BasicPropertiesTab.tsx +++ b/apps/client/src/widgets/ribbon/BasicPropertiesTab.tsx @@ -23,7 +23,7 @@ import { ContentLanguagesList } from "../type_widgets/options/i18n"; export default function BasicPropertiesTab({ note }: TabContext) { return ( -
+
@@ -43,7 +43,7 @@ function NoteTypeWidget({ note }: { note?: FNote | null }) { return mime_types.getMimeTypes().filter(mimeType => mimeType.enabled) }, [ codeNotesMimeTypes ]); const notSelectableNoteTypes = useMemo(() => NOTE_TYPES.filter((nt) => nt.reserved || nt.static).map((nt) => nt.type), []); - + const currentNoteType = useNoteProperty(note, "type") ?? undefined; const currentNoteMime = useNoteProperty(note, "mime"); const [ modalShown, setModalShown ] = useState(false); @@ -95,7 +95,7 @@ function NoteTypeWidget({ note }: { note?: FNote | null }) { checked={checked} badges={badges} onClick={() => changeNoteType(type, mime)} - >{title} + >{title} ); } else { return ( @@ -103,7 +103,7 @@ function NoteTypeWidget({ note }: { note?: FNote | null }) { {title} @@ -131,7 +131,7 @@ function NoteTypeWidget({ note }: { note?: FNote | null }) {
- ) + ) } function ProtectedNoteSwitch({ note }: { note?: FNote | null }) { @@ -151,7 +151,7 @@ function ProtectedNoteSwitch({ note }: { note?: FNote | null }) { function EditabilitySelect({ note }: { note?: FNote | null }) { const [ readOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly"); - const [ autoReadOnlyDisabled, setAutoReadOnlyDisabled ] = useNoteLabelBoolean(note, "autoReadOnlyDisabled"); + const [ autoReadOnlyDisabled, setAutoReadOnlyDisabled ] = useNoteLabelBoolean(note, "autoReadOnlyDisabled"); const options = useMemo(() => ([ { @@ -208,7 +208,7 @@ function BookmarkSwitch({ note }: { note?: FNote | null }) { { if (!note) return; const resp = await server.put(`notes/${note.noteId}/toggle-in-parent/_lbBookmarks/${shouldBookmark}`); @@ -260,11 +260,11 @@ function SharedSwitch({ note }: { note?: FNote | null }) { } else { if (note?.getParentBranches().length === 1 && !(await dialog.confirm(t("shared_switch.shared-branch")))) { return; - } + } const shareBranch = note?.getParentBranches().find((b) => b.parentNoteId === "_share"); if (!shareBranch?.branchId) return; - await server.remove(`branches/${shareBranch.branchId}?taskId=no-progress-reporting`); + await server.remove(`branches/${shareBranch.branchId}?taskId=no-progress-reporting`); } sync.syncNow(true); @@ -330,7 +330,7 @@ function NoteLanguageSwitch({ note }: { note?: FNote | null }) { return locales.find(locale => typeof locale === "object" && locale.id === currentNoteLanguage) as Locale | undefined; }, [ currentNoteLanguage ]); - return ( + return (
{t("basic_properties.language")}:   @@ -350,7 +350,7 @@ function NoteLanguageSwitch({ note }: { note?: FNote | null }) { setModalShown(true)} - >{t("note_language.configure-languages")} + >{t("note_language.configure-languages")} @@ -378,4 +378,4 @@ function findTypeTitle(type?: NoteType, mime?: string | null) { return noteType ? noteType.title : type; } -} \ No newline at end of file +} diff --git a/apps/client/src/widgets/ribbon/CollectionPropertiesTab.tsx b/apps/client/src/widgets/ribbon/CollectionPropertiesTab.tsx index 2594dd6c20..8960fe46d5 100644 --- a/apps/client/src/widgets/ribbon/CollectionPropertiesTab.tsx +++ b/apps/client/src/widgets/ribbon/CollectionPropertiesTab.tsx @@ -118,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 ( diff --git a/apps/client/src/widgets/ribbon/NotePropertiesTab.tsx b/apps/client/src/widgets/ribbon/NotePropertiesTab.tsx index 8cc0c2b857..9dc1574c72 100644 --- a/apps/client/src/widgets/ribbon/NotePropertiesTab.tsx +++ b/apps/client/src/widgets/ribbon/NotePropertiesTab.tsx @@ -17,4 +17,4 @@ export default function NotePropertiesTab({ note }: TabContext) { )}
) -} \ No newline at end of file +} diff --git a/apps/client/src/widgets/ribbon/ScriptTab.tsx b/apps/client/src/widgets/ribbon/ScriptTab.tsx index 81ac3a3ef8..dbdd49a0b1 100644 --- a/apps/client/src/widgets/ribbon/ScriptTab.tsx +++ b/apps/client/src/widgets/ribbon/ScriptTab.tsx @@ -25,4 +25,4 @@ export default function ScriptTab({ note }: TabContext) {
); -} \ No newline at end of file +} diff --git a/apps/client/src/widgets/ribbon/SearchDefinitionOptions.tsx b/apps/client/src/widgets/ribbon/SearchDefinitionOptions.tsx index 6f98c63e12..69e33cf516 100644 --- a/apps/client/src/widgets/ribbon/SearchDefinitionOptions.tsx +++ b/apps/client/src/widgets/ribbon/SearchDefinitionOptions.tsx @@ -57,7 +57,7 @@ export const SEARCH_OPTIONS: SearchOption[] = [ defaultValue: "root", icon: "bx bx-filter-alt", label: t("search_definition.ancestor"), - component: AncestorOption, + component: AncestorOption, additionalAttributesToDelete: [ { type: "label", name: "ancestorDepth" } ] }, { @@ -173,7 +173,7 @@ function SearchStringOption({ note, refreshResults, error, ...restProps }: Searc } }, [ error ]); - return {t("search_string.search_syntax")} - {t("search_string.also_see")} {t("search_string.complete_help")} @@ -243,7 +243,7 @@ function AncestorOption({ note, ...restProps}: SearchOptionProps) { const options: { value: string | undefined; label: string }[] = [ { value: "", label: t("ancestor.depth_doesnt_matter") }, { value: "eq1", label: `${t("ancestor.depth_eq", { count: 1 })} (${t("ancestor.direct_children")})` } - ]; + ]; for (let i=2; i<=9; i++) options.push({ value: "eq" + i, label: t("ancestor.depth_eq", { count: i }) }); for (let i=0; i<=9; i++) options.push({ value: "gt" + i, label: t("ancestor.depth_gt", { count: i }) }); @@ -253,7 +253,7 @@ function AncestorOption({ note, ...restProps}: SearchOptionProps) { }, []); return
@@ -357,4 +357,4 @@ function LimitOption({ note, defaultValue, ...restProps }: SearchOptionProps) { currentValue={limit ?? defaultValue} onChange={setLimit} /> -} \ No newline at end of file +} diff --git a/apps/client/src/widgets/ribbon/collection-properties-config.ts b/apps/client/src/widgets/ribbon/collection-properties-config.ts index d53513a439..93dbc10760 100644 --- a/apps/client/src/widgets/ribbon/collection-properties-config.ts +++ b/apps/client/src/widgets/ribbon/collection-properties-config.ts @@ -4,6 +4,7 @@ import attributes from "../../services/attributes"; import NoteContextAwareWidget from "../note_context_aware_widget"; 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 } export interface ButtonProperty { @@ -26,7 +27,7 @@ export interface ButtonProperty { export interface NumberProperty { type: "number", label: string; - bindToLabel: string; + bindToLabel: FilterLabelsByType; width?: number; min?: number; } @@ -44,7 +45,7 @@ interface ComboBoxGroup { export interface ComboBoxProperty { type: "combobox", label: string; - bindToLabel: string; + bindToLabel: FilterLabelsByType; /** * The default value is used when the label is not set. */ diff --git a/packages/commons/src/index.ts b/packages/commons/src/index.ts index 7bbde59ffc..ef60f29be3 100644 --- a/packages/commons/src/index.ts +++ b/packages/commons/src/index.ts @@ -9,3 +9,4 @@ export * from "./lib/bulk_actions.js"; export * from "./lib/server_api.js"; export * from "./lib/shared_constants.js"; export * from "./lib/ws_api.js"; +export * from "./lib/attribute_names.js"; diff --git a/packages/commons/src/lib/attribute_names.ts b/packages/commons/src/lib/attribute_names.ts new file mode 100644 index 0000000000..9494d7b925 --- /dev/null +++ b/packages/commons/src/lib/attribute_names.ts @@ -0,0 +1,45 @@ +type Labels = { + color: string; + iconClass: string; + workspaceIconClass: string; + executeDescription: string; + executeTitle: string; + limit: string; // should be probably be number + calendarRoot: boolean; + workspaceCalendarRoot: boolean; + archived: boolean; + sorted: boolean; + template: boolean; + autoReadOnlyDisabled: boolean; + language: string; + originalFileName: string; + pageUrl: string; + + // Search + searchString: string; + ancestorDepth: string; + orderBy: string; + orderDirection: string; + + // Collection-specific + viewType: string; + status: string; + pageSize: number; + geolocation: string; + readOnly: boolean; + expanded: boolean; + "calendar:hideWeekends": boolean; + "calendar:weekNumbers": boolean; + "calendar:view": string; + "map:style": string; + "map:scale": boolean; + "board:groupBy": string; + maxNestingDepth: number; + includeArchived: boolean; +} + +export type LabelNames = keyof Labels; + +export type FilterLabelsByType = { + [K in keyof Labels]: Labels[K] extends U ? K : never; +}[keyof Labels]; From 3ac0dfb2ad00b510361ff81ec26cac24c614f33d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 14 Sep 2025 10:22:20 +0300 Subject: [PATCH 225/233] refactor(react): add type safety for note relations --- apps/client/src/widgets/react/hooks.tsx | 4 ++-- packages/commons/src/lib/attribute_names.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 76ea6077eb..a1f06eeac1 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -2,7 +2,7 @@ import { Inputs, MutableRef, useCallback, useContext, useDebugValue, useEffect, import { CommandListenerData, EventData, EventNames } from "../../components/app_context"; import { ParentComponent } from "./react_utils"; import SpacedUpdate from "../../services/spaced_update"; -import { FilterLabelsByType, KeyboardActionNames, OptionNames } from "@triliumnext/commons"; +import { FilterLabelsByType, KeyboardActionNames, OptionNames, RelationNames } from "@triliumnext/commons"; import options, { type OptionValue } from "../../services/options"; import utils, { escapeRegExp, reloadFrontendApp } from "../../services/utils"; import NoteContext from "../../components/note_context"; @@ -258,7 +258,7 @@ export function useNoteProperty(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(note?.getRelationValue(relationName)); useEffect(() => setRelationValue(note?.getRelationValue(relationName) ?? null), [ note ]); diff --git a/packages/commons/src/lib/attribute_names.ts b/packages/commons/src/lib/attribute_names.ts index 9494d7b925..8b8de89c10 100644 --- a/packages/commons/src/lib/attribute_names.ts +++ b/packages/commons/src/lib/attribute_names.ts @@ -1,3 +1,6 @@ +/** + * A listing of all the labels used by the system (i.e. not user-defined). Labels defined here have a data type which is not enforced, but offers type safety. + */ type Labels = { color: string; iconClass: string; @@ -38,7 +41,17 @@ type Labels = { includeArchived: boolean; } +/** + * A listing of all relations used by the system (i.e. not user-defined). Unlike labels, relations + * always point to a note ID, so no specific data type is necessary. + */ +type Relations = [ + "searchScript", + "ancestor" +]; + export type LabelNames = keyof Labels; +export type RelationNames = Relations[number]; export type FilterLabelsByType = { [K in keyof Labels]: Labels[K] extends U ? K : never; From 4040f8ba89df888ee64f24bd24505c52c0a37f84 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 14 Sep 2025 10:38:05 +0300 Subject: [PATCH 226/233] chore(react): solve most type errors --- apps/client/src/widgets/collections/NoteList.tsx | 2 +- apps/client/src/widgets/collections/board/card.tsx | 4 ++-- .../client/src/widgets/collections/board/column.tsx | 13 ++++++------- apps/client/src/widgets/collections/geomap/api.ts | 2 +- .../client/src/widgets/collections/geomap/index.tsx | 2 +- apps/client/src/widgets/collections/geomap/map.tsx | 2 +- .../widgets/collections/legacy/ListOrGridView.tsx | 4 ++-- .../src/widgets/collections/table/columns.tsx | 2 +- .../src/widgets/collections/table/tabulator.tsx | 6 ++++-- 9 files changed, 19 insertions(+), 18 deletions(-) diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index 3f0231b0df..f16db0b722 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -18,7 +18,7 @@ interface NoteListProps { /** 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; + viewStorage?: ViewModeStorage; } export default function NoteList({ note: providedNote, highlightedTokens, displayOnlyCollections }: NoteListProps) { diff --git a/apps/client/src/widgets/collections/board/card.tsx b/apps/client/src/widgets/collections/board/card.tsx index 917fecefdc..5b663e141a 100644 --- a/apps/client/src/widgets/collections/board/card.tsx +++ b/apps/client/src/widgets/collections/board/card.tsx @@ -31,7 +31,7 @@ export default function Card({ index: number, isDragging: boolean }) { - const { branchIdToEdit, setBranchIdToEdit, setDraggedCard } = useContext(BoardViewContext); + const { branchIdToEdit, setBranchIdToEdit, setDraggedCard } = useContext(BoardViewContext)!; const isEditing = branch.branchId === branchIdToEdit; const colorClass = note.getColorClass() || ''; const editorRef = useRef(null); @@ -78,7 +78,7 @@ export default function Card({ return (
(null); const { handleColumnDragStart, handleColumnDragEnd, handleDragOver, handleDragLeave, handleDrop } = useDragging({ @@ -90,7 +89,7 @@ export default function Column({ >

{ 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; + 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.querySelectorAll('.board-note')); + const cards = Array.from((e.currentTarget as HTMLElement)?.querySelectorAll('.board-note')); const mouseY = e.clientY; let newIndex = cards.length; diff --git a/apps/client/src/widgets/collections/geomap/api.ts b/apps/client/src/widgets/collections/geomap/api.ts index d86ec50b74..5f73415605 100644 --- a/apps/client/src/widgets/collections/geomap/api.ts +++ b/apps/client/src/widgets/collections/geomap/api.ts @@ -1,4 +1,4 @@ -import { LatLng } from "leaflet"; +import type { LatLng, LeafletMouseEvent } from "leaflet"; import { LOCATION_ATTRIBUTE } from "."; import attributes from "../../../services/attributes"; import { prompt } from "../../../services/dialog"; diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index 416aadbe31..ccc1330d19 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -1,4 +1,4 @@ -import Map, { MapApi } from "./map"; +import Map from "./map"; import "./index.css"; import { ViewModeProps } from "../interface"; import { useNoteBlob, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useNoteTreeDrag, useSpacedUpdate, useTouchBar, useTriliumEvent } from "../../react/hooks"; diff --git a/apps/client/src/widgets/collections/geomap/map.tsx b/apps/client/src/widgets/collections/geomap/map.tsx index 06ce63dc28..faf2ec654d 100644 --- a/apps/client/src/widgets/collections/geomap/map.tsx +++ b/apps/client/src/widgets/collections/geomap/map.tsx @@ -8,7 +8,7 @@ import { useElementSize, useSyncedRef } from "../../react/hooks"; export const ParentMap = createContext(null); interface MapProps { - apiRef?: RefObject; + apiRef?: RefObject; containerRef?: RefObject; coordinates: LatLng | [number, number]; zoom: number; diff --git a/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx b/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx index 4d3f0f7952..b88812d729 100644 --- a/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx +++ b/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx @@ -12,7 +12,7 @@ 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) { +export function ListView({ note, noteIds: unfilteredNoteIds, highlightedTokens }: ViewModeProps<{}>) { const [ isExpanded ] = useNoteLabelBoolean(note, "expanded"); const noteIds = useFilteredNoteIds(note, unfilteredNoteIds); const { pageNotes, ...pagination } = usePagination(note, noteIds); @@ -34,7 +34,7 @@ export function ListView({ note, noteIds: unfilteredNoteIds, highlightedTokens } ); } -export function GridView({ note, noteIds: unfilteredNoteIds, highlightedTokens }: ViewModeProps) { +export function GridView({ note, noteIds: unfilteredNoteIds, highlightedTokens }: ViewModeProps<{}>) { const noteIds = useFilteredNoteIds(note, unfilteredNoteIds); const { pageNotes, ...pagination } = usePagination(note, noteIds); diff --git a/apps/client/src/widgets/collections/table/columns.tsx b/apps/client/src/widgets/collections/table/columns.tsx index 43390f04b2..6351ba5987 100644 --- a/apps/client/src/widgets/collections/table/columns.tsx +++ b/apps/client/src/widgets/collections/table/columns.tsx @@ -179,7 +179,6 @@ interface FormatterOpts { interface EditorOpts { cell: CellComponent, - onRendered: EmptyCallback, success: ValueBooleanCallback, cancel: ValueVoidCallback, editorParams: {} @@ -194,6 +193,7 @@ function wrapFormatter(Component: (opts: FormatterOpts) => JSX.Element): ((cell: function wrapEditor(Component: (opts: EditorOpts) => JSX.Element): (( cell: CellComponent, + onRendered: EmptyCallback, success: ValueBooleanCallback, cancel: ValueVoidCallback, editorParams: {}, diff --git a/apps/client/src/widgets/collections/table/tabulator.tsx b/apps/client/src/widgets/collections/table/tabulator.tsx index fdcbeb532f..6b8bc0a428 100644 --- a/apps/client/src/widgets/collections/table/tabulator.tsx +++ b/apps/client/src/widgets/collections/table/tabulator.tsx @@ -2,7 +2,7 @@ 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 { RefObject } from "preact"; +import { JSX, RefObject, VNode } from "preact"; import { ParentComponent, renderReactWidget } from "../../react/react_utils"; interface TableProps extends Omit { @@ -12,9 +12,10 @@ interface TableProps extends Omit Module)[]; events?: Partial; index: keyof T; + footerElement?: JSX.Element; } -export default function Tabulator({ className, columns, data, modules, tabulatorRef: externalTabulatorRef, footerElement, events, ...restProps }: TableProps) { +export default function Tabulator({ className, columns, data, modules, tabulatorRef: externalTabulatorRef, footerElement, events, index, ...restProps }: TableProps) { const parentComponent = useContext(ParentComponent); const containerRef = useRef(null); const tabulatorRef = useRef(null); @@ -33,6 +34,7 @@ export default function Tabulator({ className, columns, data, modules, tabula columns, data, footerElement: (parentComponent && footerElement ? renderReactWidget(parentComponent, footerElement)[0] : undefined), + index: index as string | number | undefined, ...restProps }); From e77e0c54f086a7fe50c532fb3217161e112eb68c Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 14 Sep 2025 10:40:14 +0300 Subject: [PATCH 227/233] chore(react/collections): clean up old files --- .../src/widgets/collections/interface.ts | 2 - .../collections/note_list_renderer.ts.bak | 45 -------- .../view_mode_storage.ts | 0 .../src/widgets/view_widgets/calendar_view.ts | 104 ------------------ .../src/widgets/view_widgets/view_mode.ts | 56 ---------- 5 files changed, 207 deletions(-) delete mode 100644 apps/client/src/widgets/collections/note_list_renderer.ts.bak rename apps/client/src/widgets/{view_widgets => collections}/view_mode_storage.ts (100%) delete mode 100644 apps/client/src/widgets/view_widgets/calendar_view.ts delete mode 100644 apps/client/src/widgets/view_widgets/view_mode.ts diff --git a/apps/client/src/widgets/collections/interface.ts b/apps/client/src/widgets/collections/interface.ts index a162be81e1..0b2fdb22da 100644 --- a/apps/client/src/widgets/collections/interface.ts +++ b/apps/client/src/widgets/collections/interface.ts @@ -1,8 +1,6 @@ import FNote from "../../entities/fnote"; -import type { ViewModeArgs } from "../view_widgets/view_mode"; export const allViewTypes = ["list", "grid", "calendar", "table", "geoMap", "board"] as const; -export type ArgsWithoutNoteId = Omit; export type ViewTypeOptions = typeof allViewTypes[number]; export interface ViewModeProps { diff --git a/apps/client/src/widgets/collections/note_list_renderer.ts.bak b/apps/client/src/widgets/collections/note_list_renderer.ts.bak deleted file mode 100644 index 0b0e489625..0000000000 --- a/apps/client/src/widgets/collections/note_list_renderer.ts.bak +++ /dev/null @@ -1,45 +0,0 @@ -import type FNote from "../../entities/fnote.js"; -import BoardView from "../view_widgets/board_view/index.js"; -import CalendarView from "../view_widgets/calendar_view.js"; -import GeoView from "../view_widgets/geo_view/index.js"; -import ListOrGridView from "../view_widgets/list_or_grid_view.js"; -import TableView from "../view_widgets/table_view/index.js"; -import type ViewMode from "../view_widgets/view_mode.js"; - -export default class NoteListRenderer { - - private viewType: ViewTypeOptions; - private args: ArgsWithoutNoteId; - public viewMode?: ViewMode; - - constructor(args: ArgsWithoutNoteId) { - this.args = args; - this.viewType = this.#getViewType(args.parentNote); - } - - 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); - } - } - -} diff --git a/apps/client/src/widgets/view_widgets/view_mode_storage.ts b/apps/client/src/widgets/collections/view_mode_storage.ts similarity index 100% rename from apps/client/src/widgets/view_widgets/view_mode_storage.ts rename to apps/client/src/widgets/collections/view_mode_storage.ts diff --git a/apps/client/src/widgets/view_widgets/calendar_view.ts b/apps/client/src/widgets/view_widgets/calendar_view.ts deleted file mode 100644 index e689e1bc43..0000000000 --- a/apps/client/src/widgets/view_widgets/calendar_view.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { Calendar, DateSelectArg, DatesSetArg, EventChangeArg, EventDropArg, EventInput, EventSourceFunc, EventSourceFuncArg, EventSourceInput, LocaleInput, PluginDef } from "@fullcalendar/core"; -import froca from "../../services/froca.js"; -import ViewMode, { type ViewModeArgs } from "./view_mode.js"; -import type FNote from "../../entities/fnote.js"; -import server from "../../services/server.js"; -import { t } from "../../services/i18n.js"; -import options from "../../services/options.js"; -import dialogService from "../../services/dialog.js"; -import attributes from "../../services/attributes.js"; -import type { CommandListenerData, EventData } from "../../components/app_context.js"; -import utils, { hasTouchBar } from "../../services/utils.js"; -import date_notes from "../../services/date_notes.js"; -import appContext from "../../components/app_context.js"; -import type { EventImpl } from "@fullcalendar/core/internal"; -import debounce, { type DebouncedFunction } from "debounce"; -import type { TouchBarItem } from "../../components/touch_bar.js"; -import type { SegmentedControlSegment } from "electron"; -import { LOCALE_IDS } from "@triliumnext/commons"; - - -export default class CalendarView extends ViewMode<{}> { - - private $root: JQuery; - private $calendarContainer: JQuery; - private calendar?: Calendar; - private isCalendarRoot: boolean; - - constructor(args: ViewModeArgs) { - super(args, "calendar"); - - this.$root = $(TPL); - this.$calendarContainer = this.$root.find(".calendar-container"); - args.$parent.append(this.$root); - } - - #onDatesSet(e: DatesSetArg) { - if (hasTouchBar) { - appContext.triggerCommand("refreshTouchBar"); - } - } - - buildTouchBarCommand({ TouchBar, buildIcon }: CommandListenerData<"buildTouchBar">) { - if (!this.calendar) { - return; - } - - const items: TouchBarItem[] = []; - const $toolbarItems = this.$calendarContainer.find(".fc-toolbar-chunk .fc-button-group, .fc-toolbar-chunk > button"); - - for (const item of $toolbarItems) { - // Button groups. - if (item.classList.contains("fc-button-group")) { - let mode: "single" | "buttons" = "single"; - let selectedIndex = 0; - const segments: SegmentedControlSegment[] = []; - const subItems = item.childNodes as NodeListOf; - let index = 0; - for (const subItem of subItems) { - if (subItem.ariaPressed === "true") { - selectedIndex = index; - } - index++; - - - // Icon button. - const iconEl = subItem.querySelector("span.fc-icon"); - let icon: string | null = null; - if (iconEl?.classList.contains("fc-icon-chevron-left")) { - icon = "NSImageNameTouchBarGoBackTemplate"; - mode = "buttons"; - } else if (iconEl?.classList.contains("fc-icon-chevron-right")) { - icon = "NSImageNameTouchBarGoForwardTemplate"; - mode = "buttons"; - } - - if (icon) { - segments.push({ - icon: buildIcon(icon) - }); - } - } - - items.push(new TouchBar.TouchBarSegmentedControl({ - mode, - segments, - selectedIndex, - change: (selectedIndex, isSelected) => subItems[selectedIndex].click() - })); - continue; - } - - // Standalone item. - if (item.innerText) { - items.push(new TouchBar.TouchBarButton({ - label: item.innerText, - click: () => item.click() - })); - } - } - - return items; - } - -} diff --git a/apps/client/src/widgets/view_widgets/view_mode.ts b/apps/client/src/widgets/view_widgets/view_mode.ts deleted file mode 100644 index 1bce104995..0000000000 --- a/apps/client/src/widgets/view_widgets/view_mode.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { EventData } from "../../components/app_context.js"; -import appContext from "../../components/app_context.js"; -import Component from "../../components/component.js"; -import type FNote from "../../entities/fnote.js"; -import { ViewTypeOptions } from "../collections/interface.js"; -import ViewModeStorage from "./view_mode_storage.js"; - -export interface ViewModeArgs { - $parent: JQuery; - parentNote: FNote; - parentNotePath?: string | null; - showNotePath?: boolean; -} - -export default abstract class ViewMode extends Component { - - private _viewStorage: ViewModeStorage | null; - protected parentNote: FNote; - protected viewType: ViewTypeOptions; - protected noteIds: string[]; - protected args: ViewModeArgs; - - constructor(args: ViewModeArgs, viewType: ViewTypeOptions) { - super(); - this.parentNote = args.parentNote; - this._viewStorage = null; - // note list must be added to the DOM immediately, otherwise some functionality scripting (canvas) won't work - args.$parent.empty(); - this.viewType = viewType; - this.args = args; - this.noteIds = []; - } - - async beforeRender() { - await this.#refreshNoteIds(); - } - - abstract renderList(): Promise | undefined>; - - /** - * Called whenever an "entitiesReloaded" event has been received by the parent component. - * - * @param e the event data. - * @return {@code true} if the view should be re-rendered, a falsy value otherwise. - */ - async onEntitiesReloaded(e: EventData<"entitiesReloaded">): Promise { - // Do nothing by default. - } - - async entitiesReloadedEvent(e: EventData<"entitiesReloaded">) { - if (await this.onEntitiesReloaded(e)) { - appContext.triggerEvent("refreshNoteList", { noteId: this.parentNote.noteId }); - } - } - -} From 6077da0df8a7cf725fa122ff48dc0cafbcd5cb01 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 14 Sep 2025 10:53:54 +0300 Subject: [PATCH 228/233] chore(react/collections): fix the rest of client type errors --- apps/client/src/widgets/collections/NoteList.tsx | 4 ++-- .../src/widgets/collections/table/tabulator.tsx | 7 ++++--- apps/client/src/widgets/react/TouchBar.tsx | 15 ++++----------- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index f16db0b722..98d9a3febf 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -3,9 +3,9 @@ import { useNoteContext, useNoteLabel, useNoteLabelBoolean, useTriliumEvent } fr import FNote from "../../entities/fnote"; import "./NoteList.css"; import { ListView, GridView } from "./legacy/ListOrGridView"; -import { useEffect, useMemo, useRef, useState } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; import GeoView from "./geomap"; -import ViewModeStorage from "../view_widgets/view_mode_storage"; +import ViewModeStorage from "./view_mode_storage"; import CalendarView from "./calendar"; import TableView from "./table"; import BoardView from "./board"; diff --git a/apps/client/src/widgets/collections/table/tabulator.tsx b/apps/client/src/widgets/collections/table/tabulator.tsx index 6b8bc0a428..57c90b59ad 100644 --- a/apps/client/src/widgets/collections/table/tabulator.tsx +++ b/apps/client/src/widgets/collections/table/tabulator.tsx @@ -2,8 +2,9 @@ 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 { JSX, RefObject, VNode } from "preact"; import { ParentComponent, renderReactWidget } from "../../react/react_utils"; +import { JSX } from "preact/jsx-runtime"; +import { isValidElement, RefObject } from "preact"; interface TableProps extends Omit { tabulatorRef: RefObject; @@ -12,7 +13,7 @@ interface TableProps extends Omit Module)[]; events?: Partial; index: keyof T; - footerElement?: JSX.Element; + footerElement?: string | HTMLElement | JSX.Element; } export default function Tabulator({ className, columns, data, modules, tabulatorRef: externalTabulatorRef, footerElement, events, index, ...restProps }: TableProps) { @@ -33,7 +34,7 @@ export default function Tabulator({ className, columns, data, modules, tabula const tabulator = new VanillaTabulator(containerRef.current, { columns, data, - footerElement: (parentComponent && footerElement ? renderReactWidget(parentComponent, footerElement)[0] : undefined), + footerElement: (parentComponent && isValidElement(footerElement) ? renderReactWidget(parentComponent, footerElement)[0] : undefined), index: index as string | number | undefined, ...restProps }); diff --git a/apps/client/src/widgets/react/TouchBar.tsx b/apps/client/src/widgets/react/TouchBar.tsx index 215b44f1a0..e0f40fd879 100644 --- a/apps/client/src/widgets/react/TouchBar.tsx +++ b/apps/client/src/widgets/react/TouchBar.tsx @@ -148,19 +148,12 @@ export function TouchBarButton({ label, icon, click, enabled }: ButtonProps) { export function TouchBarSegmentedControl({ mode, segments, selectedIndex, onChange }: SegmentedControlProps) { const api = useContext(TouchBarContext); - const processedSegments = segments.map((segment) => { - if (segment.icon) { - if (!api) return undefined; - return { - ...segment, - icon: buildIcon(api?.nativeImage, segment.icon) - } - } else { - return segment; - } - }); 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, From 970f4b028d9fb0803e7a91991a8e8956aff4c3f4 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 14 Sep 2025 10:58:11 +0300 Subject: [PATCH 229/233] chore(server): fix a few more type errors --- apps/edit-docs/src/utils.ts | 2 +- apps/server/src/services/import/single.spec.ts | 2 +- apps/server/src/services/import/zip.spec.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/edit-docs/src/utils.ts b/apps/edit-docs/src/utils.ts index 28740f3bd6..8a03ef6a00 100644 --- a/apps/edit-docs/src/utils.ts +++ b/apps/edit-docs/src/utils.ts @@ -52,7 +52,7 @@ export function startElectron(callback: () => void): DeferredPromise { export async function importData(path: string) { const buffer = await createImportZip(path); const importService = (await import("@triliumnext/server/src/services/import/zip.js")).default; - const context = new TaskContext("no-progress-reporting", "import", false); + const context = new TaskContext("no-progress-reporting", "importNotes", null); const becca = (await import("@triliumnext/server/src/becca/becca.js")).default; const rootNote = becca.getRoot(); diff --git a/apps/server/src/services/import/single.spec.ts b/apps/server/src/services/import/single.spec.ts index b16124cbb5..0d0af35f74 100644 --- a/apps/server/src/services/import/single.spec.ts +++ b/apps/server/src/services/import/single.spec.ts @@ -14,7 +14,7 @@ const scriptDir = dirname(fileURLToPath(import.meta.url)); async function testImport(fileName: string, mimetype: string) { const buffer = fs.readFileSync(path.join(scriptDir, "samples", fileName)); - const taskContext = TaskContext.getInstance("import-mdx", "import", { + const taskContext = TaskContext.getInstance("import-mdx", "importNotes", { textImportedAsText: true, codeImportedAsCode: true }); diff --git a/apps/server/src/services/import/zip.spec.ts b/apps/server/src/services/import/zip.spec.ts index db2c7ba76e..a74c243c45 100644 --- a/apps/server/src/services/import/zip.spec.ts +++ b/apps/server/src/services/import/zip.spec.ts @@ -14,7 +14,7 @@ const scriptDir = dirname(fileURLToPath(import.meta.url)); async function testImport(fileName: string) { const mdxSample = fs.readFileSync(path.join(scriptDir, "samples", fileName)); - const taskContext = TaskContext.getInstance("import-mdx", "import", { + const taskContext = TaskContext.getInstance("import-mdx", "importNotes", { textImportedAsText: true }); From d36716bdb6ee3e1cc69a4837a30b09b5291cc559 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 14 Sep 2025 10:59:15 +0300 Subject: [PATCH 230/233] chore(client): tests not being able to access .tsx --- apps/client/tsconfig.spec.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/client/tsconfig.spec.json b/apps/client/tsconfig.spec.json index dedbb02939..152d9cb276 100644 --- a/apps/client/tsconfig.spec.json +++ b/apps/client/tsconfig.spec.json @@ -8,6 +8,9 @@ "node", "vitest" ], + "jsx": "preserve", + "jsxFactory": "h", + "jsxImportSource": "preact", "module": "esnext", "moduleResolution": "bundler" }, From 1de9634c4435ab6d0bb8c004a0b213ee38e60610 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 14 Sep 2025 11:29:19 +0300 Subject: [PATCH 231/233] chore(client): remove unnecessary logs --- .../src/widgets/collections/NoteList.tsx | 1 - .../src/widgets/collections/geomap/map.tsx | 1 - .../ribbon/components/AttributeEditor.tsx | 21 +++++++++---------- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index 98d9a3febf..63eb64a993 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -123,7 +123,6 @@ function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOptions | } async function getNoteIds(note: FNote) { - console.log("Refreshed note IDs"); if (viewType === "list" || viewType === "grid") { return note.getChildNoteIds(); } else { diff --git a/apps/client/src/widgets/collections/geomap/map.tsx b/apps/client/src/widgets/collections/geomap/map.tsx index faf2ec654d..da9c2173bd 100644 --- a/apps/client/src/widgets/collections/geomap/map.tsx +++ b/apps/client/src/widgets/collections/geomap/map.tsx @@ -68,7 +68,6 @@ export default function Map({ coordinates, zoom, layerName, viewportChanged, chi useEffect(() => { const map = mapRef.current; const layerToAdd = layer; - console.log("Add layer ", map, layerToAdd); if (!map || !layerToAdd) return; layerToAdd.addTo(map); return () => layerToAdd.removeFrom(map); diff --git a/apps/client/src/widgets/ribbon/components/AttributeEditor.tsx b/apps/client/src/widgets/ribbon/components/AttributeEditor.tsx index 08241f9316..32cae708b3 100644 --- a/apps/client/src/widgets/ribbon/components/AttributeEditor.tsx +++ b/apps/client/src/widgets/ribbon/components/AttributeEditor.tsx @@ -132,7 +132,7 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI if (htmlAttrs.length > 0) { htmlAttrs += " "; - } + } editorRef.current?.setText(htmlAttrs); setCurrentValue(htmlAttrs); @@ -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(); } }); @@ -262,8 +261,8 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI } return result?.note?.getBestNotePathString(); - } - }), [ notePath ])); + } + }), [ notePath ])); // Keyboard shortcuts useTriliumEvent("addNewLabel", ({ ntxId: eventNtxId }) => { @@ -281,7 +280,7 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI refresh, renderOwnedAttributes: (attributes) => renderOwnedAttributes(attributes as FAttribute[], false) }), [ save, refresh, renderOwnedAttributes ]); - + return ( <> {!hidden &&
save(), 100); @@ -313,9 +312,9 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI }} onChange={(currentValue) => { currentValueRef.current = currentValue ?? ""; - + const oldValue = getPreprocessedData(lastSavedContent.current ?? "").trimEnd(); - const newValue = getPreprocessedData(currentValue ?? "").trimEnd(); + const newValue = getPreprocessedData(currentValue ?? "").trimEnd(); setNeedsSaving(oldValue !== newValue); setError(undefined); }} @@ -351,7 +350,7 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI x: e.pageX, y: e.pageY }); - setState("showAttributeDetail"); + setState("showAttributeDetail"); } else { setState("showHelpTooltip"); } @@ -373,7 +372,7 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI onClick={save} /> } - - ) + ) } function getPreprocessedData(currentValue: string) { From b80c4ed9217297c8c0e32217008444882689760a Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 14 Sep 2025 11:43:26 +0300 Subject: [PATCH 232/233] chore(client): remove unnecessary file --- .../src/widgets/collections/note_list.bak | 76 ------------------- 1 file changed, 76 deletions(-) delete mode 100644 apps/client/src/widgets/collections/note_list.bak diff --git a/apps/client/src/widgets/collections/note_list.bak b/apps/client/src/widgets/collections/note_list.bak deleted file mode 100644 index 53b8893f91..0000000000 --- a/apps/client/src/widgets/collections/note_list.bak +++ /dev/null @@ -1,76 +0,0 @@ -import NoteContextAwareWidget from "../note_context_aware_widget.js"; -import NoteListRenderer from "./note_list_renderer.ts.bak/index.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"; - -export default class NoteListWidget extends NoteContextAwareWidget { - - private $content!: JQuery; - private noteIdRefreshed?: string; - private shownNoteId?: string | null; - private viewMode?: ViewMode | null; - - 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(name: K, data?: CommandMappings[K]): Promise | 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(name: T, data: EventData): Promise | null { - super.handleEventInChildren(name, data); - - if (this.viewMode) { - const ret = this.viewMode.handleEvent(name, data); - if (ret) { - return ret; - } - } - - return null; - } - -} From ad366ee92801d88e14218213abb3a9143e4cf759 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 14 Sep 2025 18:25:14 +0300 Subject: [PATCH 233/233] docs(help): document new features for collections --- .../doc_notes/en/User Guide/!!!meta.json | 2 +- .../Navigation/Quick edit.clone.html | 1 + .../Note Tree/Note tree contextual menu.html | 218 +++--- .../Quick edit.html | 0 .../Quick edit_image.png | Bin .../User Guide/Note Types/Collections.html | 41 +- .../Note Types/Collections/Board View.html | 55 +- .../Note Types/Collections/Geo Map View.html | 645 +++++++++--------- .../Note Types/Collections/Table View.html | 128 ++-- docs/User Guide/!!!meta.json | 7 + .../Note Tree/Note tree contextual menu.md | 4 + .../User Guide/Note Types/Collections.md | 6 + .../Note Types/Collections/Board View.md | 9 +- .../Note Types/Collections/Geo Map View.md | 13 +- .../Note Types/Collections/Table View.md | 3 +- 15 files changed, 607 insertions(+), 525 deletions(-) create mode 100644 apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Navigation/Quick edit.clone.html rename apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/{Navigation => UI Elements}/Quick edit.html (100%) rename apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/{Navigation => UI Elements}/Quick edit_image.png (100%) diff --git a/apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json b/apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json index b41d393635..bd6d49aabe 100644 --- a/apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json +++ b/apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json @@ -1 +1 @@ -[{"id":"_help_BOCnjTMBCoxW","title":"Feature Highlights","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Feature Highlights"},{"name":"iconClass","value":"bx bx-star","type":"label"}]},{"id":"_help_Otzi9La2YAUX","title":"Installation & Setup","type":"book","attributes":[{"name":"iconClass","value":"bx bx-cog","type":"label"}],"children":[{"id":"_help_poXkQfguuA0U","title":"Desktop Installation","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Desktop Installation"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_WOcw2SLH6tbX","title":"Server Installation","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_Dgg7bR3b6K9j","title":"1. Installing the server","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_3tW6mORuTHnB","title":"Packaged version for Linux","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Packaged version for Linux"},{"name":"iconClass","value":"bx bxl-tux","type":"label"}]},{"id":"_help_rWX5eY045zbE","title":"Using Docker","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Using Docker"},{"name":"iconClass","value":"bx bxl-docker","type":"label"}]},{"id":"_help_moVgBcoxE3EK","title":"On NixOS","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/On NixOS"},{"name":"iconClass","value":"bx bxl-tux","type":"label"}]},{"id":"_help_J1Bb6lVlwU5T","title":"Manually","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Manually"},{"name":"iconClass","value":"bx bx-code-alt","type":"label"}]},{"id":"_help_DCmT6e7clMoP","title":"Using Kubernetes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Using Kubernetes"},{"name":"iconClass","value":"bx bxl-kubernetes","type":"label"}]},{"id":"_help_klCWNks3ReaQ","title":"Multiple server instances","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Multiple server instances"},{"name":"iconClass","value":"bx bxs-user-account","type":"label"}]}]},{"id":"_help_vcjrb3VVYPZI","title":"2. Reverse proxy","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_ud6MShXL4WpO","title":"Nginx","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/2. Reverse proxy/Nginx"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_fDLvzOx29Pfg","title":"Apache","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/2. Reverse proxy/Apache"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_l2VkvOwUNfZj","title":"TLS Configuration","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/TLS Configuration"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_0hzsNCP31IAB","title":"Authentication","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/Authentication"},{"name":"iconClass","value":"bx bx-lock-alt","type":"label"}]},{"id":"_help_7DAiwaf8Z7Rz","title":"Multi-Factor Authentication","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/Multi-Factor Authentication"},{"name":"iconClass","value":"bx bx-stopwatch","type":"label"}]}]},{"id":"_help_cbkrhQjrkKrh","title":"Synchronization","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Synchronization"},{"name":"iconClass","value":"bx bx-sync","type":"label"}]},{"id":"_help_RDslemsQ6gCp","title":"Mobile Frontend","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Mobile Frontend"},{"name":"iconClass","value":"bx bx-mobile-alt","type":"label"}]},{"id":"_help_MtPxeAWVAzMg","title":"Web Clipper","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Web Clipper"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_n1lujUxCwipy","title":"Upgrading TriliumNext","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Upgrading TriliumNext"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_ODY7qQn5m2FT","title":"Backup","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Backup"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_tAassRL4RSQL","title":"Data directory","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Data directory"},{"name":"iconClass","value":"bx bx-folder-open","type":"label"}]}]},{"id":"_help_gh7bpGYxajRS","title":"Basic Concepts and Features","type":"book","attributes":[{"name":"iconClass","value":"bx bx-help-circle","type":"label"}],"children":[{"id":"_help_Vc8PjrjAGuOp","title":"UI Elements","type":"book","attributes":[{"name":"iconClass","value":"bx bx-window-alt","type":"label"}],"children":[{"id":"_help_x0JgW8UqGXvq","title":"Vertical and horizontal layout","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Vertical and horizontal layout"},{"name":"iconClass","value":"bx bxs-layout","type":"label"}]},{"id":"_help_x3i7MxGccDuM","title":"Global menu","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Global menu"},{"name":"iconClass","value":"bx bx-menu","type":"label"}]},{"id":"_help_oPVyFC7WL2Lp","title":"Note Tree","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree"},{"name":"iconClass","value":"bx bxs-tree-alt","type":"label"}],"children":[{"id":"_help_YtSN43OrfzaA","title":"Note tree contextual menu","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Note tree contextual menu"},{"name":"iconClass","value":"bx bx-menu","type":"label"}]},{"id":"_help_yTjUdsOi4CIE","title":"Multiple selection","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Multiple selection"},{"name":"iconClass","value":"bx bx-list-plus","type":"label"}]},{"id":"_help_DvdZhoQZY9Yd","title":"Keyboard shortcuts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Keyboard shortcuts"},{"name":"iconClass","value":"bx bxs-keyboard","type":"label"}]}]},{"id":"_help_BlN9DFI679QC","title":"Ribbon","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Ribbon"},{"name":"iconClass","value":"bx bx-dots-horizontal","type":"label"}]},{"id":"_help_3seOhtN8uLIY","title":"Tabs","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Tabs"},{"name":"iconClass","value":"bx bx-dock-top","type":"label"}]},{"id":"_help_xYmIYSP6wE3F","title":"Launch Bar","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Launch Bar"},{"name":"iconClass","value":"bx bx-sidebar","type":"label"}]},{"id":"_help_8YBEPzcpUgxw","title":"Note buttons","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note buttons"},{"name":"iconClass","value":"bx bx-dots-vertical-rounded","type":"label"}]},{"id":"_help_4TIF1oA4VQRO","title":"Options","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Options"},{"name":"iconClass","value":"bx bx-cog","type":"label"}]},{"id":"_help_luNhaphA37EO","title":"Split View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Split View"},{"name":"iconClass","value":"bx bx-dock-right","type":"label"}]},{"id":"_help_XpOYSgsLkTJy","title":"Floating buttons","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Floating buttons"},{"name":"iconClass","value":"bx bx-rectangle","type":"label"}]},{"id":"_help_RnaPdbciOfeq","title":"Right Sidebar","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Right Sidebar"},{"name":"iconClass","value":"bx bxs-dock-right","type":"label"}]},{"id":"_help_r5JGHN99bVKn","title":"Recent Changes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Recent Changes"},{"name":"iconClass","value":"bx bx-history","type":"label"}]},{"id":"_help_ny318J39E5Z0","title":"Zoom","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Zoom"},{"name":"iconClass","value":"bx bx-zoom-in","type":"label"}]},{"id":"_help_lgKX7r3aL30x","title":"Note Tooltip","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tooltip"},{"name":"iconClass","value":"bx bx-message-detail","type":"label"}]}]},{"id":"_help_BFs8mudNFgCS","title":"Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes"},{"name":"iconClass","value":"bx bx-notepad","type":"label"}],"children":[{"id":"_help_p9kXRFAkwN4o","title":"Note Icons","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Note Icons"},{"name":"iconClass","value":"bx bxs-grid","type":"label"}]},{"id":"_help_0vhv7lsOLy82","title":"Attachments","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Attachments"},{"name":"iconClass","value":"bx bx-paperclip","type":"label"}]},{"id":"_help_IakOLONlIfGI","title":"Cloning Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Cloning Notes"},{"name":"iconClass","value":"bx bx-duplicate","type":"label"}],"children":[{"id":"_help_TBwsyfadTA18","title":"Branch prefix","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Cloning Notes/Branch prefix"},{"name":"iconClass","value":"bx bx-rename","type":"label"}]}]},{"id":"_help_bwg0e8ewQMak","title":"Protected Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Protected Notes"},{"name":"iconClass","value":"bx bx-lock-alt","type":"label"}]},{"id":"_help_MKmLg5x6xkor","title":"Archived Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Archived Notes"},{"name":"iconClass","value":"bx bx-box","type":"label"}]},{"id":"_help_vZWERwf8U3nx","title":"Note Revisions","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Note Revisions"},{"name":"iconClass","value":"bx bx-history","type":"label"}]},{"id":"_help_aGlEvb9hyDhS","title":"Sorting Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Sorting Notes"},{"name":"iconClass","value":"bx bx-sort-up","type":"label"}]},{"id":"_help_NRnIZmSMc5sj","title":"Export as PDF","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Export as PDF"},{"name":"iconClass","value":"bx bxs-file-pdf","type":"label"}]},{"id":"_help_CoFPLs3dRlXc","title":"Read-Only Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Read-Only Notes"},{"name":"iconClass","value":"bx bx-edit-alt","type":"label"}]},{"id":"_help_0ESUbbAxVnoK","title":"Note List","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Note List"},{"name":"iconClass","value":"bx bxs-grid","type":"label"}]}]},{"id":"_help_wArbEsdSae6g","title":"Navigation","type":"book","attributes":[{"name":"iconClass","value":"bx bx-navigation","type":"label"}],"children":[{"id":"_help_kBrnXNG3Hplm","title":"Tree Concepts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Tree Concepts"},{"name":"iconClass","value":"bx bx-pyramid","type":"label"}]},{"id":"_help_MMiBEQljMQh2","title":"Note Navigation","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Note Navigation"},{"name":"iconClass","value":"bx bxs-navigation","type":"label"}]},{"id":"_help_Ms1nauBra7gq","title":"Quick search","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Quick search"},{"name":"iconClass","value":"bx bx-search-alt-2","type":"label"}]},{"id":"_help_F1r9QtzQLZqm","title":"Jump to...","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Jump to"},{"name":"iconClass","value":"bx bx-send","type":"label"}]},{"id":"_help_eIg8jdvaoNNd","title":"Search","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Search"},{"name":"iconClass","value":"bx bx-search-alt-2","type":"label"}]},{"id":"_help_u3YFHC9tQlpm","title":"Bookmarks","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Bookmarks"},{"name":"iconClass","value":"bx bx-bookmarks","type":"label"}]},{"id":"_help_OR8WJ7Iz9K4U","title":"Note Hoisting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Note Hoisting"},{"name":"iconClass","value":"bx bxs-chevrons-up","type":"label"}]},{"id":"_help_ZjLYv08Rp3qC","title":"Quick edit","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Quick edit"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_9sRHySam5fXb","title":"Workspaces","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Workspaces"},{"name":"iconClass","value":"bx bx-door-open","type":"label"}]},{"id":"_help_xWtq5NUHOwql","title":"Similar Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Similar Notes"},{"name":"iconClass","value":"bx bx-bar-chart","type":"label"}]},{"id":"_help_McngOG2jbUWX","title":"Search in note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Search in note"},{"name":"iconClass","value":"bx bx-search-alt-2","type":"label"}]}]},{"id":"_help_A9Oc6YKKc65v","title":"Keyboard Shortcuts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Keyboard Shortcuts"},{"name":"iconClass","value":"bx bxs-keyboard","type":"label"}]},{"id":"_help_Wy267RK4M69c","title":"Themes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Themes"},{"name":"iconClass","value":"bx bx-palette","type":"label"}],"children":[{"id":"_help_VbjZvtUek0Ln","title":"Theme Gallery","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Themes/Theme Gallery"},{"name":"iconClass","value":"bx bx-book-reader","type":"label"}]}]},{"id":"_help_mHbBMPDPkVV5","title":"Import & Export","type":"book","attributes":[{"name":"iconClass","value":"bx bx-import","type":"label"}],"children":[{"id":"_help_Oau6X9rCuegd","title":"Markdown","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Import & Export/Markdown"},{"name":"iconClass","value":"bx bxl-markdown","type":"label"}],"children":[{"id":"_help_rJ9grSgoExl9","title":"Supported syntax","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Import & Export/Markdown/Supported syntax"},{"name":"iconClass","value":"bx bx-code-alt","type":"label"}]}]},{"id":"_help_syuSEKf2rUGr","title":"Evernote","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Import & Export/Evernote"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_GnhlmrATVqcH","title":"OneNote","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Import & Export/OneNote"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_rC3pL2aptaRE","title":"Zen mode","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Zen mode"},{"name":"iconClass","value":"bx bxs-yin-yang","type":"label"}]}]},{"id":"_help_s3YCWHBfmYuM","title":"Quick Start","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Quick Start"},{"name":"iconClass","value":"bx bx-run","type":"label"}]},{"id":"_help_i6dbnitykE5D","title":"FAQ","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/FAQ"},{"name":"iconClass","value":"bx bx-question-mark","type":"label"}]},{"id":"_help_KSZ04uQ2D1St","title":"Note Types","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types"},{"name":"iconClass","value":"bx bx-edit","type":"label"}],"children":[{"id":"_help_iPIMuisry3hd","title":"Text","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text"},{"name":"iconClass","value":"bx bx-note","type":"label"}],"children":[{"id":"_help_NwBbFdNZ9h7O","title":"Block quotes & admonitions","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Block quotes & admonitions"},{"name":"iconClass","value":"bx bx-info-circle","type":"label"}]},{"id":"_help_oSuaNgyyKnhu","title":"Bookmarks","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Bookmarks"},{"name":"iconClass","value":"bx bx-bookmark","type":"label"}]},{"id":"_help_veGu4faJErEM","title":"Content language & Right-to-left support","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Content language & Right-to-le"},{"name":"iconClass","value":"bx bx-align-right","type":"label"}]},{"id":"_help_2x0ZAX9ePtzV","title":"Cut to subnote","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Cut to subnote"},{"name":"iconClass","value":"bx bx-cut","type":"label"}]},{"id":"_help_UYuUB1ZekNQU","title":"Developer-specific formatting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Developer-specific formatting"},{"name":"iconClass","value":"bx bx-code-alt","type":"label"}],"children":[{"id":"_help_QxEyIjRBizuC","title":"Code blocks","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Developer-specific formatting/Code blocks"},{"name":"iconClass","value":"bx bx-code","type":"label"}]}]},{"id":"_help_AgjCISero73a","title":"Footnotes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Footnotes"},{"name":"iconClass","value":"bx bx-bracket","type":"label"}]},{"id":"_help_nRhnJkTT8cPs","title":"Formatting toolbar","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Formatting toolbar"},{"name":"iconClass","value":"bx bx-text","type":"label"}]},{"id":"_help_Gr6xFaF6ioJ5","title":"General formatting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/General formatting"},{"name":"iconClass","value":"bx bx-bold","type":"label"}]},{"id":"_help_AxshuNRegLAv","title":"Highlights list","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Highlights list"},{"name":"iconClass","value":"bx bx-highlight","type":"label"}]},{"id":"_help_mT0HEkOsz6i1","title":"Images","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Images"},{"name":"iconClass","value":"bx bx-image-alt","type":"label"}],"children":[{"id":"_help_0Ofbk1aSuVRu","title":"Image references","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Images/Image references"},{"name":"iconClass","value":"bx bxs-file-image","type":"label"}]}]},{"id":"_help_nBAXQFj20hS1","title":"Include Note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Include Note"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_CohkqWQC1iBv","title":"Insert buttons","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Insert buttons"},{"name":"iconClass","value":"bx bx-plus","type":"label"}]},{"id":"_help_oiVPnW8QfnvS","title":"Keyboard shortcuts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Keyboard shortcuts"},{"name":"iconClass","value":"bx bxs-keyboard","type":"label"}]},{"id":"_help_QEAPj01N5f7w","title":"Links","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Links"},{"name":"iconClass","value":"bx bx-link-alt","type":"label"}],"children":[{"id":"_help_3IDVtesTQ8ds","title":"External links","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Links/External links"},{"name":"iconClass","value":"bx bx-link-external","type":"label"}]},{"id":"_help_hrZ1D00cLbal","title":"Internal (reference) links","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Links/Internal (reference) links"},{"name":"iconClass","value":"bx bx-link","type":"label"}]}]},{"id":"_help_S6Xx8QIWTV66","title":"Lists","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Lists"},{"name":"iconClass","value":"bx bx-list-ul","type":"label"}]},{"id":"_help_QrtTYPmdd1qq","title":"Markdown-like formatting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Markdown-like formatting"},{"name":"iconClass","value":"bx bxl-markdown","type":"label"}]},{"id":"_help_YfYAtQBcfo5V","title":"Math Equations","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Math Equations"},{"name":"iconClass","value":"bx bx-math","type":"label"}]},{"id":"_help_dEHYtoWWi8ct","title":"Other features","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Other features"},{"name":"iconClass","value":"bx bxs-grid","type":"label"}]},{"id":"_help_gLt3vA97tMcp","title":"Premium features","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Premium features"},{"name":"iconClass","value":"bx bx-star","type":"label"}],"children":[{"id":"_help_ZlN4nump6EbW","title":"Slash Commands","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Premium features/Slash Commands"},{"name":"iconClass","value":"bx bx-menu","type":"label"}]},{"id":"_help_pwc194wlRzcH","title":"Text Snippets","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Premium features/Text Snippets"},{"name":"iconClass","value":"bx bx-align-left","type":"label"}]}]},{"id":"_help_BFvAtE74rbP6","title":"Table of contents","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Table of contents"},{"name":"iconClass","value":"bx bx-heading","type":"label"}]},{"id":"_help_NdowYOC1GFKS","title":"Tables","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Tables"},{"name":"iconClass","value":"bx bx-table","type":"label"}]}]},{"id":"_help_6f9hih2hXXZk","title":"Code","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Code"},{"name":"iconClass","value":"bx bx-code","type":"label"}]},{"id":"_help_m523cpzocqaD","title":"Saved Search","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Saved Search"},{"name":"iconClass","value":"bx bx-file-find","type":"label"}]},{"id":"_help_iRwzGnHPzonm","title":"Relation Map","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Relation Map"},{"name":"iconClass","value":"bx bxs-network-chart","type":"label"}]},{"id":"_help_bdUJEHsAPYQR","title":"Note Map","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Note Map"},{"name":"iconClass","value":"bx bxs-network-chart","type":"label"}]},{"id":"_help_HcABDtFCkbFN","title":"Render Note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Render Note"},{"name":"iconClass","value":"bx bx-extension","type":"label"}]},{"id":"_help_GTwFsgaA0lCt","title":"Collections","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Collections"},{"name":"iconClass","value":"bx bx-book","type":"label"}],"children":[{"id":"_help_xWbu3jpNWapp","title":"Calendar View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Collections/Calendar View"},{"name":"iconClass","value":"bx bx-calendar","type":"label"}]},{"id":"_help_81SGnPGMk7Xc","title":"Geo Map View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Collections/Geo Map View"},{"name":"iconClass","value":"bx bx-map-alt","type":"label"}]},{"id":"_help_8QqnMzx393bx","title":"Grid View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Collections/Grid View"},{"name":"iconClass","value":"bx bxs-grid","type":"label"}]},{"id":"_help_mULW0Q3VojwY","title":"List View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Collections/List View"},{"name":"iconClass","value":"bx bx-list-ul","type":"label"}]},{"id":"_help_2FvYrpmOXm29","title":"Table View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Collections/Table View"},{"name":"iconClass","value":"bx bx-table","type":"label"}]},{"id":"_help_CtBQqbwXDx1w","title":"Board View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Collections/Board View"},{"name":"iconClass","value":"bx bx-columns","type":"label"}]}]},{"id":"_help_s1aBHPd79XYj","title":"Mermaid Diagrams","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Mermaid Diagrams"},{"name":"iconClass","value":"bx bx-selection","type":"label"}],"children":[{"id":"_help_RH6yLjjWJHof","title":"ELK layout","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Mermaid Diagrams/ELK layout"},{"name":"iconClass","value":"bx bxs-network-chart","type":"label"}]}]},{"id":"_help_grjYqerjn243","title":"Canvas","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Canvas"},{"name":"iconClass","value":"bx bx-pen","type":"label"}]},{"id":"_help_1vHRoWCEjj0L","title":"Web View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Web View"},{"name":"iconClass","value":"bx bx-globe-alt","type":"label"}]},{"id":"_help_gBbsAeiuUxI5","title":"Mind Map","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Mind Map"},{"name":"iconClass","value":"bx bx-sitemap","type":"label"}]},{"id":"_help_W8vYD3Q1zjCR","title":"File","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/File"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_BgmBlOIl72jZ","title":"Troubleshooting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting"},{"name":"iconClass","value":"bx bx-bug","type":"label"}],"children":[{"id":"_help_wy8So3yZZlH9","title":"Reporting issues","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Reporting issues"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_x59R8J8KV5Bp","title":"Anonymized Database","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Anonymized Database"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_qzNzp9LYQyPT","title":"Error logs","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Error logs"},{"name":"iconClass","value":"bx bx-comment-error","type":"label"}],"children":[{"id":"_help_bnyigUA2UK7s","title":"Backend (server) logs","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Error logs/Backend (server) logs"},{"name":"iconClass","value":"bx bx-server","type":"label"}]},{"id":"_help_9yEHzMyFirZR","title":"Frontend logs","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Error logs/Frontend logs"},{"name":"iconClass","value":"bx bx-window-alt","type":"label"}]}]},{"id":"_help_vdlYGAcpXAgc","title":"Synchronization fails with 504 Gateway Timeout","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Synchronization fails with 504"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_s8alTXmpFR61","title":"Refreshing the application","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Refreshing the application"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_pKK96zzmvBGf","title":"Theme development","type":"book","attributes":[{"name":"iconClass","value":"bx bx-palette","type":"label"}],"children":[{"id":"_help_7NfNr5pZpVKV","title":"Creating a custom theme","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Theme development/Creating a custom theme"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_WFGzWeUK6arS","title":"Customize the Next theme","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Theme development/Customize the Next theme"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_WN5z4M8ASACJ","title":"Reference","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Theme development/Reference"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_AlhDUqhENtH7","title":"Custom app-wide CSS","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Theme development/Custom app-wide CSS"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_tC7s2alapj8V","title":"Advanced Usage","type":"book","attributes":[{"name":"iconClass","value":"bx bx-rocket","type":"label"}],"children":[{"id":"_help_zEY4DaJG4YT5","title":"Attributes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes"},{"name":"iconClass","value":"bx bx-list-check","type":"label"}],"children":[{"id":"_help_HI6GBBIduIgv","title":"Labels","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes/Labels"},{"name":"iconClass","value":"bx bx-hash","type":"label"}]},{"id":"_help_Cq5X6iKQop6R","title":"Relations","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes/Relations"},{"name":"iconClass","value":"bx bx-transfer","type":"label"}]},{"id":"_help_bwZpz2ajCEwO","title":"Attribute Inheritance","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes/Attribute Inheritance"},{"name":"iconClass","value":"bx bx-list-plus","type":"label"}]},{"id":"_help_OFXdgB2nNk1F","title":"Promoted Attributes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes/Promoted Attributes"},{"name":"iconClass","value":"bx bx-table","type":"label"}]}]},{"id":"_help_KC1HB96bqqHX","title":"Templates","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Templates"},{"name":"iconClass","value":"bx bx-copy","type":"label"}]},{"id":"_help_BCkXAVs63Ttv","title":"Note Map (Link map, Tree map)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Note Map (Link map, Tree map)"},{"name":"iconClass","value":"bx bxs-network-chart","type":"label"}]},{"id":"_help_R9pX4DGra2Vt","title":"Sharing","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Sharing"},{"name":"iconClass","value":"bx bx-share-alt","type":"label"}],"children":[{"id":"_help_Qjt68inQ2bRj","title":"Serving directly the content of a note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Sharing/Serving directly the content o"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_5668rwcirq1t","title":"Advanced Showcases","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Advanced Showcases"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_l0tKav7yLHGF","title":"Day Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Advanced Showcases/Day Notes"},{"name":"iconClass","value":"bx bx-calendar","type":"label"}]},{"id":"_help_R7abl2fc6Mxi","title":"Weight Tracker","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Advanced Showcases/Weight Tracker"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_xYjQUYhpbUEW","title":"Task Manager","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Advanced Showcases/Task Manager"},{"name":"iconClass","value":"bx bx-calendar-check","type":"label"}]}]},{"id":"_help_J5Ex1ZrMbyJ6","title":"Custom Request Handler","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Custom Request Handler"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_d3fAXQ2diepH","title":"Custom Resource Providers","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Custom Resource Providers"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_pgxEVkzLl1OP","title":"ETAPI (REST API)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/ETAPI (REST API)"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_9qPsTWBorUhQ","title":"API Reference","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"/etapi/docs"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_47ZrP6FNuoG8","title":"Default Note Title","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Default Note Title"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_wX4HbRucYSDD","title":"Database","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Database"},{"name":"iconClass","value":"bx bx-data","type":"label"}],"children":[{"id":"_help_oyIAJ9PvvwHX","title":"Manually altering the database","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Database/Manually altering the database"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_YKWqdJhzi2VY","title":"SQL Console","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Database/Manually altering the database/SQL Console"},{"name":"iconClass","value":"bx bx-data","type":"label"}]}]},{"id":"_help_6tZeKvSHEUiB","title":"Demo Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Database/Demo Notes"},{"name":"iconClass","value":"bx bx-package","type":"label"}]}]},{"id":"_help_Gzjqa934BdH4","title":"Configuration (config.ini or environment variables)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Configuration (config.ini or e"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_c5xB8m4g2IY6","title":"Trilium instance","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Configuration (config.ini or environment variables)/Trilium instance"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_LWtBjFej3wX3","title":"Cross-Origin Resource Sharing (CORS)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Configuration (config.ini or environment variables)/Cross-Origin Resource Sharing "},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_ivYnonVFBxbQ","title":"Bulk Actions","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Bulk Actions"},{"name":"iconClass","value":"bx bx-list-plus","type":"label"}]},{"id":"_help_4FahAwuGTAwC","title":"Note source","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Note source"},{"name":"iconClass","value":"bx bx-code","type":"label"}]},{"id":"_help_1YeN2MzFUluU","title":"Technologies used","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used"},{"name":"iconClass","value":"bx bxs-component","type":"label"}],"children":[{"id":"_help_MI26XDLSAlCD","title":"CKEditor","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used/CKEditor"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_N4IDkixaDG9C","title":"MindElixir","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used/MindElixir"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_H0mM1lTxF9JI","title":"Excalidraw","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used/Excalidraw"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_MQHyy2dIFgxS","title":"Leaflet","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used/Leaflet"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_m1lbrzyKDaRB","title":"Note ID","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Note ID"},{"name":"iconClass","value":"bx bx-hash","type":"label"}]},{"id":"_help_0vTSyvhPTAOz","title":"Internal API","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_z8O2VG4ZZJD7","title":"API Reference","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"/api/docs"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_2mUhVmZK8RF3","title":"Hidden Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Hidden Notes"},{"name":"iconClass","value":"bx bx-hide","type":"label"}]},{"id":"_help_uYF7pmepw27K","title":"Metrics","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Metrics"},{"name":"iconClass","value":"bx bxs-data","type":"label"}],"children":[{"id":"_help_bOP3TB56fL1V","title":"grafana-dashboard.json","type":"doc","attributes":[{"name":"iconClass","value":"bx bx-file","type":"label"}]}]}]},{"id":"_help_LMAv4Uy3Wk6J","title":"AI","type":"book","attributes":[{"name":"iconClass","value":"bx bx-bot","type":"label"}],"children":[{"id":"_help_GBBMSlVSOIGP","title":"Introduction","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/Introduction"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_WkM7gsEUyCXs","title":"AI Provider Information","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/AI Provider Information"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_7EdTxPADv95W","title":"Ollama","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_vvUCN7FDkq7G","title":"Installing Ollama","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/AI Provider Information/Ollama/Installing Ollama"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_ZavFigBX9AwP","title":"OpenAI","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/AI Provider Information/OpenAI"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_e0lkirXEiSNc","title":"Anthropic","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/AI Provider Information/Anthropic"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]}]},{"id":"_help_CdNpE2pqjmI6","title":"Scripting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting"},{"name":"iconClass","value":"bx bxs-file-js","type":"label"}],"children":[{"id":"_help_yIhgI5H7A2Sm","title":"Frontend Basics","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Frontend Basics"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_es8OU2GuguFU","title":"Examples","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_TjLYAo3JMO8X","title":"\"New Task\" launcher button","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Examples/New Task launcher button"},{"name":"iconClass","value":"bx bx-task","type":"label"}]},{"id":"_help_7kZPMD0uFwkH","title":"Downloading responses from Google Forms","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Examples/Downloading responses from Goo"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_DL92EjAaXT26","title":"Using promoted attributes to configure scripts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Examples/Using promoted attributes to c"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_GPERMystNGTB","title":"Events","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Events"},{"name":"iconClass","value":"bx bx-rss","type":"label"}]},{"id":"_help_MgibgPcfeuGz","title":"Custom Widgets","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Custom Widgets"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_YNxAqkI5Kg1M","title":"Word count widget","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Custom Widgets/Word count widget"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_SynTBQiBsdYJ","title":"Widget Basics","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Custom Widgets/Widget Basics"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_GLks18SNjxmC","title":"Script API","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Script API"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_Q2z6av6JZVWm","title":"Frontend API","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://triliumnext.github.io/Notes/Script%20API/interfaces/Frontend_Script_API.Api.html"},{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_habiZ3HU8Kw8","title":"FNote","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://triliumnext.github.io/Notes/Script%20API/classes/Frontend_Script_API.FNote.html"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_MEtfsqa5VwNi","title":"Backend API","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://triliumnext.github.io/Notes/Script%20API/interfaces/Backend_Script_API.Api.html"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]}]}] \ No newline at end of file +[{"id":"_help_BOCnjTMBCoxW","title":"Feature Highlights","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Feature Highlights"},{"name":"iconClass","value":"bx bx-star","type":"label"}]},{"id":"_help_Otzi9La2YAUX","title":"Installation & Setup","type":"book","attributes":[{"name":"iconClass","value":"bx bx-cog","type":"label"}],"children":[{"id":"_help_poXkQfguuA0U","title":"Desktop Installation","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Desktop Installation"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_WOcw2SLH6tbX","title":"Server Installation","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_Dgg7bR3b6K9j","title":"1. Installing the server","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_3tW6mORuTHnB","title":"Packaged version for Linux","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Packaged version for Linux"},{"name":"iconClass","value":"bx bxl-tux","type":"label"}]},{"id":"_help_rWX5eY045zbE","title":"Using Docker","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Using Docker"},{"name":"iconClass","value":"bx bxl-docker","type":"label"}]},{"id":"_help_moVgBcoxE3EK","title":"On NixOS","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/On NixOS"},{"name":"iconClass","value":"bx bxl-tux","type":"label"}]},{"id":"_help_J1Bb6lVlwU5T","title":"Manually","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Manually"},{"name":"iconClass","value":"bx bx-code-alt","type":"label"}]},{"id":"_help_DCmT6e7clMoP","title":"Using Kubernetes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Using Kubernetes"},{"name":"iconClass","value":"bx bxl-kubernetes","type":"label"}]},{"id":"_help_klCWNks3ReaQ","title":"Multiple server instances","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Multiple server instances"},{"name":"iconClass","value":"bx bxs-user-account","type":"label"}]}]},{"id":"_help_vcjrb3VVYPZI","title":"2. Reverse proxy","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_ud6MShXL4WpO","title":"Nginx","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/2. Reverse proxy/Nginx"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_fDLvzOx29Pfg","title":"Apache","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/2. Reverse proxy/Apache"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_l2VkvOwUNfZj","title":"TLS Configuration","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/TLS Configuration"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_0hzsNCP31IAB","title":"Authentication","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/Authentication"},{"name":"iconClass","value":"bx bx-lock-alt","type":"label"}]},{"id":"_help_7DAiwaf8Z7Rz","title":"Multi-Factor Authentication","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/Multi-Factor Authentication"},{"name":"iconClass","value":"bx bx-stopwatch","type":"label"}]}]},{"id":"_help_cbkrhQjrkKrh","title":"Synchronization","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Synchronization"},{"name":"iconClass","value":"bx bx-sync","type":"label"}]},{"id":"_help_RDslemsQ6gCp","title":"Mobile Frontend","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Mobile Frontend"},{"name":"iconClass","value":"bx bx-mobile-alt","type":"label"}]},{"id":"_help_MtPxeAWVAzMg","title":"Web Clipper","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Web Clipper"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_n1lujUxCwipy","title":"Upgrading TriliumNext","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Upgrading TriliumNext"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_ODY7qQn5m2FT","title":"Backup","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Backup"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_tAassRL4RSQL","title":"Data directory","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Data directory"},{"name":"iconClass","value":"bx bx-folder-open","type":"label"}]}]},{"id":"_help_gh7bpGYxajRS","title":"Basic Concepts and Features","type":"book","attributes":[{"name":"iconClass","value":"bx bx-help-circle","type":"label"}],"children":[{"id":"_help_Vc8PjrjAGuOp","title":"UI Elements","type":"book","attributes":[{"name":"iconClass","value":"bx bx-window-alt","type":"label"}],"children":[{"id":"_help_x0JgW8UqGXvq","title":"Vertical and horizontal layout","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Vertical and horizontal layout"},{"name":"iconClass","value":"bx bxs-layout","type":"label"}]},{"id":"_help_x3i7MxGccDuM","title":"Global menu","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Global menu"},{"name":"iconClass","value":"bx bx-menu","type":"label"}]},{"id":"_help_oPVyFC7WL2Lp","title":"Note Tree","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree"},{"name":"iconClass","value":"bx bxs-tree-alt","type":"label"}],"children":[{"id":"_help_YtSN43OrfzaA","title":"Note tree contextual menu","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Note tree contextual menu"},{"name":"iconClass","value":"bx bx-menu","type":"label"}]},{"id":"_help_yTjUdsOi4CIE","title":"Multiple selection","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Multiple selection"},{"name":"iconClass","value":"bx bx-list-plus","type":"label"}]},{"id":"_help_DvdZhoQZY9Yd","title":"Keyboard shortcuts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Keyboard shortcuts"},{"name":"iconClass","value":"bx bxs-keyboard","type":"label"}]}]},{"id":"_help_BlN9DFI679QC","title":"Ribbon","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Ribbon"},{"name":"iconClass","value":"bx bx-dots-horizontal","type":"label"}]},{"id":"_help_3seOhtN8uLIY","title":"Tabs","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Tabs"},{"name":"iconClass","value":"bx bx-dock-top","type":"label"}]},{"id":"_help_xYmIYSP6wE3F","title":"Launch Bar","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Launch Bar"},{"name":"iconClass","value":"bx bx-sidebar","type":"label"}]},{"id":"_help_8YBEPzcpUgxw","title":"Note buttons","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note buttons"},{"name":"iconClass","value":"bx bx-dots-vertical-rounded","type":"label"}]},{"id":"_help_4TIF1oA4VQRO","title":"Options","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Options"},{"name":"iconClass","value":"bx bx-cog","type":"label"}]},{"id":"_help_luNhaphA37EO","title":"Split View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Split View"},{"name":"iconClass","value":"bx bx-dock-right","type":"label"}]},{"id":"_help_XpOYSgsLkTJy","title":"Floating buttons","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Floating buttons"},{"name":"iconClass","value":"bx bx-rectangle","type":"label"}]},{"id":"_help_RnaPdbciOfeq","title":"Right Sidebar","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Right Sidebar"},{"name":"iconClass","value":"bx bxs-dock-right","type":"label"}]},{"id":"_help_r5JGHN99bVKn","title":"Recent Changes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Recent Changes"},{"name":"iconClass","value":"bx bx-history","type":"label"}]},{"id":"_help_ny318J39E5Z0","title":"Zoom","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Zoom"},{"name":"iconClass","value":"bx bx-zoom-in","type":"label"}]},{"id":"_help_ZjLYv08Rp3qC","title":"Quick edit","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Quick edit"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_lgKX7r3aL30x","title":"Note Tooltip","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tooltip"},{"name":"iconClass","value":"bx bx-message-detail","type":"label"}]}]},{"id":"_help_BFs8mudNFgCS","title":"Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes"},{"name":"iconClass","value":"bx bx-notepad","type":"label"}],"children":[{"id":"_help_p9kXRFAkwN4o","title":"Note Icons","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Note Icons"},{"name":"iconClass","value":"bx bxs-grid","type":"label"}]},{"id":"_help_0vhv7lsOLy82","title":"Attachments","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Attachments"},{"name":"iconClass","value":"bx bx-paperclip","type":"label"}]},{"id":"_help_IakOLONlIfGI","title":"Cloning Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Cloning Notes"},{"name":"iconClass","value":"bx bx-duplicate","type":"label"}],"children":[{"id":"_help_TBwsyfadTA18","title":"Branch prefix","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Cloning Notes/Branch prefix"},{"name":"iconClass","value":"bx bx-rename","type":"label"}]}]},{"id":"_help_bwg0e8ewQMak","title":"Protected Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Protected Notes"},{"name":"iconClass","value":"bx bx-lock-alt","type":"label"}]},{"id":"_help_MKmLg5x6xkor","title":"Archived Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Archived Notes"},{"name":"iconClass","value":"bx bx-box","type":"label"}]},{"id":"_help_vZWERwf8U3nx","title":"Note Revisions","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Note Revisions"},{"name":"iconClass","value":"bx bx-history","type":"label"}]},{"id":"_help_aGlEvb9hyDhS","title":"Sorting Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Sorting Notes"},{"name":"iconClass","value":"bx bx-sort-up","type":"label"}]},{"id":"_help_NRnIZmSMc5sj","title":"Export as PDF","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Export as PDF"},{"name":"iconClass","value":"bx bxs-file-pdf","type":"label"}]},{"id":"_help_CoFPLs3dRlXc","title":"Read-Only Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Read-Only Notes"},{"name":"iconClass","value":"bx bx-edit-alt","type":"label"}]},{"id":"_help_0ESUbbAxVnoK","title":"Note List","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Note List"},{"name":"iconClass","value":"bx bxs-grid","type":"label"}]}]},{"id":"_help_wArbEsdSae6g","title":"Navigation","type":"book","attributes":[{"name":"iconClass","value":"bx bx-navigation","type":"label"}],"children":[{"id":"_help_kBrnXNG3Hplm","title":"Tree Concepts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Tree Concepts"},{"name":"iconClass","value":"bx bx-pyramid","type":"label"}]},{"id":"_help_MMiBEQljMQh2","title":"Note Navigation","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Note Navigation"},{"name":"iconClass","value":"bx bxs-navigation","type":"label"}]},{"id":"_help_Ms1nauBra7gq","title":"Quick search","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Quick search"},{"name":"iconClass","value":"bx bx-search-alt-2","type":"label"}]},{"id":"_help_F1r9QtzQLZqm","title":"Jump to...","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Jump to"},{"name":"iconClass","value":"bx bx-send","type":"label"}]},{"id":"_help_eIg8jdvaoNNd","title":"Search","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Search"},{"name":"iconClass","value":"bx bx-search-alt-2","type":"label"}]},{"id":"_help_u3YFHC9tQlpm","title":"Bookmarks","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Bookmarks"},{"name":"iconClass","value":"bx bx-bookmarks","type":"label"}]},{"id":"_help_OR8WJ7Iz9K4U","title":"Note Hoisting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Note Hoisting"},{"name":"iconClass","value":"bx bxs-chevrons-up","type":"label"}]},{"id":"_help_ZjLYv08Rp3qC","title":"Quick edit","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Quick edit.clone"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_9sRHySam5fXb","title":"Workspaces","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Workspaces"},{"name":"iconClass","value":"bx bx-door-open","type":"label"}]},{"id":"_help_xWtq5NUHOwql","title":"Similar Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Similar Notes"},{"name":"iconClass","value":"bx bx-bar-chart","type":"label"}]},{"id":"_help_McngOG2jbUWX","title":"Search in note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Search in note"},{"name":"iconClass","value":"bx bx-search-alt-2","type":"label"}]}]},{"id":"_help_A9Oc6YKKc65v","title":"Keyboard Shortcuts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Keyboard Shortcuts"},{"name":"iconClass","value":"bx bxs-keyboard","type":"label"}]},{"id":"_help_Wy267RK4M69c","title":"Themes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Themes"},{"name":"iconClass","value":"bx bx-palette","type":"label"}],"children":[{"id":"_help_VbjZvtUek0Ln","title":"Theme Gallery","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Themes/Theme Gallery"},{"name":"iconClass","value":"bx bx-book-reader","type":"label"}]}]},{"id":"_help_mHbBMPDPkVV5","title":"Import & Export","type":"book","attributes":[{"name":"iconClass","value":"bx bx-import","type":"label"}],"children":[{"id":"_help_Oau6X9rCuegd","title":"Markdown","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Import & Export/Markdown"},{"name":"iconClass","value":"bx bxl-markdown","type":"label"}],"children":[{"id":"_help_rJ9grSgoExl9","title":"Supported syntax","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Import & Export/Markdown/Supported syntax"},{"name":"iconClass","value":"bx bx-code-alt","type":"label"}]}]},{"id":"_help_syuSEKf2rUGr","title":"Evernote","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Import & Export/Evernote"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_GnhlmrATVqcH","title":"OneNote","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Import & Export/OneNote"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_rC3pL2aptaRE","title":"Zen mode","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Zen mode"},{"name":"iconClass","value":"bx bxs-yin-yang","type":"label"}]}]},{"id":"_help_s3YCWHBfmYuM","title":"Quick Start","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Quick Start"},{"name":"iconClass","value":"bx bx-run","type":"label"}]},{"id":"_help_i6dbnitykE5D","title":"FAQ","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/FAQ"},{"name":"iconClass","value":"bx bx-question-mark","type":"label"}]},{"id":"_help_KSZ04uQ2D1St","title":"Note Types","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types"},{"name":"iconClass","value":"bx bx-edit","type":"label"}],"children":[{"id":"_help_iPIMuisry3hd","title":"Text","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text"},{"name":"iconClass","value":"bx bx-note","type":"label"}],"children":[{"id":"_help_NwBbFdNZ9h7O","title":"Block quotes & admonitions","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Block quotes & admonitions"},{"name":"iconClass","value":"bx bx-info-circle","type":"label"}]},{"id":"_help_oSuaNgyyKnhu","title":"Bookmarks","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Bookmarks"},{"name":"iconClass","value":"bx bx-bookmark","type":"label"}]},{"id":"_help_veGu4faJErEM","title":"Content language & Right-to-left support","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Content language & Right-to-le"},{"name":"iconClass","value":"bx bx-align-right","type":"label"}]},{"id":"_help_2x0ZAX9ePtzV","title":"Cut to subnote","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Cut to subnote"},{"name":"iconClass","value":"bx bx-cut","type":"label"}]},{"id":"_help_UYuUB1ZekNQU","title":"Developer-specific formatting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Developer-specific formatting"},{"name":"iconClass","value":"bx bx-code-alt","type":"label"}],"children":[{"id":"_help_QxEyIjRBizuC","title":"Code blocks","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Developer-specific formatting/Code blocks"},{"name":"iconClass","value":"bx bx-code","type":"label"}]}]},{"id":"_help_AgjCISero73a","title":"Footnotes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Footnotes"},{"name":"iconClass","value":"bx bx-bracket","type":"label"}]},{"id":"_help_nRhnJkTT8cPs","title":"Formatting toolbar","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Formatting toolbar"},{"name":"iconClass","value":"bx bx-text","type":"label"}]},{"id":"_help_Gr6xFaF6ioJ5","title":"General formatting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/General formatting"},{"name":"iconClass","value":"bx bx-bold","type":"label"}]},{"id":"_help_AxshuNRegLAv","title":"Highlights list","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Highlights list"},{"name":"iconClass","value":"bx bx-highlight","type":"label"}]},{"id":"_help_mT0HEkOsz6i1","title":"Images","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Images"},{"name":"iconClass","value":"bx bx-image-alt","type":"label"}],"children":[{"id":"_help_0Ofbk1aSuVRu","title":"Image references","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Images/Image references"},{"name":"iconClass","value":"bx bxs-file-image","type":"label"}]}]},{"id":"_help_nBAXQFj20hS1","title":"Include Note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Include Note"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_CohkqWQC1iBv","title":"Insert buttons","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Insert buttons"},{"name":"iconClass","value":"bx bx-plus","type":"label"}]},{"id":"_help_oiVPnW8QfnvS","title":"Keyboard shortcuts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Keyboard shortcuts"},{"name":"iconClass","value":"bx bxs-keyboard","type":"label"}]},{"id":"_help_QEAPj01N5f7w","title":"Links","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Links"},{"name":"iconClass","value":"bx bx-link-alt","type":"label"}],"children":[{"id":"_help_3IDVtesTQ8ds","title":"External links","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Links/External links"},{"name":"iconClass","value":"bx bx-link-external","type":"label"}]},{"id":"_help_hrZ1D00cLbal","title":"Internal (reference) links","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Links/Internal (reference) links"},{"name":"iconClass","value":"bx bx-link","type":"label"}]}]},{"id":"_help_S6Xx8QIWTV66","title":"Lists","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Lists"},{"name":"iconClass","value":"bx bx-list-ul","type":"label"}]},{"id":"_help_QrtTYPmdd1qq","title":"Markdown-like formatting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Markdown-like formatting"},{"name":"iconClass","value":"bx bxl-markdown","type":"label"}]},{"id":"_help_YfYAtQBcfo5V","title":"Math Equations","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Math Equations"},{"name":"iconClass","value":"bx bx-math","type":"label"}]},{"id":"_help_dEHYtoWWi8ct","title":"Other features","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Other features"},{"name":"iconClass","value":"bx bxs-grid","type":"label"}]},{"id":"_help_gLt3vA97tMcp","title":"Premium features","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Premium features"},{"name":"iconClass","value":"bx bx-star","type":"label"}],"children":[{"id":"_help_ZlN4nump6EbW","title":"Slash Commands","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Premium features/Slash Commands"},{"name":"iconClass","value":"bx bx-menu","type":"label"}]},{"id":"_help_pwc194wlRzcH","title":"Text Snippets","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Premium features/Text Snippets"},{"name":"iconClass","value":"bx bx-align-left","type":"label"}]}]},{"id":"_help_BFvAtE74rbP6","title":"Table of contents","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Table of contents"},{"name":"iconClass","value":"bx bx-heading","type":"label"}]},{"id":"_help_NdowYOC1GFKS","title":"Tables","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Tables"},{"name":"iconClass","value":"bx bx-table","type":"label"}]}]},{"id":"_help_6f9hih2hXXZk","title":"Code","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Code"},{"name":"iconClass","value":"bx bx-code","type":"label"}]},{"id":"_help_m523cpzocqaD","title":"Saved Search","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Saved Search"},{"name":"iconClass","value":"bx bx-file-find","type":"label"}]},{"id":"_help_iRwzGnHPzonm","title":"Relation Map","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Relation Map"},{"name":"iconClass","value":"bx bxs-network-chart","type":"label"}]},{"id":"_help_bdUJEHsAPYQR","title":"Note Map","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Note Map"},{"name":"iconClass","value":"bx bxs-network-chart","type":"label"}]},{"id":"_help_HcABDtFCkbFN","title":"Render Note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Render Note"},{"name":"iconClass","value":"bx bx-extension","type":"label"}]},{"id":"_help_GTwFsgaA0lCt","title":"Collections","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Collections"},{"name":"iconClass","value":"bx bx-book","type":"label"}],"children":[{"id":"_help_xWbu3jpNWapp","title":"Calendar View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Collections/Calendar View"},{"name":"iconClass","value":"bx bx-calendar","type":"label"}]},{"id":"_help_81SGnPGMk7Xc","title":"Geo Map View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Collections/Geo Map View"},{"name":"iconClass","value":"bx bx-map-alt","type":"label"}]},{"id":"_help_8QqnMzx393bx","title":"Grid View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Collections/Grid View"},{"name":"iconClass","value":"bx bxs-grid","type":"label"}]},{"id":"_help_mULW0Q3VojwY","title":"List View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Collections/List View"},{"name":"iconClass","value":"bx bx-list-ul","type":"label"}]},{"id":"_help_2FvYrpmOXm29","title":"Table View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Collections/Table View"},{"name":"iconClass","value":"bx bx-table","type":"label"}]},{"id":"_help_CtBQqbwXDx1w","title":"Board View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Collections/Board View"},{"name":"iconClass","value":"bx bx-columns","type":"label"}]}]},{"id":"_help_s1aBHPd79XYj","title":"Mermaid Diagrams","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Mermaid Diagrams"},{"name":"iconClass","value":"bx bx-selection","type":"label"}],"children":[{"id":"_help_RH6yLjjWJHof","title":"ELK layout","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Mermaid Diagrams/ELK layout"},{"name":"iconClass","value":"bx bxs-network-chart","type":"label"}]}]},{"id":"_help_grjYqerjn243","title":"Canvas","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Canvas"},{"name":"iconClass","value":"bx bx-pen","type":"label"}]},{"id":"_help_1vHRoWCEjj0L","title":"Web View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Web View"},{"name":"iconClass","value":"bx bx-globe-alt","type":"label"}]},{"id":"_help_gBbsAeiuUxI5","title":"Mind Map","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Mind Map"},{"name":"iconClass","value":"bx bx-sitemap","type":"label"}]},{"id":"_help_W8vYD3Q1zjCR","title":"File","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/File"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_BgmBlOIl72jZ","title":"Troubleshooting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting"},{"name":"iconClass","value":"bx bx-bug","type":"label"}],"children":[{"id":"_help_wy8So3yZZlH9","title":"Reporting issues","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Reporting issues"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_x59R8J8KV5Bp","title":"Anonymized Database","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Anonymized Database"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_qzNzp9LYQyPT","title":"Error logs","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Error logs"},{"name":"iconClass","value":"bx bx-comment-error","type":"label"}],"children":[{"id":"_help_bnyigUA2UK7s","title":"Backend (server) logs","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Error logs/Backend (server) logs"},{"name":"iconClass","value":"bx bx-server","type":"label"}]},{"id":"_help_9yEHzMyFirZR","title":"Frontend logs","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Error logs/Frontend logs"},{"name":"iconClass","value":"bx bx-window-alt","type":"label"}]}]},{"id":"_help_vdlYGAcpXAgc","title":"Synchronization fails with 504 Gateway Timeout","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Synchronization fails with 504"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_s8alTXmpFR61","title":"Refreshing the application","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Refreshing the application"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_pKK96zzmvBGf","title":"Theme development","type":"book","attributes":[{"name":"iconClass","value":"bx bx-palette","type":"label"}],"children":[{"id":"_help_7NfNr5pZpVKV","title":"Creating a custom theme","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Theme development/Creating a custom theme"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_WFGzWeUK6arS","title":"Customize the Next theme","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Theme development/Customize the Next theme"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_WN5z4M8ASACJ","title":"Reference","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Theme development/Reference"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_AlhDUqhENtH7","title":"Custom app-wide CSS","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Theme development/Custom app-wide CSS"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_tC7s2alapj8V","title":"Advanced Usage","type":"book","attributes":[{"name":"iconClass","value":"bx bx-rocket","type":"label"}],"children":[{"id":"_help_zEY4DaJG4YT5","title":"Attributes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes"},{"name":"iconClass","value":"bx bx-list-check","type":"label"}],"children":[{"id":"_help_HI6GBBIduIgv","title":"Labels","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes/Labels"},{"name":"iconClass","value":"bx bx-hash","type":"label"}]},{"id":"_help_Cq5X6iKQop6R","title":"Relations","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes/Relations"},{"name":"iconClass","value":"bx bx-transfer","type":"label"}]},{"id":"_help_bwZpz2ajCEwO","title":"Attribute Inheritance","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes/Attribute Inheritance"},{"name":"iconClass","value":"bx bx-list-plus","type":"label"}]},{"id":"_help_OFXdgB2nNk1F","title":"Promoted Attributes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes/Promoted Attributes"},{"name":"iconClass","value":"bx bx-table","type":"label"}]}]},{"id":"_help_KC1HB96bqqHX","title":"Templates","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Templates"},{"name":"iconClass","value":"bx bx-copy","type":"label"}]},{"id":"_help_BCkXAVs63Ttv","title":"Note Map (Link map, Tree map)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Note Map (Link map, Tree map)"},{"name":"iconClass","value":"bx bxs-network-chart","type":"label"}]},{"id":"_help_R9pX4DGra2Vt","title":"Sharing","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Sharing"},{"name":"iconClass","value":"bx bx-share-alt","type":"label"}],"children":[{"id":"_help_Qjt68inQ2bRj","title":"Serving directly the content of a note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Sharing/Serving directly the content o"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_5668rwcirq1t","title":"Advanced Showcases","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Advanced Showcases"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_l0tKav7yLHGF","title":"Day Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Advanced Showcases/Day Notes"},{"name":"iconClass","value":"bx bx-calendar","type":"label"}]},{"id":"_help_R7abl2fc6Mxi","title":"Weight Tracker","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Advanced Showcases/Weight Tracker"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_xYjQUYhpbUEW","title":"Task Manager","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Advanced Showcases/Task Manager"},{"name":"iconClass","value":"bx bx-calendar-check","type":"label"}]}]},{"id":"_help_J5Ex1ZrMbyJ6","title":"Custom Request Handler","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Custom Request Handler"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_d3fAXQ2diepH","title":"Custom Resource Providers","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Custom Resource Providers"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_pgxEVkzLl1OP","title":"ETAPI (REST API)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/ETAPI (REST API)"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_9qPsTWBorUhQ","title":"API Reference","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"/etapi/docs"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_47ZrP6FNuoG8","title":"Default Note Title","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Default Note Title"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_wX4HbRucYSDD","title":"Database","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Database"},{"name":"iconClass","value":"bx bx-data","type":"label"}],"children":[{"id":"_help_oyIAJ9PvvwHX","title":"Manually altering the database","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Database/Manually altering the database"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_YKWqdJhzi2VY","title":"SQL Console","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Database/Manually altering the database/SQL Console"},{"name":"iconClass","value":"bx bx-data","type":"label"}]}]},{"id":"_help_6tZeKvSHEUiB","title":"Demo Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Database/Demo Notes"},{"name":"iconClass","value":"bx bx-package","type":"label"}]}]},{"id":"_help_Gzjqa934BdH4","title":"Configuration (config.ini or environment variables)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Configuration (config.ini or e"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_c5xB8m4g2IY6","title":"Trilium instance","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Configuration (config.ini or environment variables)/Trilium instance"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_LWtBjFej3wX3","title":"Cross-Origin Resource Sharing (CORS)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Configuration (config.ini or environment variables)/Cross-Origin Resource Sharing "},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_ivYnonVFBxbQ","title":"Bulk Actions","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Bulk Actions"},{"name":"iconClass","value":"bx bx-list-plus","type":"label"}]},{"id":"_help_4FahAwuGTAwC","title":"Note source","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Note source"},{"name":"iconClass","value":"bx bx-code","type":"label"}]},{"id":"_help_1YeN2MzFUluU","title":"Technologies used","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used"},{"name":"iconClass","value":"bx bxs-component","type":"label"}],"children":[{"id":"_help_MI26XDLSAlCD","title":"CKEditor","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used/CKEditor"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_N4IDkixaDG9C","title":"MindElixir","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used/MindElixir"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_H0mM1lTxF9JI","title":"Excalidraw","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used/Excalidraw"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_MQHyy2dIFgxS","title":"Leaflet","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used/Leaflet"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_m1lbrzyKDaRB","title":"Note ID","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Note ID"},{"name":"iconClass","value":"bx bx-hash","type":"label"}]},{"id":"_help_0vTSyvhPTAOz","title":"Internal API","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_z8O2VG4ZZJD7","title":"API Reference","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"/api/docs"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_2mUhVmZK8RF3","title":"Hidden Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Hidden Notes"},{"name":"iconClass","value":"bx bx-hide","type":"label"}]},{"id":"_help_uYF7pmepw27K","title":"Metrics","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Metrics"},{"name":"iconClass","value":"bx bxs-data","type":"label"}],"children":[{"id":"_help_bOP3TB56fL1V","title":"grafana-dashboard.json","type":"doc","attributes":[{"name":"iconClass","value":"bx bx-file","type":"label"}]}]}]},{"id":"_help_LMAv4Uy3Wk6J","title":"AI","type":"book","attributes":[{"name":"iconClass","value":"bx bx-bot","type":"label"}],"children":[{"id":"_help_GBBMSlVSOIGP","title":"Introduction","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/Introduction"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_WkM7gsEUyCXs","title":"AI Provider Information","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/AI Provider Information"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_7EdTxPADv95W","title":"Ollama","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_vvUCN7FDkq7G","title":"Installing Ollama","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/AI Provider Information/Ollama/Installing Ollama"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_ZavFigBX9AwP","title":"OpenAI","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/AI Provider Information/OpenAI"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_e0lkirXEiSNc","title":"Anthropic","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/AI Provider Information/Anthropic"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]}]},{"id":"_help_CdNpE2pqjmI6","title":"Scripting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting"},{"name":"iconClass","value":"bx bxs-file-js","type":"label"}],"children":[{"id":"_help_yIhgI5H7A2Sm","title":"Frontend Basics","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Frontend Basics"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_es8OU2GuguFU","title":"Examples","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_TjLYAo3JMO8X","title":"\"New Task\" launcher button","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Examples/New Task launcher button"},{"name":"iconClass","value":"bx bx-task","type":"label"}]},{"id":"_help_7kZPMD0uFwkH","title":"Downloading responses from Google Forms","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Examples/Downloading responses from Goo"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_DL92EjAaXT26","title":"Using promoted attributes to configure scripts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Examples/Using promoted attributes to c"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_GPERMystNGTB","title":"Events","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Events"},{"name":"iconClass","value":"bx bx-rss","type":"label"}]},{"id":"_help_MgibgPcfeuGz","title":"Custom Widgets","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Custom Widgets"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_YNxAqkI5Kg1M","title":"Word count widget","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Custom Widgets/Word count widget"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_SynTBQiBsdYJ","title":"Widget Basics","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Custom Widgets/Widget Basics"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_GLks18SNjxmC","title":"Script API","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Script API"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_Q2z6av6JZVWm","title":"Frontend API","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://triliumnext.github.io/Notes/Script%20API/interfaces/Frontend_Script_API.Api.html"},{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_habiZ3HU8Kw8","title":"FNote","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://triliumnext.github.io/Notes/Script%20API/classes/Frontend_Script_API.FNote.html"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_MEtfsqa5VwNi","title":"Backend API","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://triliumnext.github.io/Notes/Script%20API/interfaces/Backend_Script_API.Api.html"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]}]}] \ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Navigation/Quick edit.clone.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Navigation/Quick edit.clone.html new file mode 100644 index 0000000000..f69c275060 --- /dev/null +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Navigation/Quick edit.clone.html @@ -0,0 +1 @@ +

This is a clone of a note. Go to its primary location.

\ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Note tree contextual menu.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Note tree contextual menu.html index c8dc3a86b2..0488c7d36e 100644 --- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Note tree contextual menu.html +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Note tree contextual menu.html @@ -8,12 +8,12 @@

Interaction

The contextual menu can operate:

    -
  • On a single note, by right clicking it in the note tree.
  • -
  • On multiple notes, by selecting them first. See On a single note, by right clicking it in the note tree.
  • +
  • On multiple notes, by selecting them first. See Multiple selection on how to do so.
      -
    • When right clicking, do note that usually the note being right clicked +
    • When right clicking, do note that usually the note being right clicked is also included in the affected notes, regardless of whether it was selected or not.
    @@ -25,133 +25,146 @@ The ones that do support multiple notes will mention this in the list below.

      -
    • Open in a new tab +
    • Open in a new tab
        -
      • Will open a single note in a new tab.
      • +
      • Will open a single note in a new tab.
    • -
    • Open in a new split +
    • Open in a new split
        -
      • Will open a split to the right with the given note within the current +
      • Will open a split to the right with the given note within the current tab.
    • -
    • Hoist note +
    • Hoist note
    • -
    • Insert note after +
    • Insert note after
        -
      • Allows easy creation of a note with a specified note type.
      • -
      • Templates will +
      • Allows easy creation of a note with a specified note type.
      • +
      • Templates will also be present (if any) at the end of the list.
      • -
      • The note will be added on the same level of hierarchy as the note selected.
      • -
      +
    • The note will be added on the same level of hierarchy as the note selected.
    • +
  • -
  • Insert child note +
  • Insert child note
      -
    • Same as Insert note after, but the note will be created as a child +
    • Same as Insert note after, but the note will be created as a child of the selected note.
  • -
  • Protect subtree +
  • Protect subtree
      -
    • Will mark this note and all of its descendents as protected. See  +
    • Will mark this note and all of its descendents as protected. See  Protected Notes for more information.
  • -
  • Unprotect subtree +
  • Unprotect subtree
      -
    • Will unprotect this note and all of its descendents.
    • +
    • Will unprotect this note and all of its descendents.
  • -
  • Cut +
  • Cut
      -
    • Will place the given notes in clipboard.
    • -
    • Use one of the two paste functions (or the keyboard shortcuts) to move +
    • Will place the given notes in clipboard.
    • +
    • Use one of the two paste functions (or the keyboard shortcuts) to move them to the desired location.
  • -
  • Copy / clone +
  • Copy / clone
      -
    • Will place the given notes in clipboard.
    • -
    • Use one of the two paste functions (or the keyboard shortcuts) to copy +
    • Will place the given notes in clipboard.
    • +
    • Use one of the two paste functions (or the keyboard shortcuts) to copy them to the desired location.
    • -
    • Note that the copy function here works according to the Note that the copy function here works according to the Cloning Notes functionality (i.e. the note itself will be present in two locations at once, and editing it in one place will edit it everywhere).
    • -
    • To simply create a duplicate note that can be modified independently, +
    • To simply create a duplicate note that can be modified independently, look for Duplicate subtree.
  • -
  • Paste into +
  • Paste into
      -
    • If there are any notes in clipboard, they will be pasted as child notes +
    • If there are any notes in clipboard, they will be pasted as child notes to the right-clicked one.
  • -
  • Paste after +
  • Paste after
      -
    • If there are any notes in clipboard, they will be pasted underneath the +
    • If there are any notes in clipboard, they will be pasted underneath the right-clicked one.
  • -
  • Move to… +
  • Move to…
      -
    • Will display a modal to specify where to move the desired notes.
    • +
    • Will display a modal to specify where to move the desired notes.
  • -
  • Clone to… +
  • Clone to…
      -
    • Will display a modal to specify where to clone the +
    • Will display a modal to specify where to clone the desired notes.
  • -
  • Duplicate +
  • Duplicate +
-
  • Delete +
  • Archive/Unarchive
      -
    • Will delete the given notes, asking for confirmation first.
    • -
    • In the dialog, the following options can be configured: -
        -
      • Delete also all clones to ensure that the note will be deleted - everywhere if it has been placed into multiple locations (see Cloning Notes).
      • -
      • Erase notes permanently will ensure that the note cannot be recovered - from Recent Changes.
      • -
      -
    • +
    • Marks a note as archived.
    • +
    • If the note is already archived, it will be unarchived instead.
    • +
    • Multiple notes can be selected as well. However, all the selected notes + must be in the same state (archived or not), otherwise the option will + be disabled.
    -
  • -
  • Import into note -
      -
    • Opens the import dialog and places - the imported notes as child notes of the selected one.
    • -
    -
  • -
  • Export -
      -
    • Opens the export dialog for the selected - notes.
    • -
    -
  • -
  • Search in subtree -
      -
    • Opens a full Search with - it preconfigured to only look into this note and its descendants (the Ancestor field).
    • -
    -
  • + +
  • Delete +
      +
    • Will delete the given notes, asking for confirmation first.
    • +
    • In the dialog, the following options can be configured: +
        +
      • Delete also all clones to ensure that the note will be deleted + everywhere if it has been placed into multiple locations (see Cloning Notes).
      • +
      • Erase notes permanently will ensure that the note cannot be recovered + from Recent Changes.
      • +
      +
    • +
    +
  • +
  • Import into note +
      +
    • Opens the import dialog and places + the imported notes as child notes of the selected one.
    • +
    +
  • +
  • Export +
      +
    • Opens the export dialog for the selected + notes.
    • +
    +
  • +
  • Search in subtree +
      +
    • Opens a full Search with + it preconfigured to only look into this note and its descendants (the Ancestor field).
    • +
    +
  • Advanced options

    @@ -163,60 +176,63 @@

    To access these options, first look for the Advanced option in the contextual menu to reveal a sub-menu with:

      -
    • Apply bulk actions +
    • Apply bulk actions
    • -
    • Edit branch prefix +
    • Edit branch prefix
        -
      • Opens a dialog to assign a name to be able to distinguish clones, +
      • Opens a dialog to assign a name to be able to distinguish clones, see Branch prefix for more information.
    • -
    • Convert to attachment +
    • Convert to attachment
    • -
    • Expand subtree +
    • Expand subtree
        -
      • Expands all the child notes in the Note Tree.
      • +
      • Expands all the child notes in the Note Tree.
    • -
    • Collapse subtree +
    • Collapse subtree
        -
      • Collapses all the child notes in the note tree.
      • +
      • Collapses all the child notes in the note tree.
    • -
    • Sort by… +
    • Sort by…
        -
      • Opens a dialog to sort all the child notes of the selected note.
      • -
      • The sorting is done only once, there is an automatic sorting mechanism +
      • Opens a dialog to sort all the child notes of the selected note.
      • +
      • The sorting is done only once, there is an automatic sorting mechanism as well that can be set using Attributes.
      • -
      • See Sorting Notes for - more information.
      • +
      • See Sorting Notes for + more information.
      -
    • -
    • Copy note path to clipboard -
        -
      • Copies a URL fragment representing the full path to this branch for a - note, such as #root/Hb2E70L7HPuf/4sRFgMZhYFts/2IVuShedRJ3U/LJVMvKXOFv7n.
      • -
      • The URL to manually create Links within - notes, or for note Navigation.
      • + +
      • Copy note path to clipboard +
          +
        • Copies a URL fragment representing the full path to this branch for a + note, such as #root/Hb2E70L7HPuf/4sRFgMZhYFts/2IVuShedRJ3U/LJVMvKXOFv7n.
        • +
        • The URL to manually create Links within + notes, or for note Navigation.
        -
      • -
      • Recent changes in subtree -
          -
        • This will open Recent Changes, - but filtered to only the changes related to this note or one of its descendants.
        • -
        -
      • + +
      • Recent changes in subtree +
          +
        • This will open Recent Changes, + but filtered to only the changes related to this note or one of its descendants.
        • +
        +
      \ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Navigation/Quick edit.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/UI Elements/Quick edit.html similarity index 100% rename from apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Navigation/Quick edit.html rename to apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/UI Elements/Quick edit.html diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Navigation/Quick edit_image.png b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/UI Elements/Quick edit_image.png similarity index 100% rename from apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Navigation/Quick edit_image.png rename to apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/UI Elements/Quick edit_image.png diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Collections.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Collections.html index 7b255c8803..8ec44b5add 100644 --- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Collections.html +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Collections.html @@ -4,29 +4,31 @@ child notes into one continuous view. This makes it ideal for reading extensive information broken into smaller, manageable segments.

        -
      • Grid View which +
      • Grid View which is the default presentation method for child notes (see Note List), where the notes are displayed as tiles with their title and content being visible.
      • -
      • List View is +
      • List View is similar to Grid View, but it displays the notes one under the other with the content being expandable/collapsible, but also works recursively.

      More specialized collections were introduced, such as the:

        -
      • Calendar View which +
      • Calendar View which displays a week, month or year calendar with the notes being shown as events. New events can be added easily by dragging across the calendar.
      • -
      • Geo Map View which +
      • Geo Map View which displays a geographical map in which the notes are represented as markers/pins on the map. New events can be easily added by pointing on the map.
      • -
      • Table View displays - each note as a row in a table, with Promoted Attributes being - shown as well. This makes it easy to visualize attributes of notes, as - well as making them easily editable.
      • -
      • Board View (Kanban) - displays notes in columns, grouped by the value of a label.
      • +
      • Table View displays + each note as a row in a table, with Promoted Attributes being + shown as well. This makes it easy to visualize attributes of notes, as + well as making them easily editable.
      • +
      • Board View (Kanban) + displays notes in columns, grouped by the value of a label.

      For a quick presentation of all the supported view types, see the child notes of this help page, including screenshots.

      @@ -42,8 +44,8 @@

      Adding a description to a collection

      To add a text before the collection, for example to describe it:

        -
      1. Create a new collection.
      2. -
      3. In the Ribbon, +
      4. Create a new collection.
      5. +
      6. In the Ribbon, go to Basic Properties and change the note type from Collection to Text.

      Now the text will be displayed above while still maintaining the collection @@ -58,15 +60,22 @@

      By default, collections come with a default configuration and sometimes even sample notes. To create a collection completely from scratch:

        -
      1. Create a new note of type Text (or any type).
      2. -
      3. In the Ribbon, +
      4. Create a new note of type Text (or any type).
      5. +
      6. In the Ribbon, go to Basic Properties and select Collection as the note type.
      7. -
      8. Still in the ribbon, go to Collection Properties and select the +
      9. Still in the ribbon, go to Collection Properties and select the desired view type.
      10. -
      11. Consult the help page of the corresponding view type in order to understand +
      12. Consult the help page of the corresponding view type in order to understand how to configure them.
      +

      Archived notes

      +

      By default, archived notes will not be shown in collections. This behaviour + can be changed by going to Collection Properties in the  + Ribbon and checking Show archived notes.

      +

      Archived notes will be generally indicated by being greyed out as opposed + to the normal ones.

      Under the hood

      Collections by themselves are simply notes with no content that rely on the Note List mechanism diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Collections/Board View.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Collections/Board View.html index 3c13a81d8a..6dc4e10293 100644 --- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Collections/Board View.html +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Collections/Board View.html @@ -15,53 +15,60 @@ in a hierarchy.

      Interaction with columns

        -
      • Create a new column by pressing Add Column near the last column. +
      • Create a new column by pressing Add Column near the last column.
          -
        • Once pressed, a text box will be displayed to set the name of the column. - Press Enter to confirm.
        • +
        • Once pressed, a text box will be displayed to set the name of the column. + Press Enter to confirm, or Escape to dismiss.
      • -
      • To reorder a column, simply hold the mouse over the title and drag it +
      • To reorder a column, simply hold the mouse over the title and drag it to the desired position.
      • -
      • To delete a column, right click on its title and select Delete column.
      • -
      • To rename a column, click on the note title. +
      • To delete a column, right click on its title and select Delete column.
      • +
      • To rename a column, click on the note title.
          -
        • Press Enter to confirm.
        • -
        • Upon renaming a column, the corresponding status attribute of all its +
        • Press Enter to confirm.
        • +
        • Upon renaming a column, the corresponding status attribute of all its notes will be changed in bulk.
        -
      • -
      • If there are many columns, use the mouse wheel to scroll.
      • + +
      • If there are many columns, use the mouse wheel to scroll.

      Interaction with notes

        -
      • Create a new note in any column by pressing New item +
      • Create a new note in any column by pressing New item
          -
        • Enter the name of the note and press Enter.
        • -
        • Doing so will create a new note. The new note will have an attribute (status label +
        • Enter the name of the note and press Enter or click away. To + dismiss the creation of a new note, simply press Escape or leave + the name empty.
        • +
        • Once created, the new note will have an attribute (status label by default) set to the name of the column.
      • -
      • To change the state of a note, simply drag a note from one column to the +
      • To open the note, simply click on it.
      • +
      • To change the title of the note directly from the board, hover the mouse + over its card and press the edit button on the right.
      • +
      • To change the state of a note, simply drag a note from one column to the other to change its state.
      • -
      • The order of the notes in each column corresponds to their position in +
      • The order of the notes in each column corresponds to their position in the tree.
          -
        • It's possible to reorder notes simply by dragging them to the desired +
        • It's possible to reorder notes simply by dragging them to the desired position within the same columns.
        • -
        • It's also possible to drag notes across columns, at the desired position.
        • +
        • It's also possible to drag notes across columns, at the desired position.
      • -
      • For more options, right click on a note to display a context menu with +
      • For more options, right click on a note to display a context menu with the following options:
          -
        • Open the note in a new tab/split/window or quick edit.
        • -
        • Move the note to any column.
        • -
        • Insert a new note above/below the current one.
        • -
        • Delete the current note.
        • +
        • Open the note in a new tab/split/window or quick edit.
        • +
        • Move the note to any column.
        • +
        • Insert a new note above/below the current one.
        • +
        • Archive/unarchive the current note.
        • +
        • Delete the current note.
      • -
      • If there are many notes within the column, move the mouse over the column +
      • If there are many notes within the column, move the mouse over the column and use the mouse wheel to scroll.

      Configuration

      @@ -77,5 +84,5 @@ class="admonition note">

      Interaction

      Limitations

        -
      • It is not possible yet to use group by a relation, only by label.
      • +
      • It is not possible yet to use group by a relation, only by label.
      \ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Collections/Geo Map View.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Collections/Geo Map View.html index cbda5b9773..9e0d2cf9c1 100644 --- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Collections/Geo Map View.html +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Collections/Geo Map View.html @@ -12,128 +12,138 @@ on an attribute. It is also possible to add new notes at a specific location using the built-in interface.

      Creating a new geo map

      - - - - - - - - - - - - - - - - - - - - -
      1 -
      - -
      -
      Right click on any note on the note tree and select Insert child noteGeo Map (beta).
      2 -
      - -
      -
      By default the map will be empty and will show the entire world.
      - +
      + + + + + + + + + + + + + + + + + + + + +
         
      1 +
      + +
      +
      Right click on any note on the note tree and select Insert child noteGeo Map (beta).
      2 +
      + +
      +
      By default the map will be empty and will show the entire world.
      +

      Repositioning the map

        -
      • Click and drag the map in order to move across the map.
      • -
      • Use the mouse wheel, two-finger gesture on a touchpad or the +/- buttons +
      • Click and drag the map in order to move across the map.
      • +
      • Use the mouse wheel, two-finger gesture on a touchpad or the +/- buttons on the top-left to adjust the zoom.

      The position on the map and the zoom are saved inside the map note and restored when visiting again the note.

      Adding a marker using the map

      Adding a new note using the plus button

      - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
      1To create a marker, first navigate to the desired point on the map. Then - press the - button in the Floating buttons (top-right) - area.   -
      -
      If the button is not visible, make sure the button section is visible - by pressing the chevron button ( - ) in the top-right of the map.
      2 - - Once pressed, the map will enter in the insert mode, as illustrated by - the notification.      -
      -
      Simply click the point on the map where to place the marker, or the Escape - key to cancel.
      3 - - Enter the name of the marker/note to be created.
      4 - - Once confirmed, the marker will show up on the map and it will also be - displayed as a child note of the map.
      - +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
         
      1To create a marker, first navigate to the desired point on the map. Then + press the + button in the Floating buttons (top-right) + area.    +
      +
      If the button is not visible, make sure the button section is visible + by pressing the chevron button ( + ) in the top-right of the map.
       
      2 + + Once pressed, the map will enter in the insert mode, as illustrated by + the notification.       +
      +
      Simply click the point on the map where to place the marker, or the Escape + key to cancel.
      3 + + Enter the name of the marker/note to be created.
      4 + + Once confirmed, the marker will show up on the map and it will also be + displayed as a child note of the map.
      +

      Adding a new note using the contextual menu

        -
      1. Right click anywhere on the map, where to place the newly created marker +
      2. Right click anywhere on the map, where to place the newly created marker (and corresponding note).
      3. -
      4. Select Add a marker at this location.
      5. -
      6. Enter the name of the newly created note.
      7. -
      8. The map should be updated with the new marker.
      9. +
      10. Select Add a marker at this location.
      11. +
      12. Enter the name of the newly created note.
      13. +
      14. The map should be updated with the new marker.

      Adding an existing note on note from the note tree

        -
      1. Select the desired note in the Note Tree.
      2. -
      3. Hold the mouse on the note and drag it to the map to the desired location.
      4. -
      5. The map should be updated with the new marker.
      6. +
      7. Select the desired note in the Note Tree.
      8. +
      9. Hold the mouse on the note and drag it to the map to the desired location.
      10. +
      11. The map should be updated with the new marker.

      This works for:

        -
      • Notes that are not part of the geo map, case in which a clone will +
      • Notes that are not part of the geo map, case in which a clone will be created.
      • -
      • Notes that are a child of the geo map but not yet positioned on the map.
      • -
      • Notes that are a child of the geo map and also positioned, case in which +
      • Notes that are a child of the geo map but not yet positioned on the map.
      • +
      • Notes that are a child of the geo map and also positioned, case in which the marker will be relocated to the new position.
      +

      How the location of the markers is stored

      The location of a marker is stored in the #geolocation attribute of the child notes:

      - +

      + +

      This value can be added manually if needed. The value of the attribute is made up of the latitude and longitude separated by a comma.

      Repositioning markers

      @@ -145,16 +155,17 @@ height="278"> page (Ctrl+R ) to cancel it.

      Interaction with the markers

        -
      • Hovering over a marker will display a Note Tooltip with +
      • Hovering over a marker will display a Note Tooltip with the content of the note it belongs to.
          -
        • Clicking on the note title in the tooltip will navigate to the note in +
        • Clicking on the note title in the tooltip will navigate to the note in the current view.
      • -
      • Middle-clicking the marker will open the note in a new tab.
      • -
      • Right-clicking the marker will open a contextual menu (as described below).
      • -
      • If the map is in read-only mode, clicking on a marker will open a  +
      • Middle-clicking the marker will open the note in a new tab.
      • +
      • Right-clicking the marker will open a contextual menu (as described below).
      • +
      • If the map is in read-only mode, clicking on a marker will open a  Quick edit popup for the corresponding note.
      @@ -162,24 +173,24 @@ height="278">

      It's possible to press the right mouse button to display a contextual menu.

        -
      1. If right-clicking an empty section of the map (not on a marker), it allows +
      2. If right-clicking an empty section of the map (not on a marker), it allows to:
          -
        1. Displays the latitude and longitude. Clicking this option will copy them +
        2. Displays the latitude and longitude. Clicking this option will copy them to the clipboard.
        3. -
        4. Open the location using an external application (if the operating system +
        5. Open the location using an external application (if the operating system supports it).
        6. -
        7. Adding a new marker at that location.
        8. +
        9. Adding a new marker at that location.
      3. -
      4. If right-clicking on a marker, it allows to: +
      5. If right-clicking on a marker, it allows to:
          -
        1. Displays the latitude and longitude. Clicking this option will copy them +
        2. Displays the latitude and longitude. Clicking this option will copy them to the clipboard.
        3. -
        4. Open the location using an external application (if the operating system +
        5. Open the location using an external application (if the operating system supports it).
        6. -
        7. Open the note in a new tab, split or window.
        8. -
        9. Remove the marker from the map, which will remove the #geolocation attribute +
        10. Open the note in a new tab, split or window.
        11. +
        12. Remove the marker from the map, which will remove the #geolocation attribute of the note. To add it back again, the coordinates have to be manually added back in.
        @@ -199,209 +210,215 @@ height="278">

        The value of the attribute is made up of the latitude and longitude separated by a comma.

        Adding from Google Maps

        - - - - - - - - - - - - - - - - - - - - - - - - - -
        1 -
        - -
        -
        Go to Google Maps on the web and look for a desired location, right click - on it and a context menu will show up.      -
        -
        Simply click on the first item displaying the coordinates and they will - be copied to clipboard.      -
        -
        Then paste the value inside the text box into the #geolocation attribute - of a child note of the map (don't forget to surround the value with a " character).
        2 -
        - -
        -
        In Trilium, create a child note under the map.
        3 -
        - -
        -
        And then go to Owned Attributes and type #geolocation=", then - paste from the clipboard as-is and then add the ending " character. - Press Enter to confirm and the map should now be updated to contain the - new note.
        - +
        + + + + + + + + + + + + + + + + + + + + + + + + + +
           
        1 +
        + +
        +
        Go to Google Maps on the web and look for a desired location, right click + on it and a context menu will show up.       +
        +
        Simply click on the first item displaying the coordinates and they will + be copied to clipboard.       +
        +
        Then paste the value inside the text box into the #geolocation attribute + of a child note of the map (don't forget to surround the value with a " character).
        2 +
        + +
        +
        In Trilium, create a child note under the map.
        3 +
        + +
        +
        And then go to Owned Attributes and type #geolocation=", then + paste from the clipboard as-is and then add the ending " character. + Press Enter to confirm and the map should now be updated to contain the + new note.
        +

        Adding from OpenStreetMap

        Similarly to the Google Maps approach:

        - - - - - - - - - - - - - - - - - - - - - - - - - -
        1 - - Go to any location on openstreetmap.org and right click to bring up the - context menu. Select the “Show address” item.
        2 - - The address will be visible in the top-left of the screen, in the place - of the search bar.      -
        -
        Select the coordinates and copy them into the clipboard.
        3 - - Simply paste the value inside the text box into the #geolocation attribute - of a child note of the map and then it should be displayed on the map.
        - +
        + + + + + + + + + + + + + + + + + + + + + + + + + +
           
        1 + + Go to any location on openstreetmap.org and right click to bring up the + context menu. Select the “Show address” item.
        2 + + The address will be visible in the top-left of the screen, in the place + of the search bar.       +
        +
        Select the coordinates and copy them into the clipboard.
        3 + + Simply paste the value inside the text box into the #geolocation attribute + of a child note of the map and then it should be displayed on the map.
        +

        Adding GPS tracks (.gpx)

        Trilium has basic support for displaying GPS tracks on the geo map.

        - - - - - - - - - - - - - - - - - - - - - - - - - -
        1 -
        - -
        -
        To add a track, simply drag & drop a .gpx file inside the geo map - in the note tree.
        2 -
        - -
        -
        In order for the file to be recognized as a GPS track, it needs to show - up as application/gpx+xml in the File type field.
        3 -
        - -
        -
        When going back to the map, the track should now be visible.      -
        -
        The start and end points of the track are indicated by the two blue markers.
        - -

        Read-only mode

        -

        When a map is in read-only all editing features will be disabled such - as:

        -
          -
        • The add button in the Floating buttons.
        • -
        • Dragging markers.
        • -
        • Editing from the contextual menu (removing locations or adding new items).
        • -
        -

        To enable read-only mode simply press the Lock icon from the  - Floating buttons. To disable it, press the button again.

        -

        Configuration

        -

        Map Style

        -

        The styling of the map can be adjusted in the Collection Properties tab - in the Ribbon or - manually via the #map:style attribute.

        -

        The geo map comes with two different types of styles:

        -
          -
        • Raster styles -
            -
          • For these styles the map is represented as a grid of images at different - zoom levels. This is the traditional way OpenStreetMap used to work.
          • -
          • Zoom is slightly restricted.
          • -
          • Currently, the only raster theme is the original OpenStreetMap style.
          • +
            + + + + + + + + + + + + + + + + + + + + + + + + + +
               
            1 +
            + +
            +
            To add a track, simply drag & drop a .gpx file inside the geo map + in the note tree.
            2 +
            + +
            +
            In order for the file to be recognized as a GPS track, it needs to show + up as application/gpx+xml in the File type field.
            3 +
            + +
            +
            When going back to the map, the track should now be visible.       +
            +
            The start and end points of the track are indicated by the two blue markers.
            +
            + +

            Read-only mode

            +

            When a map is in read-only all editing features will be disabled such + as:

            +
              +
            • The add button in the Floating buttons.
            • +
            • Dragging markers.
            • +
            • Editing from the contextual menu (removing locations or adding new items).
            • +
            +

            To enable read-only mode simply press the Lock icon from the  + Floating buttons. To disable it, press the button again.

            +

            Configuration

            +

            Map Style

            +

            The styling of the map can be adjusted in the Collection Properties tab + in the Ribbon or + manually via the #map:style attribute.

            +

            The geo map comes with two different types of styles:

            +
              +
            • Raster styles +
                +
              • For these styles the map is represented as a grid of images at different + zoom levels. This is the traditional way OpenStreetMap used to work.
              • +
              • Zoom is slightly restricted.
              • +
              • Currently, the only raster theme is the original OpenStreetMap style.
              -
            • -
            • Vector styles -
                -
              • Vector styles are not represented as images, but as geometrical shapes. - This makes the rendering much smoother, especially when zooming and looking - at the building edges, for example.
              • -
              • The map can be zoomed in much further.
              • -
              • These come both in a light and a dark version.
              • -
              • The vector styles come from VersaTiles, - a free and open-source project providing map tiles based on OpenStreetMap.
              • -
              -
            • -
            - -

            Scale

            -

            Activating this option via the Ribbon or - manually via #map:scale will display an indicator in the bottom-left - of the scale of the map.

            -

            Troubleshooting

            -
            - -
            - -

            Grid-like artifacts on the map

            -

            This occurs if the application is not at 100% zoom which causes the pixels - of the map to not render correctly due to fractional scaling. The only - possible solution is to set the UI zoom at 100% (default keyboard shortcut - is Ctrl+0).

            \ No newline at end of file + +
          • Vector styles +
              +
            • Vector styles are not represented as images, but as geometrical shapes. + This makes the rendering much smoother, especially when zooming and looking + at the building edges, for example.
            • +
            • The map can be zoomed in much further.
            • +
            • These come both in a light and a dark version.
            • +
            • The vector styles come from VersaTiles, + a free and open-source project providing map tiles based on OpenStreetMap.
            • +
            +
          • +
          + +

          Scale

          +

          Activating this option via the Ribbon or + manually via #map:scale will display an indicator in the bottom-left + of the scale of the map.

          +

          Troubleshooting

          +
          + +
          +

          Grid-like artifacts on the map

          +

          This occurs if the application is not at 100% zoom which causes the pixels + of the map to not render correctly due to fractional scaling. The only + possible solution is to set the UI zoom at 100% (default keyboard shortcut + is Ctrl+0).

          \ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Collections/Table View.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Collections/Table View.html index 3c6e3fc9b6..e256b4cb39 100644 --- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Collections/Table View.html +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Collections/Table View.html @@ -8,31 +8,31 @@

          How it works

          The tabular structure is represented as such:

            -
          • Each child note is a row in the table.
          • -
          • If child rows also have children, they will be displayed under an expander +
          • Each child note is a row in the table.
          • +
          • If child rows also have children, they will be displayed under an expander (nested notes).
          • -
          • Each column is a promoted attribute that +
          • Each column is a promoted attribute that is defined on the Collection note.
              -
            • Actually, both promoted and unpromoted attributes are supported, but it's +
            • Actually, both promoted and unpromoted attributes are supported, but it's a requirement to use a label/relation definition.
            • -
            • The promoted attributes are usually defined as inheritable in order to +
            • The promoted attributes are usually defined as inheritable in order to show up in the child notes, but it's not a requirement.
          • -
          • If there are multiple attribute definitions with the same name, +
          • If there are multiple attribute definitions with the same name, only one will be displayed.

          There are also a few predefined columns:

            -
          • The current item number, identified by the # symbol. +
          • The current item number, identified by the # symbol.
              -
            • This simply counts the note and is affected by sorting.
            • +
            • This simply counts the note and is affected by sorting.
          • -
          • Note ID, +
          • Note ID, representing the unique ID used internally by Trilium
          • -
          • The title of the note.
          • +
          • The title of the note.

          Interaction

          Creating a new table

          @@ -43,17 +43,18 @@ is defined on the Collection note.

          To create a new column, either:

            -
          • Press Add new column at the bottom of the table.
          • -
          • Right click on an existing column and select Add column to the left/right.
          • -
          • Right click on the empty space of the column header and select Label or Relation in +
          • Press Add new column at the bottom of the table.
          • +
          • Right click on an existing column and select Add column to the left/right.
          • +
          • Right click on the empty space of the column header and select Label or Relation in the New column section.

          Adding new rows

          Each row is actually a note that is a child of the Collection note.

          To create a new note, either:

            -
          • Press Add new row at the bottom of the table.
          • -
          • Right click on an existing row and select Insert row above, Insert child note or Insert row below.
          • +
          • Press Add new row at the bottom of the table.
          • +
          • Right click on an existing row and select Insert row above, Insert child note or Insert row below.

          By default it will try to edit the title of the newly created note.

          Alternatively, the note can be created from the Context menu

    There are multiple menus:

      -
    • Right clicking on a column, allows: +
    • Right clicking on a column, allows:
        -
      • Sorting by the selected column and resetting the sort.
      • -
      • Hiding the selected column or adjusting the visibility of every column.
      • -
      • Adding new columns to the left or the right of the column.
      • -
      • Editing the current column.
      • -
      • Deleting the current column.
      • +
      • Sorting by the selected column and resetting the sort.
      • +
      • Hiding the selected column or adjusting the visibility of every column.
      • +
      • Adding new columns to the left or the right of the column.
      • +
      • Editing the current column.
      • +
      • Deleting the current column.
      • +
      +
    • +
    • Right clicking on the space to the right of the columns, allows: +
        +
      • Adjusting the visibility of every column.
      • +
      • Adding new columns.
    • -
    • Right clicking on the space to the right of the columns, allows: +
    • Right clicking on a row, allows:
        -
      • Adjusting the visibility of every column.
      • -
      • Adding new columns.
      • -
      -
    • -
    • Right clicking on a row, allows: -
        -
      • Opening the corresponding note of the row in a new tab, split, window +
      • Opening the corresponding note of the row in a new tab, split, window or quick editing it.
      • -
      • Inserting rows above, below or as a child note.
      • -
      • Deleting the row.
      • +
      • Inserting a new note above or below the selected row. These options are + only enabled if the table is not sorted.
      • +
      • Inserting a new child note for the selected row.
      • +
      • Deleting the row.
    @@ -90,17 +94,18 @@ not only reflect in the table, but also as an attribute of the corresponding note.

      -
    • The editing will respect the type of the promoted attribute, by presenting +
    • The editing will respect the type of the promoted attribute, by presenting a normal text box, a number selector or a date selector for example.
    • -
    • It also possible to change the title of a note.
    • -
    • Editing relations is also possible -
        -
      • Simply click on a relation and it will become editable. Enter the text - to look for a note and click on it.
      • -
      • To remove a relation, remove the title of the note from the text box and - click outside the cell.
      • -
      -
    • +
    • It also possible to change the title of a note.
    • +
    • Editing relations is also possible +
        +
      • Simply click on a relation and it will become editable. Enter the text + to look for a note and click on it.
      • +
      • To remove a relation, remove the title of the note from the text box and + click outside the cell.
      • +
      +

    Editing columns

    It is possible to edit a column by right clicking it and selecting Edit column. This @@ -114,18 +119,19 @@ href="#root/_help_oPVyFC7WL2Lp">Note Tree. However, it is possible to sort the data by the values of a column:

      -
    • To do so, simply click on a column.
    • -
    • To switch between ascending or descending sort, simply click again on +
    • To do so, simply click on a column.
    • +
    • To switch between ascending or descending sort, simply click again on the same column. The arrow next to the column will indicate the direction of the sort.
    • -
    • To disable sorting and fall back to the original order, right click any +
    • To disable sorting and fall back to the original order, right click any column on the header and select Clear sorting.

    Reordering and hiding columns

      -
    • Columns can be reordered by dragging the header of the columns.
    • -
    • Columns can be hidden or shown by right clicking on a column and clicking +
    • Columns can be reordered by dragging the header of the columns.
    • +
    • Columns can be hidden or shown by right clicking on a column and clicking the item corresponding to the column.

    Reordering rows

    @@ -136,10 +142,12 @@ href="#root/_help_oPVyFC7WL2Lp">Note Tree.

    Reordering does have some limitations:

      -
    • If the parent note has #sorted, reordering will be disabled.
    • -
    • If using nested tables, then reordering will also be disabled.
    • -
    • Currently, it's possible to reorder notes even if column sorting is used, - but the result might be inconsistent.
    • +
    • If the parent note has #sorted, reordering will be disabled.
    • +
    • If using nested tables, then reordering will also be disabled.
    • +
    • Currently, it's possible to reorder notes even if column sorting is used, + but the result might be inconsistent.

    Nested trees

    If the child notes of the collection also have their own child notes, @@ -150,27 +158,27 @@ to a certain number of levels or even disable it completely. To do so, either:

      -
    • Go to Collection Properties in the Go to Collection Properties in the Ribbon and look for the Max nesting depth section.
        -
      • To disable nesting, type 0 and press Enter.
      • -
      • To limit to a certain depth, type in the desired number (e.g. 2 to only +
      • To disable nesting, type 0 and press Enter.
      • +
      • To limit to a certain depth, type in the desired number (e.g. 2 to only display children and sub-children).
      • -
      • To re-enable unlimited nesting, remove the number and press Enter.
      • +
      • To re-enable unlimited nesting, remove the number and press Enter.
    • -
    • Manually set maxNestingDepth to the desired value.
    • +
    • Manually set maxNestingDepth to the desired value.

    Limitations:

      -
    • While in this mode, it's not possible to reorder notes.
    • +
    • While in this mode, it's not possible to reorder notes.

    Limitations

      -
    • Multi-value labels and relations are not supported. If a Multi-value labels and relations are not supported. If a Promoted Attributes is defined with a Multi value specificity, they will be ignored.
    • -
    • There is no support to filter the rows by a certain criteria. Consider +
    • There is no support to filter the rows by a certain criteria. Consider using the table view in search for that use case.

    Use in search

    @@ -181,8 +189,8 @@ of the Search.

    However, there are also some limitations:

      -
    • It's not possible to reorder notes.
    • -
    • It's not possible to add a new row.
    • +
    • It's not possible to reorder notes.
    • +
    • It's not possible to add a new row.

    Columns are supported, by being defined as Promoted Attributes to the  diff --git a/docs/User Guide/!!!meta.json b/docs/User Guide/!!!meta.json index 7a1d0831bd..2a90f721a9 100644 --- a/docs/User Guide/!!!meta.json +++ b/docs/User Guide/!!!meta.json @@ -1728,6 +1728,13 @@ "value": "bx bx-menu", "isInheritable": false, "position": 10 + }, + { + "type": "relation", + "name": "internalLink", + "value": "MKmLg5x6xkor", + "isInheritable": false, + "position": 200 } ], "format": "markdown", diff --git a/docs/User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Note tree contextual menu.md b/docs/User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Note tree contextual menu.md index 2df2536cf2..5ecb4bbd30 100644 --- a/docs/User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Note tree contextual menu.md +++ b/docs/User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Note tree contextual menu.md @@ -52,6 +52,10 @@ The contextual menu can operate: * Creates a copy of the note and its descendants. * This process is different from Cloning Notes since the duplicated note can be edited independently from the original. * An alternative to this, if done regularly, would be Templates. +* **Archive/Unarchive** + * Marks a note as [archived](../../Notes/Archived%20Notes.md). + * If the note is already archived, it will be unarchived instead. + * Multiple notes can be selected as well. However, all the selected notes must be in the same state (archived or not), otherwise the option will be disabled. * **Delete** * Will delete the given notes, asking for confirmation first. * In the dialog, the following options can be configured: diff --git a/docs/User Guide/User Guide/Note Types/Collections.md b/docs/User Guide/User Guide/Note Types/Collections.md index 81f177fd50..03bc5140c5 100644 --- a/docs/User Guide/User Guide/Note Types/Collections.md +++ b/docs/User Guide/User Guide/Note Types/Collections.md @@ -47,6 +47,12 @@ By default, collections come with a default configuration and sometimes even sam 3. Still in the ribbon, go to _Collection Properties_ and select the desired view type. 4. Consult the help page of the corresponding view type in order to understand how to configure them. +## Archived notes + +By default, archived notes will not be shown in collections. This behaviour can be changed by going to _Collection Properties_ in the Ribbon and checking _Show archived notes_. + +Archived notes will be generally indicated by being greyed out as opposed to the normal ones. + ## Under the hood Collections by themselves are simply notes with no content that rely on the Note List mechanism (the one that lists the children notes at the bottom of a note) to display information. diff --git a/docs/User Guide/User Guide/Note Types/Collections/Board View.md b/docs/User Guide/User Guide/Note Types/Collections/Board View.md index 6a631bd751..4b8bcbdbc3 100644 --- a/docs/User Guide/User Guide/Note Types/Collections/Board View.md +++ b/docs/User Guide/User Guide/Note Types/Collections/Board View.md @@ -12,7 +12,7 @@ Notes are displayed recursively, so even the child notes of the child notes will ## Interaction with columns * Create a new column by pressing _Add Column_ near the last column. - * Once pressed, a text box will be displayed to set the name of the column. Press Enter to confirm. + * Once pressed, a text box will be displayed to set the name of the column. Press Enter to confirm, or Escape to dismiss. * To reorder a column, simply hold the mouse over the title and drag it to the desired position. * To delete a column, right click on its title and select _Delete column_. * To rename a column, click on the note title. @@ -23,8 +23,10 @@ Notes are displayed recursively, so even the child notes of the child notes will ## Interaction with notes * Create a new note in any column by pressing _New item_ - * Enter the name of the note and press _Enter_. - * Doing so will create a new note. The new note will have an attribute (`status` label by default) set to the name of the column. + * Enter the name of the note and press Enter or click away. To dismiss the creation of a new note, simply press Escape or leave the name empty. + * Once created, the new note will have an attribute (`status` label by default) set to the name of the column. +* To open the note, simply click on it. +* To change the title of the note directly from the board, hover the mouse over its card and press the edit button on the right. * To change the state of a note, simply drag a note from one column to the other to change its state. * The order of the notes in each column corresponds to their position in the tree. * It's possible to reorder notes simply by dragging them to the desired position within the same columns. @@ -33,6 +35,7 @@ Notes are displayed recursively, so even the child notes of the child notes will * Open the note in a new tab/split/window or quick edit. * Move the note to any column. * Insert a new note above/below the current one. + * Archive/unarchive the current note. * Delete the current note. * If there are many notes within the column, move the mouse over the column and use the mouse wheel to scroll. diff --git a/docs/User Guide/User Guide/Note Types/Collections/Geo Map View.md b/docs/User Guide/User Guide/Note Types/Collections/Geo Map View.md index ae0be91ce0..a060d385eb 100644 --- a/docs/User Guide/User Guide/Note Types/Collections/Geo Map View.md +++ b/docs/User Guide/User Guide/Note Types/Collections/Geo Map View.md @@ -26,8 +26,8 @@ The position on the map and the zoom are saved inside the map note and restored | | | | | --- | --- | --- | -| 1 | To create a marker, first navigate to the desired point on the map. Then press the ![](10_Geo%20Map%20View_image.png) button in the [Floating buttons](../../Basic%20Concepts%20and%20Features/UI%20Elements/Floating%20buttons.md) (top-right) area.  

    If the button is not visible, make sure the button section is visible by pressing the chevron button (![](17_Geo%20Map%20View_image.png)) in the top-right of the map. | | -| 2 | | Once pressed, the map will enter in the insert mode, as illustrated by the notification.     

    Simply click the point on the map where to place the marker, or the Escape key to cancel. | +| 1 | To create a marker, first navigate to the desired point on the map. Then press the ![](10_Geo%20Map%20View_image.png) button in the [Floating buttons](../../Basic%20Concepts%20and%20Features/UI%20Elements/Floating%20buttons.md) (top-right) area.   

    If the button is not visible, make sure the button section is visible by pressing the chevron button (![](17_Geo%20Map%20View_image.png)) in the top-right of the map. | | +| 2 | | Once pressed, the map will enter in the insert mode, as illustrated by the notification.      

    Simply click the point on the map where to place the marker, or the Escape key to cancel. | | 3 | | Enter the name of the marker/note to be created. | | 4 | | Once confirmed, the marker will show up on the map and it will also be displayed as a child note of the map. | @@ -50,6 +50,9 @@ This works for: * Notes that are a child of the geo map but not yet positioned on the map. * Notes that are a child of the geo map and also positioned, case in which the marker will be relocated to the new position. +> [!NOTE] +> Dragging existing notes only works if the map is in editing mode. See the _Read-only_ section for more information. + ## How the location of the markers is stored The location of a marker is stored in the `#geolocation` attribute of the child notes: @@ -106,7 +109,7 @@ The value of the attribute is made up of the latitude and longitude separated by | | | | | --- | --- | --- | -| 1 |

    | Go to Google Maps on the web and look for a desired location, right click on it and a context menu will show up.     

    Simply click on the first item displaying the coordinates and they will be copied to clipboard.     

    Then paste the value inside the text box into the `#geolocation` attribute of a child note of the map (don't forget to surround the value with a `"` character). | +| 1 |
    | Go to Google Maps on the web and look for a desired location, right click on it and a context menu will show up.      

    Simply click on the first item displaying the coordinates and they will be copied to clipboard.      

    Then paste the value inside the text box into the `#geolocation` attribute of a child note of the map (don't forget to surround the value with a `"` character). | | 2 |
    | In Trilium, create a child note under the map. | | 3 |
    | And then go to Owned Attributes and type `#geolocation="`, then paste from the clipboard as-is and then add the ending `"` character. Press Enter to confirm and the map should now be updated to contain the new note. | @@ -117,7 +120,7 @@ Similarly to the Google Maps approach: | | | | | --- | --- | --- | | 1 | | Go to any location on openstreetmap.org and right click to bring up the context menu. Select the “Show address” item. | -| 2 | | The address will be visible in the top-left of the screen, in the place of the search bar.     

    Select the coordinates and copy them into the clipboard. | +| 2 | | The address will be visible in the top-left of the screen, in the place of the search bar.      

    Select the coordinates and copy them into the clipboard. | | 3 | | Simply paste the value inside the text box into the `#geolocation` attribute of a child note of the map and then it should be displayed on the map. | ## Adding GPS tracks (.gpx) @@ -128,7 +131,7 @@ Trilium has basic support for displaying GPS tracks on the geo map. | --- | --- | --- | | 1 |
    | To add a track, simply drag & drop a .gpx file inside the geo map in the note tree. | | 2 |
    | In order for the file to be recognized as a GPS track, it needs to show up as `application/gpx+xml` in the _File type_ field. | -| 3 |
    | When going back to the map, the track should now be visible.     

    The start and end points of the track are indicated by the two blue markers. | +| 3 |
    | When going back to the map, the track should now be visible.      

    The start and end points of the track are indicated by the two blue markers. | > [!NOTE] > The starting point of the track will be displayed as a marker, with the name of the note underneath. The start marker will also respect the icon and the `color` of the note. The end marker is displayed with a distinct icon. diff --git a/docs/User Guide/User Guide/Note Types/Collections/Table View.md b/docs/User Guide/User Guide/Note Types/Collections/Table View.md index 0454b0a933..78774734ac 100644 --- a/docs/User Guide/User Guide/Note Types/Collections/Table View.md +++ b/docs/User Guide/User Guide/Note Types/Collections/Table View.md @@ -65,7 +65,8 @@ There are multiple menus: * Adding new columns. * Right clicking on a row, allows: * Opening the corresponding note of the row in a new tab, split, window or quick editing it. - * Inserting rows above, below or as a child note. + * Inserting a new note above or below the selected row. These options are only enabled if the table is not sorted. + * Inserting a new child note for the selected row. * Deleting the row. ### Editing data