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 530170282..0e9a39caa 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 02171db60..292006011 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 b7eceffa2..f7d3a1395 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 9660883ea..ce7bbc426 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 9ab58bbbf..f68521d5c 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 2b7f02c9a..cb7d3a8a8 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 c4402e8c7..d57c66871 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 6d64c2b45..4c3c71e76 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 25817b806..550edff60 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 cc7afe812..e047a1762 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 f68521d5c..e533b3562 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 73c98a5f2..21e60c981 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 550edff60..f6873aabf 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 e533b3562..c61859f09 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 f6873aabf..f28f8c2bb 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 c61859f09..b028fbe42 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 612d088f4..08fab236c 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 f28f8c2bb..7c061921b 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 b028fbe42..857f1c6f0 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 000000000..b37824e00 --- /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 7c061921b..c9fc455b1 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 c9fc455b1..bb61ef04c 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 221b902af..09ed254d8 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 857f1c6f0..28f4fd53e 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 d76843a27..d265e0283 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 b37824e00..9d4782da4 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 28f4fd53e..8e88ebdcf 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 cd301498e..cebb2ba58 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 9d4782da4..435f9049b 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 8e88ebdcf..0b1d6b579 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 d57c66871..d749e2844 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 435f9049b..969a173cc 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 21e60c981..f981144b4 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 bb61ef04c..46450707e 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 0b1d6b579..28f2cb866 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 969a173cc..36a751cda 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 d265e0283..81c0cacc7 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 46450707e..cc08685cc 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 f981144b4..1e7fa1a0d 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 cc08685cc..1bf98c3de 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 28f2cb866..2ca6b246a 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 77fec1366..4b425f832 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 d749e2844..173ec06be 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 4c3c71e76..80c194f31 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 1bf98c3de..62f6505fd 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 09ed254d8..4d7925bf7 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 a8b3b2820..e022b5480 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 523f3ef77..fa6c901bc 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 adfb1b0a6..abe9d4174 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 62f6505fd..c25f4ac55 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 4d7925bf7..2a9ec199d 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 fa6c901bc..b4148197d 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 c25f4ac55..eb3ea1de0 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 eb3ea1de0..9893bdd51 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 9893bdd51..4d3f0f795 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 173ec06be..9d9f772c4 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 9d9f772c4..3aec82d99 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 ce7bbc426..0b0e48962 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 1e7fa1a0d..784dcfa49 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 4a9f24ceb..6b1d7af68 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 3aec82d99..05ddc34fe 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 61fdca46b..e730eee23 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 05ddc34fe..d8bfabf85 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 d8bfabf85..3b10d831e 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 e730eee23..53b8893f9 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 3b10d831e..501944158 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 000000000..668962ff1 --- /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 000000000..c116ebd7a --- /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 000000000..8a3dd97b1 --- /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 6a0c74d04..d53513a43 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 9b194f9df..845df3813 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 c116ebd7a..0a490b1f3 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 8a3dd97b1..4452be824 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 501944158..5781daf60 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 0a490b1f3..39081c5ed 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 80c194f31..e528db165 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 845df3813..fa23b6e0e 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 cb7d3a8a8..303eac985 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 39081c5ed..8bdc8b7a8 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 4452be824..8dd6d1fc0 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 fa23b6e0e..60e9facd0 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 5781daf60..f19d983c8 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 8bdc8b7a8..293a1f4a6 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 e528db165..4f89a871d 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 60e9facd0..880ef7230 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 293a1f4a6..a966cb6d5 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 8dd6d1fc0..f959606c7 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 000000000..5f5830c63 --- /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 880ef7230..3a3a286be 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 af836c252..11aa45190 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 a966cb6d5..09d4b7760 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 09d4b7760..8dc272604 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 5f5830c63..559723b83 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 11aa45190..f5649551f 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 8dc272604..142690691 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 142690691..63142b365 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 559723b83..900ef8852 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 f5649551f..1a89d3c2f 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 000000000..7c3b1dbbd --- /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 63142b365..dae07753d 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 900ef8852..53ec84ab8 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 c9dd7368c..85753f38d 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 1a89d3c2f..8cfad222d 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 303eac985..1bce10499 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 dae07753d..7a09d4eb6 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 53ec84ab8..5c250097b 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 7a09d4eb6..4e6ced7a6 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 5c250097b..009d60034 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 8cfad222d..f32824283 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 a1e54f5e8..22efee370 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 7c3b1dbbd..d86ec50b7 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 26d91df27..617c4637f 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 4e6ced7a6..1b8d35834 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 009d60034..7f9a7cda9 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 85753f38d..71041b50a 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 f32824283..0e8e9f4f1 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 668962ff1..45dbf33f6 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 1b8d35834..fee1a36bf 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 f959606c7..1a0f642cc 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 3a3a286be..43c6f70d5 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 1a0f642cc..8fe102210 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 43c6f70d5..e663a449d 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 fee1a36bf..430f56694 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 8fe102210..bb4f22c03 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 bb4f22c03..6e1cb57f1 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 430f56694..a97cac980 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 df87bd669..16a36c883 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 a97cac980..cc17d5691 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 6e1cb57f1..99372d994 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 cc17d5691..4651abddb 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 7f9a7cda9..2a2142d1c 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 a9bf023f3..46f535afe 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 0e8e9f4f1..000000000 --- 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 4651abddb..335a55825 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 6b1d7af68..cc4eb64fb 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 71041b50a..16d3f435d 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 335a55825..16a2a9662 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 16a2a9662..49677f622 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 c55848241..8a7afb781 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 49677f622..df04a1225 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 16d3f435d..000000000 --- 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 46f535afe..4786748c2 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 d888eba6f..cba5c91af 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 7bf10d7f1..226318a92 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 df04a1225..43dbc4a7f 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 8a7afb781..85c7b9b34 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 cc4eb64fb..bc1c14689 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 4786748c2..000000000 --- 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 f19d983c8..a963c50ef 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 000000000..b036a7bdd --- /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 000000000..93ec300df --- /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 ff32474f1..1befd1bb5 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 000000000..69b116a18 --- /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 93ec300df..42b766b8f 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 1befd1bb5..d4e10a0fc 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 b036a7bdd..62842580a 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 42b766b8f..b14fd889d 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 d4e10a0fc..370f841fb 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 b14fd889d..73bce3585 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 370f841fb..b738bc2c1 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 73bce3585..79cb2ac0e 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 b738bc2c1..14e849532 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 62842580a..061680ccf 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 79cb2ac0e..3ffe1fc9d 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 bc1c14689..97b890e02 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 14e849532..ee771144a 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 3ffe1fc9d..1df397f88 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 97b890e02..c86fa544d 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 ee771144a..b091126d0 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 1df397f88..7f15876d3 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 000000000..f8b75386b --- /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 b091126d0..186c4b980 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 000000000..4a256728b --- /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 7f15876d3..803f1f2cc 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 4b425f832..015c509d6 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 4a256728b..c9a13c816 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 803f1f2cc..6ed019df1 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 e13ad4b3e..992e1a1a0 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 e8dc01bf9..73b92aa5b 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 000000000..e12844fab --- /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 6ed019df1..ed4450b30 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 ed4450b30..da30ed0ae 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 73b92aa5b..8448baf1a 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 e12844fab..934edcb2e 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 da30ed0ae..1a4114251 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 1a4114251..be3607f96 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 8448baf1a..d2e8bcc8d 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 be3607f96..af027b1ef 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 af027b1ef..354adf36c 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 354adf36c..49aec00cc 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 a608ca3ad..8834eb8ec 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 49aec00cc..52ca329d8 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 8834eb8ec..594734713 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 ad6c38b02..2c872a14e 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 c9a13c816..3ea4c1001 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 52ca329d8..19ff79040 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 760651774..e4fde650b 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 2aefbbc01..a3e08ff59 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 81c0cacc7..d10669b46 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 69b116a18..2f4103106 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 19ff79040..a135fa904 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 5e6f3266b..2eb69bab8 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 000000000..ad6eaf741 --- /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 0b3ac20af..fc6008e00 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 a04bf989d..1034bb8b3 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 5ad6ac3f2..dbf5055d0 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 ad6eaf741..a04838c84 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 fc6008e00..2d030f4cd 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 21f434d7d..37caf1663 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 dbf5055d0..c887b4fec 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 a04838c84..8deb1f9e3 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 8deb1f9e3..ff1d6556f 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 c887b4fec..b162cbfa4 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 ff1d6556f..22b453110 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 858b792c4..000000000 --- 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 b162cbfa4..b9f6a8d7a 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 22b453110..90dee5efb 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 2d030f4cd..4383eada3 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 b9f6a8d7a..e9c8b8802 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 90dee5efb..4ed890aa9 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 e9c8b8802..0fac94210 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 4ed890aa9..2f8a00550 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 4383eada3..c93700740 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 0fac94210..589af3639 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 c93700740..a454f77f6 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 0c17fd1f3..b0dd94622 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 4f89a871d..a162be81e 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 000000000..c6e1114e1 --- /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 589af3639..4b4db6da8 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 92b0eeea4..99029a54f 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 c6e1114e1..e71aa7bb4 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 4b4db6da8..88c50f2f5 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 99029a54f..000000000 --- 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 88c50f2f5..f880d4873 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 e71aa7bb4..b8d66ea7f 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 f880d4873..532622982 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 39d5a0178..000000000 --- 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 000000000..6aa415a5a --- /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 532622982..82e99ead9 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 b8d66ea7f..2e5ecca14 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 b5568ca34..306a9d6fb 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 6aa415a5a..54c513d61 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 82e99ead9..d372ce12c 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 306a9d6fb..4114fdf5c 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 d372ce12c..dc6062baf 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 2f8a00550..3481c471f 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 a454f77f6..ef80dab80 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 54c513d61..ca5636040 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 dc6062baf..72dfeeb91 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 000000000..3daae04da --- /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 4114fdf5c..cf520303d 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 ef80dab80..479e6f90b 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 ca5636040..8e15677d9 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 3481c471f..9191f22da 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 010bd1c48..000000000 --- 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 cf520303d..1a6939b3c 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 72dfeeb91..4b60a4abd 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 479e6f90b..f25430f85 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 4b60a4abd..7d7048a55 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 2e5ecca14..af92b86d3 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 f25430f85..2011a7456 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 7d7048a55..5ab96ee6e 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 af92b86d3..22ef0e7e4 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 2011a7456..d0684b317 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 8e15677d9..53f63a152 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 5ab96ee6e..fc2e71a7b 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 1a6939b3c..000000000 --- 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 d0684b317..000000000 --- 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 9191f22da..fdcbeb532 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 294d1f2c1..197ccba54 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 a333742e9..85aee6025 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 5e436bf14..d752662f5 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 197ccba54..1e04822f4 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 85aee6025..873231381 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 1e04822f4..8a6e319af 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 873231381..88a7b2cb1 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 8a6e319af..6646d643e 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 88a7b2cb1..000000000 --- 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 6646d643e..2edcccaf8 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 2edcccaf8..43390f04b 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 8e948cc2a..000000000 --- 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 b0dd94622..8e462e44b 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 f468f2292..2a59e82b7 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 000000000..bc941b54e --- /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 000000000..a84e934b8 --- /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 20c51141a..df354ace4 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 92dd99f5f..000000000 --- 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 4f1cf64dc..e73f74a51 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 1a4a48bb2..ec203068f 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 a84e934b8..4220cca8e 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 e73f74a51..474fa7317 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 000000000..70246efcc --- /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 4220cca8e..d47a066bc 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 df354ace4..086a6a714 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 474fa7317..ba2d0990e 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 ec203068f..489fd0b95 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 2a59e82b7..47cc10144 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 d47a066bc..97cdbf97a 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 489fd0b95..5b874a5f7 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 bc941b54e..d5ed0ba5f 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 97cdbf97a..880321e82 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 086a6a714..2a6ed25bd 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 ba2d0990e..54658f6ee 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 5b874a5f7..83767445c 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 70246efcc..103ae3a0e 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 880321e82..6eb9e17a9 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 d5ed0ba5f..6a00dec2f 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 6eb9e17a9..64364ed17 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 6a00dec2f..72edc61a2 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 64364ed17..cf1bf981c 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 cf1bf981c..ec9d18c7b 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 72edc61a2..9e672e22d 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 ec9d18c7b..1fa651f94 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 8e462e44b..fe62d0254 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 9e672e22d..dc1d2f10d 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 1fa651f94..1fee7e5c6 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 1fee7e5c6..ae122c65e 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 103ae3a0e..600c5ebd1 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 ae122c65e..b5f4ea603 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 4e7200328..13eff8c46 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 600c5ebd1..7088198de 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 000000000..cdbe6b5e9 --- /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 b5f4ea603..209f2b106 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 62cf43e65..378c508a2 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 7088198de..e937f1735 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 cdbe6b5e9..7b90d7000 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 209f2b106..a7ba46fbc 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 378c508a2..000000000 --- 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 e937f1735..a3b67ab0a 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 a7ba46fbc..b9a59a259 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 54658f6ee..430629637 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 dc1d2f10d..c620ab0bc 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 b9a59a259..13e15db5e 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 13e15db5e..3547257b0 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 3547257b0..cc00b7b10 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 13eff8c46..48a9590e5 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 a3b67ab0a..f70a8d00d 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 7b90d7000..303db332a 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 66fc218b0..1739c8374 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 430629637..3641fc50d 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 83767445c..11fe5fe6d 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 cc00b7b10..5c2c44603 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 f27fe6814..ba48a38df 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 f70a8d00d..210c2fb92 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 c620ab0bc..6e002ba20 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 5c2c44603..2acbfe82d 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 000000000..3dda7fd89 --- /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 000000000..08da9c97b --- /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 2acbfe82d..e7b9a9322 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 3dda7fd89..842af7ebd 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 08da9c97b..a64ed4afb 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 e7b9a9322..fdb7d2a63 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 842af7ebd..04e025dcf 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 fdb7d2a63..400e5b578 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 56ef0945d..7e0932616 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 210c2fb92..b696cb228 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 04e025dcf..0535b493e 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 43ee9ef63..67f6e7674 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 47cc10144..2a59e82b7 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 400e5b578..ea2badb3c 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 0535b493e..6d684ad3c 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 67f6e7674..5518ae771 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 5518ae771..9567862aa 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 ea2badb3c..f4ae8769c 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 6d684ad3c..ca880f77a 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 1b03a4273..81d0ca1e5 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 3641fc50d..000000000 --- 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 11be8f9f2..000000000 --- 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 3957ee2e9..000000000 --- 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 ef08d7900..000000000 --- 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 93b14d94f..d30a6e340 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 f4ae8769c..e963d00fb 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 ca880f77a..f301a2855 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 d30a6e340..5a2ed3ad2 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 e963d00fb..520817fbd 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 5a2ed3ad2..5d859d942 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 520817fbd..886719e00 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 e81704bb9..4d548d8cb 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 5d859d942..cfca7b28f 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 cfca7b28f..b1e6559ed 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 2812120aa..000000000 --- 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 4d548d8cb..000000000 --- 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 f301a2855..15cb11d35 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 81d0ca1e5..a70440286 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 15cb11d35..8c325b72f 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 8c325b72f..4ad335b4b 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 a70440286..acadb5b6d 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 886719e00..23c866265 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 acadb5b6d..80e0a83cf 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 80e0a83cf..11971f591 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 11971f591..154f4adb3 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 4ad335b4b..165eccc75 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 154f4adb3..67fe0985c 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 67fe0985c..740412b2f 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 b696cb228..cc00b5495 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 b1e6559ed..44aca3fd9 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 740412b2f..18f9bf76b 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 23c866265..df3fcf6cd 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 cc00b5495..7188ef699 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 df3fcf6cd..483703552 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 18f9bf76b..054ab82a7 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 b80e8e3fb..354a2f213 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 7e0932616..bba8036bf 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 fe62d0254..1d642c3b2 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 303db332a..a2489fcf2 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 2a59e82b7..9f55b26b5 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 7188ef699..c4cca678a 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 483703552..2dc71b647 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 42eda932e..000000000 --- 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 354a2f213..3fff82afa 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 1d642c3b2..7f3da0c15 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 bba8036bf..41616154b 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 ebacd85d3..c58bbc02b 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 45dbf33f6..6b8b27d45 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 7dfc5de58..d53158b4a 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 c58bbc02b..6180b2321 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 02438d4e3..0dc3f4ffe 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 3ea4c1001..8687dc6d9 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 c56858610..2255cc1d0 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 cc1eb1329..48249b0c9 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 fc2e71a7b..0c4e3e8f8 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 460f169ac..84b4c5882 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 165eccc75..ffd3d0b67 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 054ab82a7..fa259ccb4 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 2dc71b647..765ffb1f8 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 0dc3f4ffe..276256e25 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 41616154b..574b70688 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 574b70688..c6d0bdac3 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 d53158b4a..4f1784850 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 48a9590e5..06ea554ec 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 ffd3d0b67..b80510f61 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 44aca3fd9..c38b49ffe 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 765ffb1f8..79f400355 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 c4cca678a..86936bc67 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 c38b49ffe..b9036c7f5 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 c6d0bdac3..98797051b 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 b80510f61..68c1c1a37 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 a2489fcf2..9e4a98465 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 98797051b..634fe3c35 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 86936bc67..61bcbe339 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 9e4a98465..d3f74fde4 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 79f400355..3475e3be9 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 b9036c7f5..269a61260 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 3475e3be9..552f71476 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 634fe3c35..c084cf7ec 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 269a61260..88fec26aa 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 552f71476..5bbfed9ad 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 5bbfed9ad..2e77069fc 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 2e77069fc..3b623f7cb 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 3b623f7cb..a71a117a3 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 fa259ccb4..cf269eabc 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 88fec26aa..c9b644852 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 c9b644852..ab211ca1b 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 68c1c1a37..bd377f2ec 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 ab211ca1b..37e095a44 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 37e095a44..f91243923 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 2bbee7b36..4636cd60d 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 bd377f2ec..342f781ad 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 a71a117a3..8b95da30b 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 342f781ad..917fecefd 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 f91243923..23b481358 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 61bcbe339..90d41d7c4 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 23b481358..584537387 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 b4055d6f8..e11beb203 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 6180b2321..2594dd6c2 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 4f1784850..b8e80ab79 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 7f3da0c15..af5d831c8 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 0e4ae2678..5e1c53041 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 cd213748b..7636be7dd 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 ec7850572..7b4ba72ad 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 66c2613ce..c0a97c7d6 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 e69eb5c37..000000000 --- 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 d5f4d2b1d..92b28e573 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 a26fabf82..ef3bd6cba 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 a22ba6717..9d4ff5c4c 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 c37cf7550..71e94707e 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 74570c75f..0d56685cc 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 cba5c91af..ce33d1447 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 86ec8e9a8..b1231e598 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 cda3c2852..c3df01b0e 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 035bed6a6..6121ab422 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 fc34a805f..94148a455 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 dcd63e577..b873289b4 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 594104b66..441b86315 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 ce26b6078..d6bd8b675 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 5181c333b..efec3d2af 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 a15660676..78573ceda 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 479bd3494..cce580a08 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 3aeb26d83..1ebca5d5a 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 79b587a09..622f65374 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 5bf57c042..45af7e944 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 f75d399f4..000000000 --- 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 f83154147..7cef303f8 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 3c359d742..000000000 --- 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 71e94707e..f89b44869 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 0d56685cc..2454fbf1b 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 6121ab422..2300ca101 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 7cef303f8..60ece320b 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 2454fbf1b..114ff4eea 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 432990bc0..7bbde59ff 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 114ff4eea..74570c75f 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 000000000..dca860784 --- /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 94148a455..1e1984ae5 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 00e3ec4b7..cd50fe09b 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 68c82702b..1a724b1b0 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 e7a1c0a1c..07d6a3e68 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 bed1c93b7..ac6da765f 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 7433cd552..4bc0c2177 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 c7253f2d6..273dc1e1d 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 8a426dea3..3c6db4054 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 74d2b2c4e..52e60a60f 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 b626bf919..678fb39e5 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 91d01c8c7..116a841b2 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 4699ca32e..5a13e0960 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 cce580a08..0a129ae1e 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 934578c70..130eb8197 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 7603cd625..ac52a43f4 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 b2d83bdc6..c1ac90b91 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 e225cdb52..3ecf98e0a 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 541e487a0..926d61bba 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 60ece320b..79122895b 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 f89b44869..9dfcbc019 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 dca860784..67beb0b42 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 b873289b4..79f64c598 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 af5d831c8..1c82abe90 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 1c82abe90..3f0231b0d 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 85c7b9b34..06ce63dc2 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 b8e80ab79..23432084d 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 2a2142d1c..8b6cb1371 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 3fff82afa..dcb768dd7 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 23432084d..416aadbe3 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 06ea554ec..1b218bb33 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 c65b9ab3e..4b42699d3 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 824040b8a..cb747d6b4 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 1b218bb33..76ea6077e 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 d5e3332c1..2232503db 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 2594dd6c2..8960fe46d 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 8cc0c2b85..9dc1574c7 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 81ac3a3ef..dbdd49a0b 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 6f98c63e1..69e33cf51 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 d53513a43..93dbc1076 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 7bbde59ff..ef60f29be 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 000000000..9494d7b92 --- /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 76ea6077e..a1f06eeac 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 9494d7b92..8b8de89c1 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 3f0231b0d..f16db0b72 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 917fecefd..5b663e141 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 d86ec50b7..5f7341560 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 416aadbe3..ccc1330d1 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 06ce63dc2..faf2ec654 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 4d3f0f795..b88812d72 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 43390f04b..6351ba598 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 fdcbeb532..6b8bc0a42 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 a162be81e..0b2fdb22d 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 0b0e48962..000000000 --- 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 e689e1bc4..000000000 --- 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 1bce10499..000000000 --- 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 f16db0b72..98d9a3feb 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 6b8bc0a42..57c90b59a 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 215b44f1a..e0f40fd87 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 28740f3bd..8a03ef6a0 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 b16124cbb..0d0af35f7 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 db2c7ba76..a74c243c4 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 dedbb0293..152d9cb27 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 98d9a3feb..63eb64a99 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 faf2ec654..da9c2173b 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 08241f931..32cae708b 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 53b8893f9..000000000 --- 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 b41d39363..bd6d49aab 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 000000000..f69c27506 --- /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 c8dc3a86b..0488c7d36 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 7b255c880..8ec44b5ad 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 3c13a81d8..6dc4e1029 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 cbda5b977..9e0d2cf9c 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 3c6e3fc9b..e256b4cb3 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 7a1d0831b..2a90f721a 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 2df2536cf..5ecb4bbd3 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 81f177fd5..03bc5140c 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 6a631bd75..4b8bcbdbc 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 ae0be91ce..a060d385e 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 0454b0a93..78774734a 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