From 11547ecaa33e005db0e8270dcd21507fc0fd4de3 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 19 Jul 2025 18:29:31 +0300 Subject: [PATCH 01/53] chore(views/board): create empty board --- .../client/src/services/note_list_renderer.ts | 8 ++- .../widgets/view_widgets/board_view/index.ts | 51 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 apps/client/src/widgets/view_widgets/board_view/index.ts diff --git a/apps/client/src/services/note_list_renderer.ts b/apps/client/src/services/note_list_renderer.ts index 08af048f0..50556715a 100644 --- a/apps/client/src/services/note_list_renderer.ts +++ b/apps/client/src/services/note_list_renderer.ts @@ -1,4 +1,5 @@ import type FNote from "../entities/fnote.js"; +import BoardView from "../widgets/view_widgets/board_view/index.js"; import CalendarView from "../widgets/view_widgets/calendar_view.js"; import GeoView from "../widgets/view_widgets/geo_view/index.js"; import ListOrGridView from "../widgets/view_widgets/list_or_grid_view.js"; @@ -6,8 +7,9 @@ import TableView from "../widgets/view_widgets/table_view/index.js"; import type { ViewModeArgs } from "../widgets/view_widgets/view_mode.js"; import type ViewMode from "../widgets/view_widgets/view_mode.js"; +const allViewTypes = ["list", "grid", "calendar", "table", "geoMap", "board"] as const; export type ArgsWithoutNoteId = Omit; -export type ViewTypeOptions = "list" | "grid" | "calendar" | "table" | "geoMap"; +export type ViewTypeOptions = typeof allViewTypes[number]; export default class NoteListRenderer { @@ -23,7 +25,7 @@ export default class NoteListRenderer { #getViewType(parentNote: FNote): ViewTypeOptions { const viewType = parentNote.getLabelValue("viewType"); - if (!["list", "grid", "calendar", "table", "geoMap"].includes(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 { @@ -57,6 +59,8 @@ export default class NoteListRenderer { return new TableView(args); case "geoMap": return new GeoView(args); + case "board": + return new BoardView(args); case "list": case "grid": default: diff --git a/apps/client/src/widgets/view_widgets/board_view/index.ts b/apps/client/src/widgets/view_widgets/board_view/index.ts new file mode 100644 index 000000000..0e5c6e241 --- /dev/null +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -0,0 +1,51 @@ +import ViewMode, { ViewModeArgs } from "../view_mode"; + +const TPL = /*html*/` +
+ + +
+ Board view goes here. +
+
+`; + +export interface StateInfo { + +}; + +export default class BoardView extends ViewMode { + + private $root: JQuery; + private $container: JQuery; + + constructor(args: ViewModeArgs) { + super(args, "board"); + + this.$root = $(TPL); + this.$container = this.$root.find(".board-view-container"); + args.$parent.append(this.$root); + } + + async renderList(): Promise | undefined> { + // this.$container.empty(); + this.renderBoard(this.$container[0]); + return this.$root; + } + + private async renderBoard(el: HTMLElement) { + + } + +} From 951b5384a3376d82e5b0b87f0a09c4bd6b781911 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 19 Jul 2025 18:34:59 +0300 Subject: [PATCH 02/53] chore(views/board): prepare to group by attribute --- .../widgets/view_widgets/board_view/data.ts | 23 +++++++++++++++++++ .../widgets/view_widgets/board_view/index.ts | 4 +++- 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 apps/client/src/widgets/view_widgets/board_view/data.ts diff --git a/apps/client/src/widgets/view_widgets/board_view/data.ts b/apps/client/src/widgets/view_widgets/board_view/data.ts new file mode 100644 index 000000000..8b3407270 --- /dev/null +++ b/apps/client/src/widgets/view_widgets/board_view/data.ts @@ -0,0 +1,23 @@ +import FNote from "../../../entities/fnote"; +import froca from "../../../services/froca"; + +export async function getBoardData(noteIds: string[], groupByColumn: string) { + const notes = await froca.getNotes(noteIds); + const byColumn: Map = new Map(); + + for (const note of notes) { + const group = note.getLabelValue(groupByColumn); + if (!group) { + continue; + } + + if (!byColumn.has(group)) { + byColumn.set(group, []); + } + byColumn.get(group)!.push(note); + } + + return { + byColumn + }; +} 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 0e5c6e241..0589845d2 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -1,4 +1,5 @@ import ViewMode, { ViewModeArgs } from "../view_mode"; +import { getBoardData } from "./data"; const TPL = /*html*/`
@@ -45,7 +46,8 @@ export default class BoardView extends ViewMode { } private async renderBoard(el: HTMLElement) { - + const data = await getBoardData(this.noteIds, "status"); + console.log("Board data:", data); } } From 0d18b944b63212f1420212130ab992377a7a16f8 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 19 Jul 2025 18:44:50 +0300 Subject: [PATCH 03/53] feat(views/board): display columns --- .../widgets/view_widgets/board_view/index.ts | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) 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 0589845d2..45be8a654 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -8,17 +8,26 @@ const TPL = /*html*/` overflow: hidden; position: relative; height: 100%; + padding: 1em; user-select: none; } .board-view-container { height: 100%; + display: flex; + gap: 1em; + } + + .board-view-container .board-column { + min-width: 200px; + } + + .board-view-container .board-column h3 { + font-size: 1.2em; } -
- Board view goes here. -
+
`; @@ -40,14 +49,32 @@ export default class BoardView extends ViewMode { } async renderList(): Promise | undefined> { - // this.$container.empty(); + this.$container.empty(); this.renderBoard(this.$container[0]); + return this.$root; } private async renderBoard(el: HTMLElement) { const data = await getBoardData(this.noteIds, "status"); - console.log("Board data:", data); + + for (const column of data.byColumn.keys()) { + const columnNotes = data.byColumn.get(column); + if (!columnNotes) { + continue; + } + + const $columnEl = $("
") + .addClass("board-column") + .append($("

").text(column)); + + for (const note of columnNotes) { + const $noteEl = $("
").addClass("board-note").text(note.title); // Assuming FNote has a title property + $columnEl.append($noteEl); + } + + $(el).append($columnEl); + } } } From 47daebc65a5ab7cef858ddc3f8914e88ae5872af Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 19 Jul 2025 19:02:45 +0300 Subject: [PATCH 04/53] feat(views/board): improve display of the notes --- apps/client/src/widgets/view_widgets/board_view/index.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 45be8a654..552a5c3e2 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -23,7 +23,14 @@ const TPL = /*html*/` } .board-view-container .board-column h3 { - font-size: 1.2em; + font-size: 1em; + } + + .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; } From 7664839135d7f9f7d1acd2a42f08a0e0d9cb78e1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 19 Jul 2025 19:16:39 +0300 Subject: [PATCH 05/53] feat(views/board): display note icon --- .../src/widgets/view_widgets/board_view/index.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 552a5c3e2..d90379795 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -32,6 +32,10 @@ const TPL = /*html*/` padding: 0.5em; border-radius: 5px; } + + .board-view-container .board-note .icon { + margin-right: 0.25em; + }
@@ -76,7 +80,14 @@ export default class BoardView extends ViewMode { .append($("

").text(column)); for (const note of columnNotes) { - const $noteEl = $("
").addClass("board-note").text(note.title); // Assuming FNote has a title property + const $iconEl = $("") + .addClass("icon") + .addClass(note.getIcon()); + + const $noteEl = $("
") + .addClass("board-note") + .text(note.title); // Assuming FNote has a title property + $noteEl.prepend($iconEl); $columnEl.append($noteEl); } From 2a25cd8686922e4184dc892307102e49ef30b7a7 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 19 Jul 2025 19:20:32 +0300 Subject: [PATCH 06/53] feat(views/board): fixed column size --- apps/client/src/widgets/view_widgets/board_view/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 d90379795..4bfec9721 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -15,11 +15,11 @@ const TPL = /*html*/` .board-view-container { height: 100%; display: flex; - gap: 1em; + gap: 1.5em; } .board-view-container .board-column { - min-width: 200px; + width: 250px; } .board-view-container .board-column h3 { From 3e7dc719959b21f64b24b8ecacee79eb0c9843a7 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 19 Jul 2025 19:23:42 +0300 Subject: [PATCH 07/53] feat(views/board): make scrollable --- apps/client/src/widgets/view_widgets/board_view/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 4bfec9721..d4038df4d 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -5,10 +5,9 @@ const TPL = /*html*/`
@@ -52,6 +97,8 @@ export default class BoardView extends ViewMode { private $root: JQuery; private $container: JQuery; + private draggedNote: any = null; + private draggedNoteElement: JQuery | null = null; constructor(args: ViewModeArgs) { super(args, "board"); @@ -65,7 +112,7 @@ export default class BoardView extends ViewMode { async renderList(): Promise | undefined> { this.$container.empty(); - this.renderBoard(this.$container[0]); + await this.renderBoard(this.$container[0]); return this.$root; } @@ -81,8 +128,12 @@ export default class BoardView extends ViewMode { const $columnEl = $("
") .addClass("board-column") + .attr("data-column", column) .append($("

").text(column)); + // Setup drop zone for the column + this.setupColumnDropZone($columnEl, column); + for (const note of columnNotes) { const $iconEl = $("") .addClass("icon") @@ -90,8 +141,15 @@ export default class BoardView extends ViewMode { const $noteEl = $("
") .addClass("board-note") - .text(note.title); // Assuming FNote has a title property + .attr("data-note-id", note.noteId) + .attr("data-current-column", column) + .text(note.title); + $noteEl.prepend($iconEl); + + // Setup drag functionality for the note + this.setupNoteDrag($noteEl, note); + $columnEl.append($noteEl); } @@ -99,4 +157,132 @@ export default class BoardView extends ViewMode { } } + private setupNoteDrag($noteEl: JQuery, note: any) { + $noteEl.attr("draggable", "true"); + + $noteEl.on("dragstart", (e) => { + this.draggedNote = note; + this.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.draggedNote = null; + this.draggedNoteElement = null; + + // Remove all drop indicators + this.$container.find(".board-drop-indicator").removeClass("show"); + }); + } + + private setupColumnDropZone($columnEl: JQuery, column: string) { + $columnEl.on("dragover", (e) => { + e.preventDefault(); + const originalEvent = e.originalEvent as DragEvent; + if (originalEvent.dataTransfer) { + originalEvent.dataTransfer.dropEffect = "move"; + } + + if (this.draggedNote) { + $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"); + $columnEl.find(".board-drop-indicator").removeClass("show"); + } + }); + + $columnEl.on("drop", async (e) => { + e.preventDefault(); + $columnEl.removeClass("drag-over"); + $columnEl.find(".board-drop-indicator").removeClass("show"); + + if (this.draggedNote && this.draggedNoteElement) { + const currentColumn = this.draggedNoteElement.attr("data-current-column"); + + if (currentColumn !== column) { + try { + // Update the note's status label + await attributeService.setLabel(this.draggedNote.noteId, "status", column); + + // Move the note element to the new column + const dropIndicator = $columnEl.find(".board-drop-indicator.show"); + if (dropIndicator.length > 0) { + dropIndicator.after(this.draggedNoteElement); + } else { + $columnEl.append(this.draggedNoteElement); + } + + // Update the data attribute + this.draggedNoteElement.attr("data-current-column", column); + + // Show success feedback (optional) + console.log(`Moved note "${this.draggedNote.title}" from "${currentColumn}" to "${column}"`); + } catch (error) { + console.error("Failed to update note status:", error); + // Optionally show user-facing error message + } + } + } + }); + } + + private showDropIndicator($columnEl: JQuery, e: JQuery.DragOverEvent) { + const originalEvent = e.originalEvent as DragEvent; + const mouseY = originalEvent.clientY; + const columnRect = $columnEl[0].getBoundingClientRect(); + const relativeY = mouseY - columnRect.top; + + // Find existing drop indicator or create one + let $dropIndicator = $columnEl.find(".board-drop-indicator"); + if ($dropIndicator.length === 0) { + $dropIndicator = $("
").addClass("board-drop-indicator"); + $columnEl.append($dropIndicator); + } + + // Find the best position to insert the note + const $notes = this.draggedNoteElement ? + $columnEl.find(".board-note").not(this.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"); + } + } From 765691751a2bcc51b25ccb761caa42bb199b39af Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 19 Jul 2025 19:53:48 +0300 Subject: [PATCH 10/53] feat(views/board): bypass horizontal scroll if column needs scrolling --- .../src/widgets/view_widgets/board_view/index.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 8bbe443a2..33f61a632 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -29,6 +29,7 @@ const TPL = /*html*/` padding: 0.5em; background-color: var(--accented-background-color); transition: border-color 0.2s ease; + overflow-y: auto; } .board-view-container .board-column.drag-over { @@ -131,6 +132,15 @@ export default class BoardView extends ViewMode { .attr("data-column", column) .append($("

").text(column)); + // Allow vertical scrolling in the column, bypassing the horizontal scroll of the container. + $columnEl.on("wheel", (event) => { + const el = $columnEl[0]; + const needsScroll = el.scrollHeight > el.clientHeight; + if (needsScroll) { + event.stopPropagation(); + } + }); + // Setup drop zone for the column this.setupColumnDropZone($columnEl, column); From c5ffc2882b3d5210c2c3da11584cef244f9793c5 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 19 Jul 2025 19:57:02 +0300 Subject: [PATCH 11/53] feat(views/board): react to changes --- apps/client/src/widgets/view_widgets/board_view/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) 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 33f61a632..e6d2ba1f5 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -2,6 +2,7 @@ import { setupHorizontalScrollViaWheel } from "../../widget_utils"; import ViewMode, { ViewModeArgs } from "../view_mode"; import { getBoardData } from "./data"; import attributeService from "../../../services/attributes"; +import { EventData } from "../../../components/app_context"; const TPL = /*html*/`
@@ -295,4 +296,12 @@ export default class BoardView extends ViewMode { $dropIndicator.addClass("show"); } + async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) { + if (loadResults.getAttributeRows().some(attr => attr.name === "status" && this.noteIds.includes(attr.noteId!))) { + return true; + } + + return false; + } + } From f69878b082884663706d69518b5257820be622b1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 19 Jul 2025 20:30:05 +0300 Subject: [PATCH 12/53] refactor(views/board): use branches instead of notes --- .../widgets/view_widgets/board_view/data.ts | 28 ++++++++++++------- .../widgets/view_widgets/board_view/index.ts | 13 ++++++--- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/apps/client/src/widgets/view_widgets/board_view/data.ts b/apps/client/src/widgets/view_widgets/board_view/data.ts index 8b3407270..9f5d06929 100644 --- a/apps/client/src/widgets/view_widgets/board_view/data.ts +++ b/apps/client/src/widgets/view_widgets/board_view/data.ts @@ -1,11 +1,23 @@ +import FBranch from "../../../entities/fbranch"; import FNote from "../../../entities/fnote"; -import froca from "../../../services/froca"; -export async function getBoardData(noteIds: string[], groupByColumn: string) { - const notes = await froca.getNotes(noteIds); - const byColumn: Map = new Map(); +export async function getBoardData(parentNote: FNote, groupByColumn: string) { + const byColumn: Map = new Map(); + + await recursiveGroupBy(parentNote.getChildBranches(), byColumn, groupByColumn); + + return { + byColumn + }; +} + +async function recursiveGroupBy(branches: FBranch[], byColumn: Map, groupByColumn: string) { + for (const branch of branches) { + const note = await branch.getNote(); + if (!note) { + continue; + } - for (const note of notes) { const group = note.getLabelValue(groupByColumn); if (!group) { continue; @@ -14,10 +26,6 @@ export async function getBoardData(noteIds: string[], groupByColumn: string) { if (!byColumn.has(group)) { byColumn.set(group, []); } - byColumn.get(group)!.push(note); + byColumn.get(group)!.push(branch); } - - return { - byColumn - }; } 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 e6d2ba1f5..be5a27432 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -120,11 +120,11 @@ export default class BoardView extends ViewMode { } private async renderBoard(el: HTMLElement) { - const data = await getBoardData(this.noteIds, "status"); + const data = await getBoardData(this.parentNote, "status"); for (const column of data.byColumn.keys()) { - const columnNotes = data.byColumn.get(column); - if (!columnNotes) { + const columnBranches = data.byColumn.get(column); + if (!columnBranches) { continue; } @@ -145,7 +145,12 @@ export default class BoardView extends ViewMode { // Setup drop zone for the column this.setupColumnDropZone($columnEl, column); - for (const note of columnNotes) { + for (const branch of columnBranches) { + const note = await branch.getNote(); + if (!note) { + continue; + } + const $iconEl = $("") .addClass("icon") .addClass(note.getIcon()); From a428ea7beb12619b3263babe71458c1bead7d6df Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 19 Jul 2025 20:34:54 +0300 Subject: [PATCH 13/53] refactor(views/board): store both branch and note --- .../src/widgets/view_widgets/board_view/data.ts | 14 +++++++++++--- .../src/widgets/view_widgets/board_view/index.ts | 8 ++++---- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/client/src/widgets/view_widgets/board_view/data.ts b/apps/client/src/widgets/view_widgets/board_view/data.ts index 9f5d06929..bd624e1f7 100644 --- a/apps/client/src/widgets/view_widgets/board_view/data.ts +++ b/apps/client/src/widgets/view_widgets/board_view/data.ts @@ -1,8 +1,13 @@ import FBranch from "../../../entities/fbranch"; import FNote from "../../../entities/fnote"; +type ColumnMap = Map; + export async function getBoardData(parentNote: FNote, groupByColumn: string) { - const byColumn: Map = new Map(); + const byColumn: ColumnMap = new Map(); await recursiveGroupBy(parentNote.getChildBranches(), byColumn, groupByColumn); @@ -11,7 +16,7 @@ export async function getBoardData(parentNote: FNote, groupByColumn: string) { }; } -async function recursiveGroupBy(branches: FBranch[], byColumn: Map, groupByColumn: string) { +async function recursiveGroupBy(branches: FBranch[], byColumn: ColumnMap, groupByColumn: string) { for (const branch of branches) { const note = await branch.getNote(); if (!note) { @@ -26,6 +31,9 @@ async function recursiveGroupBy(branches: FBranch[], byColumn: Map { const data = await getBoardData(this.parentNote, "status"); for (const column of data.byColumn.keys()) { - const columnBranches = data.byColumn.get(column); - if (!columnBranches) { + const columnItems = data.byColumn.get(column); + if (!columnItems) { continue; } @@ -145,8 +145,8 @@ export default class BoardView extends ViewMode { // Setup drop zone for the column this.setupColumnDropZone($columnEl, column); - for (const branch of columnBranches) { - const note = await branch.getNote(); + for (const item of columnItems) { + const note = item.note; if (!note) { continue; } From 08d60c554c3f78759f95ca252e64072e5b48d930 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 19 Jul 2025 20:44:54 +0300 Subject: [PATCH 14/53] feat(views/board): set up reordering for same column --- .../widgets/view_widgets/board_view/index.ts | 89 ++++++++++++++----- 1 file changed, 66 insertions(+), 23 deletions(-) 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 ef3643df6..3c89c5309 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -2,6 +2,7 @@ import { setupHorizontalScrollViaWheel } from "../../widget_utils"; import ViewMode, { ViewModeArgs } from "../view_mode"; import { getBoardData } from "./data"; import attributeService from "../../../services/attributes"; +import branchService from "../../../services/branches"; import { EventData } from "../../../components/app_context"; const TPL = /*html*/` @@ -100,6 +101,7 @@ export default class BoardView extends ViewMode { private $root: JQuery; private $container: JQuery; private draggedNote: any = null; + private draggedBranch: any = null; private draggedNoteElement: JQuery | null = null; constructor(args: ViewModeArgs) { @@ -147,6 +149,7 @@ export default class BoardView extends ViewMode { for (const item of columnItems) { const note = item.note; + const branch = item.branch; if (!note) { continue; } @@ -158,13 +161,14 @@ export default class BoardView extends ViewMode { const $noteEl = $("
") .addClass("board-note") .attr("data-note-id", note.noteId) + .attr("data-branch-id", branch.branchId) .attr("data-current-column", column) .text(note.title); $noteEl.prepend($iconEl); // Setup drag functionality for the note - this.setupNoteDrag($noteEl, note); + this.setupNoteDrag($noteEl, note, branch); $columnEl.append($noteEl); } @@ -173,11 +177,12 @@ export default class BoardView extends ViewMode { } } - private setupNoteDrag($noteEl: JQuery, note: any) { + private setupNoteDrag($noteEl: JQuery, note: any, branch: any) { $noteEl.attr("draggable", "true"); $noteEl.on("dragstart", (e) => { this.draggedNote = note; + this.draggedBranch = branch; this.draggedNoteElement = $noteEl; $noteEl.addClass("dragging"); @@ -192,6 +197,7 @@ export default class BoardView extends ViewMode { $noteEl.on("dragend", () => { $noteEl.removeClass("dragging"); this.draggedNote = null; + this.draggedBranch = null; this.draggedNoteElement = null; // Remove all drop indicators @@ -229,34 +235,65 @@ export default class BoardView extends ViewMode { $columnEl.on("drop", async (e) => { e.preventDefault(); $columnEl.removeClass("drag-over"); - $columnEl.find(".board-drop-indicator").removeClass("show"); - if (this.draggedNote && this.draggedNoteElement) { + if (this.draggedNote && this.draggedNoteElement && this.draggedBranch) { const currentColumn = this.draggedNoteElement.attr("data-current-column"); - if (currentColumn !== column) { - try { - // Update the note's status label - await attributeService.setLabel(this.draggedNote.noteId, "status", 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; - // Move the note element to the new column - const dropIndicator = $columnEl.find(".board-drop-indicator.show"); - if (dropIndicator.length > 0) { - dropIndicator.after(this.draggedNoteElement); - } else { - $columnEl.append(this.draggedNoteElement); - } + 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"); - // Update the data attribute - this.draggedNoteElement.attr("data-current-column", column); - - // Show success feedback (optional) - console.log(`Moved note "${this.draggedNote.title}" from "${currentColumn}" to "${column}"`); - } catch (error) { - console.error("Failed to update note status:", error); - // Optionally show user-facing error message + 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"; } } + + // Now remove the drop indicator + $columnEl.find(".board-drop-indicator").removeClass("show"); + + try { + // Handle column change + if (currentColumn !== column) { + await attributeService.setLabel(this.draggedNote.noteId, "status", column); + } + + // Handle position change (works for both same column and different column moves) + if (targetBranchId && moveType) { + if (moveType === "before") { + console.log("Move before branch:", this.draggedBranch.branchId, "to", targetBranchId); + await branchService.moveBeforeBranch([this.draggedBranch.branchId], targetBranchId); + } else if (moveType === "after") { + console.log("Move after branch:", this.draggedBranch.branchId, "to", targetBranchId); + await branchService.moveAfterBranch([this.draggedBranch.branchId], targetBranchId); + } + } + + // Update the UI + if (dropIndicator.length > 0) { + dropIndicator.after(this.draggedNoteElement); + } else { + $columnEl.append(this.draggedNoteElement); + } + + // Update the data attributes + this.draggedNoteElement.attr("data-current-column", column); + + // Show success feedback + console.log(`Moved note "${this.draggedNote.title}" from "${currentColumn}" to "${column}"`); + } catch (error) { + console.error("Failed to update note position:", error); + // Optionally show user-facing error message + } } }); } @@ -302,10 +339,16 @@ export default class BoardView extends ViewMode { } async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) { + // React to changes in "status" attribute for notes in this board if (loadResults.getAttributeRows().some(attr => attr.name === "status" && this.noteIds.includes(attr.noteId!))) { return true; } + // React to changes in branches for subchildren (e.g., moved, added, or removed notes) + if (loadResults.getBranchRows().some(branch => this.noteIds.includes(branch.noteId!))) { + return true; + } + return false; } From efd409da1721f9b794976e14eff0ea897d71f2bf Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 19 Jul 2025 21:07:29 +0300 Subject: [PATCH 15/53] fix(views/board): some runtime errors --- .../widgets/view_widgets/board_view/index.ts | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) 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 3c89c5309..998e1ad7a 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -236,8 +236,11 @@ export default class BoardView extends ViewMode { e.preventDefault(); $columnEl.removeClass("drag-over"); - if (this.draggedNote && this.draggedNoteElement && this.draggedBranch) { - const currentColumn = this.draggedNoteElement.attr("data-current-column"); + const draggedNoteElement = this.draggedNoteElement; + const draggedNote = this.draggedNote; + const draggedBranch = this.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"); @@ -264,32 +267,32 @@ export default class BoardView extends ViewMode { try { // Handle column change if (currentColumn !== column) { - await attributeService.setLabel(this.draggedNote.noteId, "status", column); + await attributeService.setLabel(draggedNote.noteId, "status", column); } // Handle position change (works for both same column and different column moves) if (targetBranchId && moveType) { if (moveType === "before") { - console.log("Move before branch:", this.draggedBranch.branchId, "to", targetBranchId); - await branchService.moveBeforeBranch([this.draggedBranch.branchId], targetBranchId); + console.log("Move before branch:", draggedBranch.branchId, "to", targetBranchId); + await branchService.moveBeforeBranch([draggedBranch.branchId], targetBranchId); } else if (moveType === "after") { - console.log("Move after branch:", this.draggedBranch.branchId, "to", targetBranchId); - await branchService.moveAfterBranch([this.draggedBranch.branchId], targetBranchId); + console.log("Move after branch:", draggedBranch.branchId, "to", targetBranchId); + await branchService.moveAfterBranch([draggedBranch.branchId], targetBranchId); } } // Update the UI if (dropIndicator.length > 0) { - dropIndicator.after(this.draggedNoteElement); + dropIndicator.after(draggedNoteElement); } else { - $columnEl.append(this.draggedNoteElement); + $columnEl.append(draggedNoteElement); } // Update the data attributes - this.draggedNoteElement.attr("data-current-column", column); + draggedNoteElement.attr("data-current-column", column); // Show success feedback - console.log(`Moved note "${this.draggedNote.title}" from "${currentColumn}" to "${column}"`); + console.log(`Moved note "${draggedNote.title}" from "${currentColumn}" to "${column}"`); } catch (error) { console.error("Failed to update note position:", error); // Optionally show user-facing error message From 944f0b694b5048c509f5ddb3f2fcbc08ff339379 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 19 Jul 2025 21:09:55 +0300 Subject: [PATCH 16/53] feat(views/board): open in popup --- apps/client/src/widgets/view_widgets/board_view/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 998e1ad7a..3e385548f 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -3,7 +3,7 @@ import ViewMode, { ViewModeArgs } from "../view_mode"; import { getBoardData } from "./data"; import attributeService from "../../../services/attributes"; import branchService from "../../../services/branches"; -import { EventData } from "../../../components/app_context"; +import appContext, { EventData } from "../../../components/app_context"; const TPL = /*html*/`
@@ -166,6 +166,7 @@ export default class BoardView extends ViewMode { .text(note.title); $noteEl.prepend($iconEl); + $noteEl.on("click", () => appContext.triggerCommand("openInPopup", { noteIdOrPath: note.noteId })); // Setup drag functionality for the note this.setupNoteDrag($noteEl, note, branch); From 657df7a7287ee6b1c940defeb02772648b8ec0f1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 19 Jul 2025 21:45:48 +0300 Subject: [PATCH 17/53] feat(views/board): add new item --- .../widgets/view_widgets/board_view/index.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) 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 3e385548f..9c2119f3f 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -3,6 +3,7 @@ import ViewMode, { ViewModeArgs } from "../view_mode"; import { getBoardData } from "./data"; import attributeService from "../../../services/attributes"; import branchService from "../../../services/branches"; +import noteCreateService from "../../../services/note_create"; import appContext, { EventData } from "../../../components/app_context"; const TPL = /*html*/` @@ -86,6 +87,28 @@ const TPL = /*html*/` .board-drop-indicator.show { opacity: 1; } + + .board-new-item { + margin-top: 0.5em; + padding: 0.5em; + border: 2px dashed var(--main-border-color); + border-radius: 5px; + text-align: center; + 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; + }
@@ -174,6 +197,18 @@ export default class BoardView extends ViewMode { $columnEl.append($noteEl); } + // Add "New item" link at the bottom of the column + const $newItemEl = $("
") + .addClass("board-new-item") + .attr("data-column", column) + .html('New item'); + + $newItemEl.on("click", () => { + this.createNewItem(column); + }); + + $columnEl.append($newItemEl); + $(el).append($columnEl); } } @@ -342,6 +377,31 @@ export default class BoardView extends ViewMode { $dropIndicator.addClass("show"); } + 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 + }); + + if (newNote) { + // Set the status label to place it in the correct column + await attributeService.setLabel(newNote.noteId, "status", column); + + // Refresh the board to show the new item + await this.renderList(); + + // Optionally, open the new note for editing + appContext.triggerCommand("openInPopup", { noteIdOrPath: newNote.noteId }); + } + } catch (error) { + console.error("Failed to create new item:", error); + } + } + async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) { // React to changes in "status" attribute for notes in this board if (loadResults.getAttributeRows().some(attr => attr.name === "status" && this.noteIds.includes(attr.noteId!))) { From 9e3372df72ae161e83d10f726946bdbdd0ee8790 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 19 Jul 2025 21:50:57 +0300 Subject: [PATCH 18/53] feat(views/board): react to changes in note title --- apps/client/src/widgets/view_widgets/board_view/index.ts | 5 +++++ 1 file changed, 5 insertions(+) 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 9c2119f3f..c5ae51c46 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -408,6 +408,11 @@ export default class BoardView extends ViewMode { return true; } + // React to changes in note title. + if (loadResults.getNoteIds().some(noteId => this.noteIds.includes(noteId))) { + return true; + } + // React to changes in branches for subchildren (e.g., moved, added, or removed notes) if (loadResults.getBranchRows().some(branch => this.noteIds.includes(branch.noteId!))) { return true; From b1b756b179b0c71b4f7bf361361d1dd21baa1151 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 19 Jul 2025 22:21:24 +0300 Subject: [PATCH 19/53] feat(views/board): store new columns into config --- .../widgets/view_widgets/board_view/config.ts | 7 +++++ .../widgets/view_widgets/board_view/data.ts | 28 +++++++++++++++++-- .../widgets/view_widgets/board_view/index.ts | 25 +++++++++++++---- 3 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 apps/client/src/widgets/view_widgets/board_view/config.ts diff --git a/apps/client/src/widgets/view_widgets/board_view/config.ts b/apps/client/src/widgets/view_widgets/board_view/config.ts new file mode 100644 index 000000000..def136da7 --- /dev/null +++ b/apps/client/src/widgets/view_widgets/board_view/config.ts @@ -0,0 +1,7 @@ +interface BoardColumnData { + value: string; +} + +export interface BoardData { + columns?: BoardColumnData[]; +} diff --git a/apps/client/src/widgets/view_widgets/board_view/data.ts b/apps/client/src/widgets/view_widgets/board_view/data.ts index bd624e1f7..7ace172d1 100644 --- a/apps/client/src/widgets/view_widgets/board_view/data.ts +++ b/apps/client/src/widgets/view_widgets/board_view/data.ts @@ -1,18 +1,42 @@ import FBranch from "../../../entities/fbranch"; import FNote from "../../../entities/fnote"; +import { BoardData } from "./config"; type ColumnMap = Map; -export async function getBoardData(parentNote: FNote, groupByColumn: string) { +export async function getBoardData(parentNote: FNote, groupByColumn: string, persistedData: BoardData) { const byColumn: ColumnMap = new Map(); await recursiveGroupBy(parentNote.getChildBranches(), byColumn, groupByColumn); + let newPersistedData: BoardData | undefined; + if (persistedData) { + // Check if we have new columns. + const existingColumns = persistedData.columns?.map(c => c.value) || []; + for (const column of existingColumns) { + if (!byColumn.has(column)) { + byColumn.set(column, []); + } + } + + const newColumns = [...byColumn.keys()] + .filter(column => !existingColumns.includes(column)) + .map(column => ({ value: column })); + + if (newColumns.length > 0) { + newPersistedData = { + ...persistedData, + columns: [...(persistedData.columns || []), ...newColumns] + }; + } + } + return { - byColumn + byColumn, + newPersistedData }; } 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 c5ae51c46..b85368c0b 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -5,6 +5,8 @@ import attributeService from "../../../services/attributes"; import branchService from "../../../services/branches"; import noteCreateService from "../../../services/note_create"; import appContext, { EventData } from "../../../components/app_context"; +import { BoardData } from "./config"; +import SpacedUpdate from "../../../services/spaced_update"; const TPL = /*html*/`
@@ -115,17 +117,15 @@ const TPL = /*html*/`
`; -export interface StateInfo { - -}; - -export default class BoardView extends ViewMode { +export default class BoardView extends ViewMode { private $root: JQuery; private $container: JQuery; + private spacedUpdate: SpacedUpdate; private draggedNote: any = null; private draggedBranch: any = null; private draggedNoteElement: JQuery | null = null; + private persistentData: BoardData; constructor(args: ViewModeArgs) { super(args, "board"); @@ -133,6 +133,10 @@ export default class BoardView extends ViewMode { this.$root = $(TPL); setupHorizontalScrollViaWheel(this.$root); this.$container = this.$root.find(".board-view-container"); + this.spacedUpdate = new SpacedUpdate(() => this.onSave(), 5_000); + this.persistentData = { + columns: [] + }; args.$parent.append(this.$root); } @@ -145,7 +149,12 @@ export default class BoardView extends ViewMode { } private async renderBoard(el: HTMLElement) { - const data = await getBoardData(this.parentNote, "status"); + const data = await getBoardData(this.parentNote, "status", this.persistentData); + + if (data.newPersistedData) { + this.persistentData = data.newPersistedData; + this.viewStorage.store(this.persistentData); + } for (const column of data.byColumn.keys()) { const columnItems = data.byColumn.get(column); @@ -421,4 +430,8 @@ export default class BoardView extends ViewMode { return false; } + private onSave() { + this.viewStorage.store(this.persistentData); + } + } From af797489e83aa15541689c6c2a3589a9b9220bf6 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 20 Jul 2025 10:30:48 +0300 Subject: [PATCH 20/53] feat(views/board): set up template --- .../src/assets/translations/en/server.json | 4 ++- .../src/services/hidden_subtree_templates.ts | 29 ++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/server/src/assets/translations/en/server.json b/apps/server/src/assets/translations/en/server.json index 97920deca..7a097b92c 100644 --- a/apps/server/src/assets/translations/en/server.json +++ b/apps/server/src/assets/translations/en/server.json @@ -317,6 +317,8 @@ "start-time": "Start Time", "end-time": "End Time", "geolocation": "Geolocation", - "built-in-templates": "Built-in templates" + "built-in-templates": "Built-in templates", + "board": "Board", + "status": "Status" } } diff --git a/apps/server/src/services/hidden_subtree_templates.ts b/apps/server/src/services/hidden_subtree_templates.ts index 6cc35fff9..06c7dd612 100644 --- a/apps/server/src/services/hidden_subtree_templates.ts +++ b/apps/server/src/services/hidden_subtree_templates.ts @@ -170,7 +170,34 @@ export default function buildHiddenSubtreeTemplates() { isInheritable: true } ] - } + }, + { + id: "_template_board", + type: "book", + title: t("hidden_subtree_templates.board"), + icon: "bx bx-columns", + attributes: [ + { + name: "template", + type: "label" + }, + { + name: "collection", + type: "label" + }, + { + name: "viewType", + type: "label", + value: "board" + }, + { + name: "label:status", + type: "label", + value: `promoted,alias=${t("hidden_subtree_templates.status")},single,text`, + isInheritable: true + } + ] + }, ] }; From b7b0b39afc07686ff8bbe5676df271a09ba7c82f Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 20 Jul 2025 10:36:36 +0300 Subject: [PATCH 21/53] feat(views/board): add preset notes --- .../src/assets/translations/en/server.json | 8 ++++- .../src/services/hidden_subtree_templates.ts | 32 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/server/src/assets/translations/en/server.json b/apps/server/src/assets/translations/en/server.json index 7a097b92c..3f1553d6b 100644 --- a/apps/server/src/assets/translations/en/server.json +++ b/apps/server/src/assets/translations/en/server.json @@ -319,6 +319,12 @@ "geolocation": "Geolocation", "built-in-templates": "Built-in templates", "board": "Board", - "status": "Status" + "status": "Status", + "board_note_first": "First note", + "board_note_second": "Second note", + "board_note_third": "Third note", + "board_status_todo": "To Do", + "board_status_progress": "In Progress", + "board_status_done": "Done" } } diff --git a/apps/server/src/services/hidden_subtree_templates.ts b/apps/server/src/services/hidden_subtree_templates.ts index 06c7dd612..105ddeb5f 100644 --- a/apps/server/src/services/hidden_subtree_templates.ts +++ b/apps/server/src/services/hidden_subtree_templates.ts @@ -196,6 +196,38 @@ export default function buildHiddenSubtreeTemplates() { value: `promoted,alias=${t("hidden_subtree_templates.status")},single,text`, isInheritable: true } + ], + children: [ + { + id: "_template_board_first", + title: t("hidden_subtree_templates.board_note_first"), + attributes: [{ + name: "status", + value: t("hidden_subtree_templates.board_status_todo"), + type: "label" + }], + type: "text" + }, + { + id: "_template_board_second", + title: t("hidden_subtree_templates.board_note_second"), + attributes: [{ + name: "status", + value: t("hidden_subtree_templates.board_status_progress"), + type: "label" + }], + type: "text" + }, + { + id: "_template_board_third", + title: t("hidden_subtree_templates.board_note_third"), + attributes: [{ + name: "status", + value: t("hidden_subtree_templates.board_status_done"), + type: "label" + }], + type: "text" + } ] }, ] From e1a8f4f5db6c101ffeae32cf66b6ca8ced162c6c Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 20 Jul 2025 10:50:13 +0300 Subject: [PATCH 22/53] chore(views/board): hide promoted attributes of collection --- apps/server/src/services/hidden_subtree_templates.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/server/src/services/hidden_subtree_templates.ts b/apps/server/src/services/hidden_subtree_templates.ts index 105ddeb5f..11ed6c66b 100644 --- a/apps/server/src/services/hidden_subtree_templates.ts +++ b/apps/server/src/services/hidden_subtree_templates.ts @@ -190,6 +190,10 @@ export default function buildHiddenSubtreeTemplates() { type: "label", value: "board" }, + { + name: "hidePromotedAttributes", + type: "label" + }, { name: "label:status", type: "label", From 37c9260dcab7d3adc598c6d1cee9c114da8a3841 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 20 Jul 2025 10:50:26 +0300 Subject: [PATCH 23/53] feat(views/board): keep empty columns --- .../widgets/view_widgets/board_view/data.ts | 35 ++++++++++--------- .../widgets/view_widgets/board_view/index.ts | 5 ++- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/apps/client/src/widgets/view_widgets/board_view/data.ts b/apps/client/src/widgets/view_widgets/board_view/data.ts index 7ace172d1..bb3095e89 100644 --- a/apps/client/src/widgets/view_widgets/board_view/data.ts +++ b/apps/client/src/widgets/view_widgets/board_view/data.ts @@ -10,28 +10,31 @@ type ColumnMap = Map c.value) || []; - for (const column of existingColumns) { - if (!byColumn.has(column)) { - byColumn.set(column, []); - } + // Check if we have new columns. + const existingColumns = persistedData.columns?.map(c => c.value) || []; + for (const column of existingColumns) { + if (!byColumn.has(column)) { + byColumn.set(column, []); } + } - const newColumns = [...byColumn.keys()] - .filter(column => !existingColumns.includes(column)) - .map(column => ({ value: column })); + const newColumns = [...byColumn.keys()] + .filter(column => !existingColumns.includes(column)) + .map(column => ({ value: column })); - if (newColumns.length > 0) { - newPersistedData = { - ...persistedData, - columns: [...(persistedData.columns || []), ...newColumns] - }; - } + if (newColumns.length > 0) { + newPersistedData = { + ...persistedData, + columns: [...(persistedData.columns || []), ...newColumns] + }; } 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 b85368c0b..21f2c2105 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -149,7 +149,10 @@ export default class BoardView extends ViewMode { } private async renderBoard(el: HTMLElement) { - const data = await getBoardData(this.parentNote, "status", this.persistentData); + const persistedData = await this.viewStorage.restore() ?? this.persistentData; + this.persistentData = persistedData; + + const data = await getBoardData(this.parentNote, "status", persistedData); if (data.newPersistedData) { this.persistentData = data.newPersistedData; From e51ea1a619b3a51f112a16dbc5e61331ce1bd530 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 20 Jul 2025 12:40:30 +0300 Subject: [PATCH 24/53] feat(views/board): add context menu with delete --- .../src/translations/en/translation.json | 3 ++ .../view_widgets/board_view/context_menu.ts | 28 +++++++++++++++++++ .../widgets/view_widgets/board_view/index.ts | 2 ++ 3 files changed, 33 insertions(+) create mode 100644 apps/client/src/widgets/view_widgets/board_view/context_menu.ts diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 35e835e30..f3693e853 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1968,5 +1968,8 @@ }, "table_context_menu": { "delete_row": "Delete row" + }, + "board_view": { + "delete-note": "Delete Note" } } 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 new file mode 100644 index 000000000..6ef692f5b --- /dev/null +++ b/apps/client/src/widgets/view_widgets/board_view/context_menu.ts @@ -0,0 +1,28 @@ +import contextMenu from "../../../menus/context_menu.js"; +import branches from "../../../services/branches.js"; +import { t } from "../../../services/i18n.js"; + +export function showNoteContextMenu($container: JQuery) { + $container.on("contextmenu", ".board-note", (event) => { + event.preventDefault(); + event.stopPropagation(); + + const $el = $(event.currentTarget); + const noteId = $el.data("note-id"); + const branchId = $el.data("branch-id"); + if (!noteId) return; + + contextMenu.show({ + x: event.pageX, + y: event.pageY, + items: [ + { + title: t("board_view.delete-note"), + uiIcon: "bx bx-trash", + handler: () => branches.deleteNotes([ branchId ], false, false) + } + ], + selectMenuItemHandler: () => {} + }); + }); +} 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 21f2c2105..f399dca03 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -7,6 +7,7 @@ import noteCreateService from "../../../services/note_create"; import appContext, { EventData } from "../../../components/app_context"; import { BoardData } from "./config"; import SpacedUpdate from "../../../services/spaced_update"; +import { showNoteContextMenu } from "./context_menu"; const TPL = /*html*/`
@@ -133,6 +134,7 @@ export default class BoardView extends ViewMode { this.$root = $(TPL); setupHorizontalScrollViaWheel(this.$root); this.$container = this.$root.find(".board-view-container"); + showNoteContextMenu(this.$container); this.spacedUpdate = new SpacedUpdate(() => this.onSave(), 5_000); this.persistentData = { columns: [] From a594e5147c9ec9e9c67ccb0476f74a6570cf34ad Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 20 Jul 2025 12:42:19 +0300 Subject: [PATCH 25/53] feat(views/board): set up open in context menu --- .../src/widgets/view_widgets/board_view/context_menu.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 6ef692f5b..c6bd99ddb 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 @@ -1,4 +1,5 @@ import contextMenu from "../../../menus/context_menu.js"; +import link_context_menu from "../../../menus/link_context_menu.js"; import branches from "../../../services/branches.js"; import { t } from "../../../services/i18n.js"; @@ -16,13 +17,15 @@ export function showNoteContextMenu($container: JQuery) { x: event.pageX, y: event.pageY, items: [ + ...link_context_menu.getItems(), + { title: "----" }, { title: t("board_view.delete-note"), uiIcon: "bx bx-trash", handler: () => branches.deleteNotes([ branchId ], false, false) } ], - selectMenuItemHandler: () => {} + selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, noteId), }); }); } From 1763d80d5f763a716031a6f68e62869059eb6bfd Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 20 Jul 2025 13:24:22 +0300 Subject: [PATCH 26/53] feat(views/board): add move to in context menu --- .../client/src/translations/en/translation.json | 3 ++- .../src/widgets/view_widgets/board_view/api.ts | 12 ++++++++++++ .../widgets/view_widgets/board_view/config.ts | 2 +- .../view_widgets/board_view/context_menu.ts | 17 ++++++++++++++++- .../widgets/view_widgets/board_view/index.ts | 11 +++++++++-- 5 files changed, 40 insertions(+), 5 deletions(-) create mode 100644 apps/client/src/widgets/view_widgets/board_view/api.ts diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index f3693e853..d436cc078 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1970,6 +1970,7 @@ "delete_row": "Delete row" }, "board_view": { - "delete-note": "Delete Note" + "delete-note": "Delete Note", + "move-to": "Move to" } } diff --git a/apps/client/src/widgets/view_widgets/board_view/api.ts b/apps/client/src/widgets/view_widgets/board_view/api.ts new file mode 100644 index 000000000..af87668ae --- /dev/null +++ b/apps/client/src/widgets/view_widgets/board_view/api.ts @@ -0,0 +1,12 @@ +import attributes from "../../../services/attributes"; + +export default class BoardApi { + + constructor(public columns: string[]) { + } + + async changeColumn(noteId: string, newColumn: string) { + await attributes.setLabel(noteId, "status", newColumn); + } + +} diff --git a/apps/client/src/widgets/view_widgets/board_view/config.ts b/apps/client/src/widgets/view_widgets/board_view/config.ts index def136da7..92dd99f5f 100644 --- a/apps/client/src/widgets/view_widgets/board_view/config.ts +++ b/apps/client/src/widgets/view_widgets/board_view/config.ts @@ -1,4 +1,4 @@ -interface BoardColumnData { +export interface BoardColumnData { value: string; } 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 c6bd99ddb..03d8099dd 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 @@ -2,8 +2,14 @@ import contextMenu from "../../../menus/context_menu.js"; import link_context_menu from "../../../menus/link_context_menu.js"; import branches from "../../../services/branches.js"; import { t } from "../../../services/i18n.js"; +import BoardApi from "./api.js"; -export function showNoteContextMenu($container: JQuery) { +interface ShowNoteContextMenuArgs { + $container: JQuery; + api: BoardApi; +} + +export function showNoteContextMenu({ $container, api }: ShowNoteContextMenuArgs) { $container.on("contextmenu", ".board-note", (event) => { event.preventDefault(); event.stopPropagation(); @@ -19,6 +25,15 @@ export function showNoteContextMenu($container: JQuery) { items: [ ...link_context_menu.getItems(), { title: "----" }, + { + title: t("board_view.move-to"), + uiIcon: "bx bx-transfer", + items: api.columns.map(column => ({ + title: column, + handler: () => api.changeColumn(noteId, column) + })) + }, + { title: "----" }, { title: t("board_view.delete-note"), uiIcon: "bx bx-trash", 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 f399dca03..d1f8a8108 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -8,6 +8,7 @@ import appContext, { EventData } from "../../../components/app_context"; import { BoardData } from "./config"; import SpacedUpdate from "../../../services/spaced_update"; import { showNoteContextMenu } from "./context_menu"; +import BoardApi from "./api"; const TPL = /*html*/`
@@ -127,6 +128,7 @@ export default class BoardView extends ViewMode { private draggedBranch: any = null; private draggedNoteElement: JQuery | null = null; private persistentData: BoardData; + private api?: BoardApi; constructor(args: ViewModeArgs) { super(args, "board"); @@ -134,7 +136,6 @@ export default class BoardView extends ViewMode { this.$root = $(TPL); setupHorizontalScrollViaWheel(this.$root); this.$container = this.$root.find(".board-view-container"); - showNoteContextMenu(this.$container); this.spacedUpdate = new SpacedUpdate(() => this.onSave(), 5_000); this.persistentData = { columns: [] @@ -155,6 +156,12 @@ export default class BoardView extends ViewMode { this.persistentData = persistedData; const data = await getBoardData(this.parentNote, "status", persistedData); + const columns = Array.from(data.byColumn.keys()) || []; + this.api = new BoardApi(columns); + showNoteContextMenu({ + $container: this.$container, + api: this.api + }); if (data.newPersistedData) { this.persistentData = data.newPersistedData; @@ -317,7 +324,7 @@ export default class BoardView extends ViewMode { try { // Handle column change if (currentColumn !== column) { - await attributeService.setLabel(draggedNote.noteId, "status", column); + await this.api?.changeColumn(draggedNote.noteId, column); } // Handle position change (works for both same column and different column moves) From 26ee0ff48fdd08de982b85c555b86cd1edc5cbd4 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 20 Jul 2025 17:35:52 +0300 Subject: [PATCH 27/53] feat(views/board): insert above/below --- .../src/translations/en/translation.json | 4 ++- .../widgets/view_widgets/board_view/api.ts | 35 ++++++++++++++++++- .../view_widgets/board_view/context_menu.ts | 10 ++++++ .../widgets/view_widgets/board_view/index.ts | 5 ++- 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index d436cc078..6a9f5a413 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1971,6 +1971,8 @@ }, "board_view": { "delete-note": "Delete Note", - "move-to": "Move to" + "move-to": "Move to", + "insert-above": "Insert above", + "insert-below": "Insert below" } } 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 af87668ae..436e88152 100644 --- a/apps/client/src/widgets/view_widgets/board_view/api.ts +++ b/apps/client/src/widgets/view_widgets/board_view/api.ts @@ -1,12 +1,45 @@ +import appContext from "../../../components/app_context"; import attributes from "../../../services/attributes"; +import note_create from "../../../services/note_create"; export default class BoardApi { - constructor(public columns: string[]) { + constructor( + private _columns: string[], + private _parentNoteId: string) {} + + get columns() { + return this._columns; } async changeColumn(noteId: string, newColumn: string) { await attributes.setLabel(noteId, "status", newColumn); } + 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 + }); + + if (!note) { + throw new Error("Failed to create note"); + } + + const { noteId } = note; + await this.changeColumn(noteId, column); + if (open) { + this.openNote(noteId); + } + } + } 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 03d8099dd..fb7291922 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 @@ -17,6 +17,7 @@ export function showNoteContextMenu({ $container, api }: ShowNoteContextMenuArgs 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({ @@ -34,6 +35,15 @@ export function showNoteContextMenu({ $container, api }: ShowNoteContextMenuArgs })) }, { title: "----" }, + { + title: t("board_view.insert-above"), + handler: () => api.insertRowAtPosition(column, branchId, "before") + }, + { + title: t("board_view.insert-below"), + handler: () => api.insertRowAtPosition(column, branchId, "after") + }, + { title: "----" }, { title: t("board_view.delete-note"), uiIcon: "bx bx-trash", 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 d1f8a8108..7ca6d9738 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -157,7 +157,10 @@ export default class BoardView extends ViewMode { const data = await getBoardData(this.parentNote, "status", persistedData); const columns = Array.from(data.byColumn.keys()) || []; - this.api = new BoardApi(columns); + this.api = new BoardApi( + columns, + this.parentNote.noteId + ); showNoteContextMenu({ $container: this.$container, api: this.api From 4146192b6db7e67936714c34404f9cd99576a815 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 20 Jul 2025 17:53:47 +0300 Subject: [PATCH 28/53] chore(views/board): add icon to menu item --- apps/client/src/widgets/view_widgets/board_view/context_menu.ts | 2 ++ 1 file changed, 2 insertions(+) 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 fb7291922..de19d6a42 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 @@ -37,10 +37,12 @@ export function showNoteContextMenu({ $container, api }: ShowNoteContextMenuArgs { title: "----" }, { title: t("board_view.insert-above"), + uiIcon: "bx bx-list-plus", handler: () => api.insertRowAtPosition(column, branchId, "before") }, { title: t("board_view.insert-below"), + uiIcon: "bx bx-empty", handler: () => api.insertRowAtPosition(column, branchId, "after") }, { title: "----" }, From d60b855f745f233a53c16183eb15e223b8f11131 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 20 Jul 2025 17:56:37 +0300 Subject: [PATCH 29/53] chore(views/board): disable move to for the current column --- .../src/widgets/view_widgets/board_view/context_menu.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 de19d6a42..a6044c8c7 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 @@ -29,9 +29,10 @@ export function showNoteContextMenu({ $container, api }: ShowNoteContextMenuArgs { title: t("board_view.move-to"), uiIcon: "bx bx-transfer", - items: api.columns.map(column => ({ - title: column, - handler: () => api.changeColumn(noteId, column) + items: api.columns.map(columnToMoveTo => ({ + title: columnToMoveTo, + enabled: columnToMoveTo !== column, + handler: () => api.changeColumn(noteId, columnToMoveTo) })) }, { title: "----" }, From 3e5c91415db9de3cdf97b87f254d8b2a79d55941 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 20 Jul 2025 18:17:53 +0300 Subject: [PATCH 30/53] feat(views/board): rename columns --- .../widgets/view_widgets/board_view/api.ts | 7 + .../widgets/view_widgets/board_view/index.ts | 146 +++++++++++++++++- 2 files changed, 151 insertions(+), 2 deletions(-) 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 436e88152..bccf0201b 100644 --- a/apps/client/src/widgets/view_widgets/board_view/api.ts +++ b/apps/client/src/widgets/view_widgets/board_view/api.ts @@ -42,4 +42,11 @@ export default class BoardApi { } } + async renameColumn(oldValue: string, newValue: string, noteIds: string[]) { + // Update all notes that have the old status value to the new value + for (const noteId of noteIds) { + await attributes.setLabel(noteId, "status", newValue); + } + } + } 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 7ca6d9738..b772e2082 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -49,6 +49,54 @@ const TPL = /*html*/` margin-bottom: 0.75em; padding-bottom: 0.5em; border-bottom: 1px solid var(--main-border-color); + cursor: pointer; + position: relative; + transition: background-color 0.2s ease; + display: flex; + align-items: center; + justify-content: space-between; + } + + .board-view-container .board-column h3:hover { + background-color: var(--hover-item-background-color); + border-radius: 4px; + padding: 0.25em 0.5em; + margin: -0.25em -0.5em 0.75em -0.5em; + } + + .board-view-container .board-column h3.editing { + background-color: var(--main-background-color); + border: 1px solid var(--main-text-color); + border-radius: 4px; + padding: 0.25em 0.5em; + margin: -0.25em -0.5em 0.75em -0.5em; + } + + .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; + font-size: 0.8em; + 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 { @@ -177,10 +225,23 @@ export default class BoardView extends ViewMode { continue; } + // Find the column data to get custom title + const columnTitle = column; + const $columnEl = $("
") .addClass("board-column") - .attr("data-column", column) - .append($("

").text(column)); + .attr("data-column", column); + + const $titleEl = $("

") + .attr("data-column-value", column); + + const { $titleText, $editIcon } = this.createTitleStructure(columnTitle); + $titleEl.append($titleText, $editIcon); + + // Make column title editable + this.setupColumnTitleEdit($titleEl, column, columnItems); + + $columnEl.append($titleEl); // Allow vertical scrolling in the column, bypassing the horizontal scroll of the container. $columnEl.on("wheel", (event) => { @@ -401,6 +462,87 @@ export default class BoardView extends ViewMode { $dropIndicator.addClass("show"); } + 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 setupColumnTitleEdit($titleEl: JQuery, columnValue: string, columnItems: { branch: any; note: any; }[]) { + $titleEl.on("click", (e) => { + e.stopPropagation(); + this.startEditingColumnTitle($titleEl, columnValue, columnItems); + }); + } + + private startEditingColumnTitle($titleEl: JQuery, columnValue: string, columnItems: { branch: any; note: any; }[]) { + if ($titleEl.hasClass("editing")) { + return; // Already editing + } + + const $titleText = $titleEl.find("span").first(); + const currentTitle = $titleText.text(); + $titleEl.addClass("editing"); + + const $input = $("") + .attr("type", "text") + .val(currentTitle) + .attr("placeholder", "Column title"); + + $titleEl.empty().append($input); + $input.focus().select(); + + const finishEdit = async (save: boolean = true) => { + if (!$titleEl.hasClass("editing")) { + return; // Already finished + } + + $titleEl.removeClass("editing"); + + 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) + await this.api?.renameColumn(oldValue, newValue, noteIds); + + // Refresh the board to reflect the changes + await this.renderList(); + } catch (error) { + console.error("Failed to rename column:", error); + } + } + private async createNewItem(column: string) { try { // Get the parent note path From 977fbf54ee5fe31029abbe8b2fd865f6710b0a71 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 20 Jul 2025 19:06:33 +0300 Subject: [PATCH 31/53] refactor(views/board): delegate storage to API --- .../widgets/view_widgets/board_view/api.ts | 27 +++++++++++++++++-- .../widgets/view_widgets/board_view/data.ts | 2 +- .../widgets/view_widgets/board_view/index.ts | 19 +++---------- 3 files changed, 29 insertions(+), 19 deletions(-) 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 bccf0201b..b2e2009b3 100644 --- a/apps/client/src/widgets/view_widgets/board_view/api.ts +++ b/apps/client/src/widgets/view_widgets/board_view/api.ts @@ -1,17 +1,27 @@ import appContext from "../../../components/app_context"; +import FNote from "../../../entities/fnote"; import attributes from "../../../services/attributes"; 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 { - constructor( + private constructor( private _columns: string[], - private _parentNoteId: string) {} + private _parentNoteId: string, + private viewStorage: ViewModeStorage, + private byColumn: ColumnMap) {} get columns() { return this._columns; } + getColumn(column: string) { + return this.byColumn.get(column); + } + async changeColumn(noteId: string, newColumn: string) { await attributes.setLabel(noteId, "status", newColumn); } @@ -49,4 +59,17 @@ export default class BoardApi { } } + static async build(parentNote: FNote, viewStorage: ViewModeStorage) { + let persistedData = await viewStorage.restore() ?? {}; + const { byColumn, newPersistedData } = await getBoardData(parentNote, "status", persistedData); + const columns = Array.from(byColumn.keys()) || []; + + if (newPersistedData) { + persistedData = newPersistedData; + viewStorage.store(persistedData); + } + + return new BoardApi(columns, parentNote.noteId, viewStorage, byColumn); + } + } diff --git a/apps/client/src/widgets/view_widgets/board_view/data.ts b/apps/client/src/widgets/view_widgets/board_view/data.ts index bb3095e89..828181896 100644 --- a/apps/client/src/widgets/view_widgets/board_view/data.ts +++ b/apps/client/src/widgets/view_widgets/board_view/data.ts @@ -2,7 +2,7 @@ import FBranch from "../../../entities/fbranch"; import FNote from "../../../entities/fnote"; import { BoardData } from "./config"; -type ColumnMap = Map; 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 b772e2082..28ef1dc28 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -200,27 +200,14 @@ export default class BoardView extends ViewMode { } private async renderBoard(el: HTMLElement) { - const persistedData = await this.viewStorage.restore() ?? this.persistentData; - this.persistentData = persistedData; - - const data = await getBoardData(this.parentNote, "status", persistedData); - const columns = Array.from(data.byColumn.keys()) || []; - this.api = new BoardApi( - columns, - this.parentNote.noteId - ); + this.api = await BoardApi.build(this.parentNote, this.viewStorage); showNoteContextMenu({ $container: this.$container, api: this.api }); - if (data.newPersistedData) { - this.persistentData = data.newPersistedData; - this.viewStorage.store(this.persistentData); - } - - for (const column of data.byColumn.keys()) { - const columnItems = data.byColumn.get(column); + for (const column of this.api.columns) { + const columnItems = this.api.getColumn(column); if (!columnItems) { continue; } From e8fd2c1b3ce465cb83669ebc9975e3cf5b52bdc1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 20 Jul 2025 19:12:44 +0300 Subject: [PATCH 32/53] fix(views/board): old column not removed when changing it --- .../src/widgets/view_widgets/board_view/api.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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 b2e2009b3..39c7bf246 100644 --- a/apps/client/src/widgets/view_widgets/board_view/api.ts +++ b/apps/client/src/widgets/view_widgets/board_view/api.ts @@ -12,7 +12,8 @@ export default class BoardApi { private _columns: string[], private _parentNoteId: string, private viewStorage: ViewModeStorage, - private byColumn: ColumnMap) {} + private byColumn: ColumnMap, + private persistedData: BoardData) {} get columns() { return this._columns; @@ -53,6 +54,14 @@ export default class BoardApi { } async renameColumn(oldValue: string, newValue: string, noteIds: string[]) { + // Rename the column in the persisted data. + for (const column of this.persistedData.columns || []) { + if (column.value === oldValue) { + column.value = newValue; + } + } + this.viewStorage.store(this.persistedData); + // Update all notes that have the old status value to the new value for (const noteId of noteIds) { await attributes.setLabel(noteId, "status", newValue); @@ -69,7 +78,7 @@ export default class BoardApi { viewStorage.store(persistedData); } - return new BoardApi(columns, parentNote.noteId, viewStorage, byColumn); + return new BoardApi(columns, parentNote.noteId, viewStorage, byColumn, persistedData); } } From 9e936cb57b600d3cac14d33c74f5e63cc09c672b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 20 Jul 2025 19:33:56 +0300 Subject: [PATCH 33/53] feat(views/board): delete empty columns --- .../src/translations/en/translation.json | 4 +- .../widgets/view_widgets/board_view/api.ts | 5 +++ .../view_widgets/board_view/context_menu.ts | 40 +++++++++++++++++-- .../widgets/view_widgets/board_view/index.ts | 10 +++-- 4 files changed, 51 insertions(+), 8 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 6a9f5a413..7bb080778 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1973,6 +1973,8 @@ "delete-note": "Delete Note", "move-to": "Move to", "insert-above": "Insert above", - "insert-below": "Insert below" + "insert-below": "Insert below", + "delete-column": "Delete column", + "delete-column-confirmation": "Are you sure you want to delete this column? All notes in this column will be deleted as well." } } 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 39c7bf246..aa2e50802 100644 --- a/apps/client/src/widgets/view_widgets/board_view/api.ts +++ b/apps/client/src/widgets/view_widgets/board_view/api.ts @@ -68,6 +68,11 @@ export default class BoardApi { } } + async removeColumn(column: string) { + this.persistedData.columns = (this.persistedData.columns ?? []).filter(col => col.value !== column); + this.viewStorage.store(this.persistedData); + } + static async build(parentNote: FNote, viewStorage: ViewModeStorage) { let persistedData = await viewStorage.restore() ?? {}; const { byColumn, newPersistedData } = await getBoardData(parentNote, "status", persistedData); 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 a6044c8c7..9ee9909e0 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 @@ -1,6 +1,7 @@ -import contextMenu from "../../../menus/context_menu.js"; +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"; @@ -9,8 +10,39 @@ interface ShowNoteContextMenuArgs { api: BoardApi; } -export function showNoteContextMenu({ $container, api }: ShowNoteContextMenuArgs) { - $container.on("contextmenu", ".board-note", (event) => { +export function setupContextMenu({ $container, api }: ShowNoteContextMenuArgs) { + $container.on("contextmenu", ".board-note", showNoteContextMenu); + $container.on("contextmenu", ".board-column", 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) { event.preventDefault(); event.stopPropagation(); @@ -55,5 +87,5 @@ export function showNoteContextMenu({ $container, api }: ShowNoteContextMenuArgs ], selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, noteId), }); - }); + } } 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 28ef1dc28..6505d11cf 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -1,13 +1,12 @@ import { setupHorizontalScrollViaWheel } from "../../widget_utils"; import ViewMode, { ViewModeArgs } from "../view_mode"; -import { getBoardData } from "./data"; import attributeService from "../../../services/attributes"; import branchService from "../../../services/branches"; import noteCreateService from "../../../services/note_create"; import appContext, { EventData } from "../../../components/app_context"; import { BoardData } from "./config"; import SpacedUpdate from "../../../services/spaced_update"; -import { showNoteContextMenu } from "./context_menu"; +import { setupContextMenu } from "./context_menu"; import BoardApi from "./api"; const TPL = /*html*/` @@ -201,7 +200,7 @@ export default class BoardView extends ViewMode { private async renderBoard(el: HTMLElement) { this.api = await BoardApi.build(this.parentNote, this.viewStorage); - showNoteContextMenu({ + setupContextMenu({ $container: this.$container, api: this.api }); @@ -571,6 +570,11 @@ export default class BoardView extends ViewMode { return true; } + // React to attachment change. + if (loadResults.getAttachmentRows().some(att => att.ownerId === this.parentNote.noteId && att.title === "board.json")) { + return true; + } + return false; } From 2b5029cc38fa1bfddcc032e2dbbf9e4759d2acba Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 20 Jul 2025 19:51:03 +0300 Subject: [PATCH 34/53] chore(views/board): delete values when deleting column --- apps/client/src/services/bulk_action.ts | 6 +++--- apps/client/src/translations/en/translation.json | 2 +- .../src/widgets/view_widgets/board_view/api.ts | 10 ++++++++++ .../view_widgets/table_view/bulk_actions.ts | 16 ++++++++-------- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/apps/client/src/services/bulk_action.ts b/apps/client/src/services/bulk_action.ts index 57b880c36..d894ee17e 100644 --- a/apps/client/src/services/bulk_action.ts +++ b/apps/client/src/services/bulk_action.ts @@ -91,10 +91,10 @@ function parseActions(note: FNote) { .filter((action) => !!action); } -export async function executeBulkActions(parentNoteId: string, actions: BulkAction[]) { +export async function executeBulkActions(targetNoteIds: string[], actions: BulkAction[], includeDescendants = false) { await server.post("bulk-action/execute", { - noteIds: [ parentNoteId ], - includeDescendants: true, + noteIds: targetNoteIds, + includeDescendants, actions }); diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 7bb080778..1ef41d3f0 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1975,6 +1975,6 @@ "insert-above": "Insert above", "insert-below": "Insert below", "delete-column": "Delete column", - "delete-column-confirmation": "Are you sure you want to delete this column? All notes in this column will be deleted as well." + "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." } } 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 aa2e50802..7a706f2e6 100644 --- a/apps/client/src/widgets/view_widgets/board_view/api.ts +++ b/apps/client/src/widgets/view_widgets/board_view/api.ts @@ -1,6 +1,7 @@ import appContext from "../../../components/app_context"; import FNote from "../../../entities/fnote"; import attributes from "../../../services/attributes"; +import bulk_action, { executeBulkActions } from "../../../services/bulk_action"; import note_create from "../../../services/note_create"; import ViewModeStorage from "../view_mode_storage"; import { BoardData } from "./config"; @@ -69,6 +70,15 @@ export default class BoardApi { } 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: "status" + } + ]); + this.persistedData.columns = (this.persistedData.columns ?? []).filter(col => col.value !== column); this.viewStorage.store(this.persistedData); } 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 index b0f590c16..010bd1c48 100644 --- a/apps/client/src/widgets/view_widgets/table_view/bulk_actions.ts +++ b/apps/client/src/widgets/view_widgets/table_view/bulk_actions.ts @@ -2,30 +2,30 @@ 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, [{ + return executeBulkActions([parentNoteId], [{ name: "renameLabel", oldLabelName: originalName, newLabelName: newName - }]); + }], true); } else { - return executeBulkActions(parentNoteId, [{ + 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, [{ + return executeBulkActions([parentNoteId], [{ name: "deleteLabel", labelName: columnName - }]); + }], true); } else { - return executeBulkActions(parentNoteId, [{ + return executeBulkActions([parentNoteId], [{ name: "deleteRelation", relationName: columnName - }]); + }], true); } } From b22e08b1eba6b3e298da18665a0cb19f77f46fa1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 20 Jul 2025 19:59:21 +0300 Subject: [PATCH 35/53] refactor(views/board): use bulk API for renaming columns --- .../src/widgets/view_widgets/board_view/api.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) 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 7a706f2e6..ffec13960 100644 --- a/apps/client/src/widgets/view_widgets/board_view/api.ts +++ b/apps/client/src/widgets/view_widgets/board_view/api.ts @@ -55,18 +55,22 @@ export default class BoardApi { } async renameColumn(oldValue: string, newValue: string, noteIds: string[]) { + // Change the value in the notes. + await executeBulkActions(noteIds, [ + { + name: "updateLabelValue", + labelName: "status", + labelValue: newValue + } + ]); + // Rename the column in the persisted data. for (const column of this.persistedData.columns || []) { if (column.value === oldValue) { column.value = newValue; } } - this.viewStorage.store(this.persistedData); - - // Update all notes that have the old status value to the new value - for (const noteId of noteIds) { - await attributes.setLabel(noteId, "status", newValue); - } + await this.viewStorage.store(this.persistedData); } async removeColumn(column: string) { From 1f792ca41895a1f41279b9d7b3f40a89b838b0d5 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 20 Jul 2025 20:06:54 +0300 Subject: [PATCH 36/53] feat(views/board): add new column --- .../widgets/view_widgets/board_view/api.ts | 15 +++ .../widgets/view_widgets/board_view/index.ts | 118 ++++++++++++++++++ 2 files changed, 133 insertions(+) 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 ffec13960..cd94abce9 100644 --- a/apps/client/src/widgets/view_widgets/board_view/api.ts +++ b/apps/client/src/widgets/view_widgets/board_view/api.ts @@ -87,6 +87,21 @@ 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; + } + static async build(parentNote: FNote, viewStorage: ViewModeStorage) { let persistedData = await viewStorage.restore() ?? {}; const { byColumn, newPersistedData } = await getBoardData(parentNote, "status", persistedData); 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 6505d11cf..ad4a46b9f 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -160,6 +160,39 @@ const TPL = /*html*/` .board-new-item .icon { margin-right: 0.25em; } + + .board-add-column { + width: 250px; + flex-shrink: 0; + min-height: 200px; + border: 2px dashed var(--main-border-color); + border-radius: 8px; + padding: 0.5em; + background-color: transparent; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--muted-text-color); + } + + .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.editing { + border-style: solid; + border-color: var(--main-text-color); + background-color: var(--main-background-color); + } + + .board-add-column .icon { + margin-right: 0.5em; + font-size: 1.2em; + }
@@ -282,6 +315,18 @@ export default class BoardView extends ViewMode { $(el).append($columnEl); } + + // Add "Add Column" button at the end + const $addColumnEl = $("
") + .addClass("board-add-column") + .html('Add Column'); + + $addColumnEl.on("click", (e) => { + e.stopPropagation(); + this.startCreatingNewColumn($addColumnEl); + }); + + $(el).append($addColumnEl); } private setupNoteDrag($noteEl: JQuery, note: any, branch: any) { @@ -554,6 +599,79 @@ 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(); + + 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()); + } + } + + // 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) { + 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); + } + } + async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) { // React to changes in "status" attribute for notes in this board if (loadResults.getAttributeRows().some(attr => attr.name === "status" && this.noteIds.includes(attr.noteId!))) { From c752b98995849addeda8dc01df38d595469d9322 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 20 Jul 2025 20:22:41 +0300 Subject: [PATCH 37/53] chore(views/board): smaller add new column --- apps/client/src/widgets/view_widgets/board_view/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 ad4a46b9f..6b0f16fd3 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -162,9 +162,9 @@ const TPL = /*html*/` } .board-add-column { - width: 250px; + width: 180px; flex-shrink: 0; - min-height: 200px; + height: 60px; border: 2px dashed var(--main-border-color); border-radius: 8px; padding: 0.5em; @@ -175,6 +175,8 @@ const TPL = /*html*/` justify-content: center; cursor: pointer; color: var(--muted-text-color); + font-size: 0.9em; + align-self: flex-start; } .board-add-column:hover { From 1cde14859b3c978510ba9905744767d52bb047d7 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 20 Jul 2025 20:31:07 +0300 Subject: [PATCH 38/53] feat(views/board): touch support --- .../widgets/view_widgets/board_view/index.ts | 184 +++++++++++++++++- 1 file changed, 176 insertions(+), 8 deletions(-) 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 6b0f16fd3..b3b51344a 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -334,6 +334,7 @@ export default class BoardView extends ViewMode { private setupNoteDrag($noteEl: JQuery, note: any, branch: any) { $noteEl.attr("draggable", "true"); + // Mouse drag events $noteEl.on("dragstart", (e) => { this.draggedNote = note; this.draggedBranch = branch; @@ -357,6 +358,82 @@ export default class BoardView extends ViewMode { // Remove all drop indicators this.$container.find(".board-drop-indicator").removeClass("show"); }); + + // Touch drag events + let isDragging = false; + let startY = 0; + let startX = 0; + let dragThreshold = 10; // Minimum distance to start dragging + + $noteEl.on("touchstart", (e) => { + const touch = (e.originalEvent as TouchEvent).touches[0]; + startX = touch.clientX; + startY = touch.clientY; + isDragging = false; + }); + + $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.draggedNote = note; + this.draggedBranch = branch; + this.draggedNoteElement = $noteEl; + $noteEl.addClass("dragging"); + } + + if (isDragging) { + // 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.$container.find(".board-drop-indicator").removeClass("show"); + } + } + } + }); + + $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.draggedNote && this.draggedNoteElement && this.draggedBranch) { + await this.handleNoteDrop($columnEl, column); + } + } + } + + // Clean up + $noteEl.removeClass("dragging"); + this.draggedNote = null; + this.draggedBranch = null; + this.draggedNoteElement = null; + this.$container.find('.board-column').removeClass('drag-over'); + this.$container.find(".board-drop-indicator").removeClass("show"); + } + isDragging = false; + }); } private setupColumnDropZone($columnEl: JQuery, column: string) { @@ -435,21 +512,16 @@ export default class BoardView extends ViewMode { } } - // Update the UI - if (dropIndicator.length > 0) { - dropIndicator.after(draggedNoteElement); - } else { - $columnEl.append(draggedNoteElement); - } - // Update the data attributes draggedNoteElement.attr("data-current-column", column); // Show success feedback console.log(`Moved note "${draggedNote.title}" from "${currentColumn}" to "${column}"`); + + // Refresh the board to reflect the changes + await this.renderList(); } catch (error) { console.error("Failed to update note position:", error); - // Optionally show user-facing error message } } }); @@ -495,6 +567,102 @@ export default class BoardView extends ViewMode { $dropIndicator.addClass("show"); } + private showDropIndicatorAtPoint($columnEl: JQuery, touchY: number) { + const columnRect = $columnEl[0].getBoundingClientRect(); + const relativeY = touchY - columnRect.top; + + // Find existing drop indicator or create one + let $dropIndicator = $columnEl.find(".board-drop-indicator"); + if ($dropIndicator.length === 0) { + $dropIndicator = $("
").addClass("board-drop-indicator"); + $columnEl.append($dropIndicator); + } + + // Find the best position to insert the note + const $notes = this.draggedNoteElement ? + $columnEl.find(".board-note").not(this.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.draggedNoteElement; + const draggedNote = this.draggedNote; + const draggedBranch = this.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") { + console.log("Move before branch:", draggedBranch.branchId, "to", targetBranchId); + await branchService.moveBeforeBranch([draggedBranch.branchId], targetBranchId); + } else if (moveType === "after") { + console.log("Move after branch:", draggedBranch.branchId, "to", targetBranchId); + await branchService.moveAfterBranch([draggedBranch.branchId], targetBranchId); + } + } + + // Update the data attributes + draggedNoteElement.attr("data-current-column", column); + + // Show success feedback + console.log(`Moved note "${draggedNote.title}" from "${currentColumn}" to "${column}"`); + + // Refresh the board to reflect the changes + await this.renderList(); + } catch (error) { + console.error("Failed to update note position:", error); + } + } + } + private createTitleStructure(title: string): { $titleText: JQuery; $editIcon: JQuery } { const $titleText = $("").text(title); const $editIcon = $("") From eb76362de4141be93ca30aa5965999ad9c7432e3 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 20 Jul 2025 20:55:41 +0300 Subject: [PATCH 39/53] chore(views/board): improve header --- .../src/widgets/view_widgets/board_view/index.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) 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 b3b51344a..d5527dc73 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -46,29 +46,27 @@ const TPL = /*html*/` .board-view-container .board-column h3 { font-size: 1em; margin-bottom: 0.75em; - padding-bottom: 0.5em; + padding: 0.5em 0.5em 0.5em 0.5em; border-bottom: 1px solid var(--main-border-color); cursor: pointer; position: relative; - transition: background-color 0.2s ease; + 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:hover { background-color: var(--hover-item-background-color); border-radius: 4px; - padding: 0.25em 0.5em; - margin: -0.25em -0.5em 0.75em -0.5em; } .board-view-container .board-column h3.editing { background-color: var(--main-background-color); border: 1px solid var(--main-text-color); border-radius: 4px; - padding: 0.25em 0.5em; - margin: -0.25em -0.5em 0.75em -0.5em; } .board-view-container .board-column h3 input { @@ -84,7 +82,6 @@ const TPL = /*html*/` .board-view-container .board-column h3 .edit-icon { opacity: 0; - font-size: 0.8em; margin-left: 0.5em; transition: opacity 0.2s ease; color: var(--muted-text-color); From 482b592f77396be4a08d211f5803cf25014e1144 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 21 Jul 2025 10:55:01 +0300 Subject: [PATCH 40/53] feat(views/board): add drag preview when using touch --- .../widgets/view_widgets/board_view/index.ts | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) 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 d5527dc73..b62f21794 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -192,6 +192,22 @@ const TPL = /*html*/` 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; + }
@@ -361,12 +377,14 @@ export default class BoardView extends ViewMode { 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) => { @@ -382,9 +400,18 @@ export default class BoardView extends ViewMode { this.draggedBranch = branch; this.draggedNoteElement = $noteEl; $noteEl.addClass("dragging"); + + // Create drag preview + $dragPreview = this.createDragPreview($noteEl, touch.clientX, touch.clientY); } - if (isDragging) { + 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) { @@ -428,6 +455,12 @@ export default class BoardView extends ViewMode { this.draggedNoteElement = null; this.$container.find('.board-column').removeClass('drag-over'); this.$container.find(".board-drop-indicator").removeClass("show"); + + // Remove drag preview + if ($dragPreview) { + $dragPreview.remove(); + $dragPreview = null; + } } isDragging = false; }); @@ -602,6 +635,24 @@ export default class BoardView extends ViewMode { $dropIndicator.addClass("show"); } + 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 async handleNoteDrop($columnEl: JQuery, column: string) { const draggedNoteElement = this.draggedNoteElement; const draggedNote = this.draggedNote; From 4826898c553eb87e020f341395b25ee537be1fa9 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 21 Jul 2025 11:01:42 +0300 Subject: [PATCH 41/53] refactor(views/board): move drag logic to separate file --- .../view_widgets/board_view/drag_handler.ts | 318 ++++++++++++++ .../widgets/view_widgets/board_view/index.ts | 390 +----------------- 2 files changed, 335 insertions(+), 373 deletions(-) create mode 100644 apps/client/src/widgets/view_widgets/board_view/drag_handler.ts 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 new file mode 100644 index 000000000..a11970491 --- /dev/null +++ b/apps/client/src/widgets/view_widgets/board_view/drag_handler.ts @@ -0,0 +1,318 @@ +import branchService from "../../../services/branches"; +import BoardApi from "./api"; + +export interface DragContext { + draggedNote: any; + draggedBranch: any; + draggedNoteElement: JQuery | null; +} + +export class BoardDragHandler { + private $container: JQuery; + private api: BoardApi; + private context: DragContext; + private onBoardRefresh: () => Promise; + + constructor( + $container: JQuery, + api: BoardApi, + context: DragContext, + onBoardRefresh: () => Promise + ) { + this.$container = $container; + this.api = api; + this.context = context; + this.onBoardRefresh = onBoardRefresh; + } + + 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); + } + + 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; + + // Remove all drop indicators + this.$container.find(".board-drop-indicator").removeClass("show"); + }); + } + + 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.$container.find(".board-drop-indicator").removeClass("show"); + } + } + } + }); + + $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.$container.find(".board-drop-indicator").removeClass("show"); + + // Remove drag preview + if ($dragPreview) { + $dragPreview.remove(); + $dragPreview = null; + } + } + isDragging = false; + }); + } + + setupColumnDropZone($columnEl: JQuery, column: string) { + $columnEl.on("dragover", (e) => { + e.preventDefault(); + const originalEvent = e.originalEvent as DragEvent; + if (originalEvent.dataTransfer) { + originalEvent.dataTransfer.dropEffect = "move"; + } + + if (this.context.draggedNote) { + $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"); + $columnEl.find(".board-drop-indicator").removeClass("show"); + } + }); + + $columnEl.on("drop", async (e) => { + e.preventDefault(); + $columnEl.removeClass("drag-over"); + + if (this.context.draggedNote && this.context.draggedNoteElement && this.context.draggedBranch) { + await this.handleNoteDrop($columnEl, column); + } + }); + } + + 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; + + // Find existing drop indicator or create one + let $dropIndicator = $columnEl.find(".board-drop-indicator"); + if ($dropIndicator.length === 0) { + $dropIndicator = $("
").addClass("board-drop-indicator"); + $columnEl.append($dropIndicator); + } + + // 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") { + console.log("Move before branch:", draggedBranch.branchId, "to", targetBranchId); + await branchService.moveBeforeBranch([draggedBranch.branchId], targetBranchId); + } else if (moveType === "after") { + console.log("Move after branch:", draggedBranch.branchId, "to", targetBranchId); + await branchService.moveAfterBranch([draggedBranch.branchId], targetBranchId); + } + } + + // Update the data attributes + draggedNoteElement.attr("data-current-column", column); + + // Show success feedback + console.log(`Moved note "${draggedNote.title}" from "${currentColumn}" to "${column}"`); + + // Refresh the board to reflect the changes + await this.onBoardRefresh(); + } catch (error) { + console.error("Failed to update note position:", error); + } + } + } +} 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 b62f21794..23af7a8ee 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -1,13 +1,13 @@ import { setupHorizontalScrollViaWheel } from "../../widget_utils"; import ViewMode, { ViewModeArgs } from "../view_mode"; import attributeService from "../../../services/attributes"; -import branchService from "../../../services/branches"; import noteCreateService from "../../../services/note_create"; import appContext, { EventData } from "../../../components/app_context"; 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"; const TPL = /*html*/`
@@ -219,11 +219,10 @@ export default class BoardView extends ViewMode { private $root: JQuery; private $container: JQuery; private spacedUpdate: SpacedUpdate; - private draggedNote: any = null; - private draggedBranch: any = null; - private draggedNoteElement: JQuery | null = null; + private dragContext: DragContext; private persistentData: BoardData; private api?: BoardApi; + private dragHandler?: BoardDragHandler; constructor(args: ViewModeArgs) { super(args, "board"); @@ -235,6 +234,11 @@ export default class BoardView extends ViewMode { this.persistentData = { columns: [] }; + this.dragContext = { + draggedNote: null, + draggedBranch: null, + draggedNoteElement: null + }; args.$parent.append(this.$root); } @@ -248,6 +252,13 @@ export default class BoardView extends ViewMode { private async renderBoard(el: HTMLElement) { this.api = await BoardApi.build(this.parentNote, this.viewStorage); + this.dragHandler = new BoardDragHandler( + this.$container, + this.api, + this.dragContext, + async () => { await this.renderList(); } + ); + setupContextMenu({ $container: this.$container, api: this.api @@ -287,7 +298,7 @@ export default class BoardView extends ViewMode { }); // Setup drop zone for the column - this.setupColumnDropZone($columnEl, column); + this.dragHandler!.setupColumnDropZone($columnEl, column); for (const item of columnItems) { const note = item.note; @@ -311,7 +322,7 @@ export default class BoardView extends ViewMode { $noteEl.on("click", () => appContext.triggerCommand("openInPopup", { noteIdOrPath: note.noteId })); // Setup drag functionality for the note - this.setupNoteDrag($noteEl, note, branch); + this.dragHandler!.setupNoteDrag($noteEl, note, branch); $columnEl.append($noteEl); } @@ -344,373 +355,6 @@ export default class BoardView extends ViewMode { $(el).append($addColumnEl); } - private setupNoteDrag($noteEl: JQuery, note: any, branch: any) { - $noteEl.attr("draggable", "true"); - - // Mouse drag events - $noteEl.on("dragstart", (e) => { - this.draggedNote = note; - this.draggedBranch = branch; - this.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.draggedNote = null; - this.draggedBranch = null; - this.draggedNoteElement = null; - - // Remove all drop indicators - this.$container.find(".board-drop-indicator").removeClass("show"); - }); - - // Touch drag events - 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.draggedNote = note; - this.draggedBranch = branch; - this.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.$container.find(".board-drop-indicator").removeClass("show"); - } - } - } - }); - - $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.draggedNote && this.draggedNoteElement && this.draggedBranch) { - await this.handleNoteDrop($columnEl, column); - } - } - } - - // Clean up - $noteEl.removeClass("dragging"); - this.draggedNote = null; - this.draggedBranch = null; - this.draggedNoteElement = null; - this.$container.find('.board-column').removeClass('drag-over'); - this.$container.find(".board-drop-indicator").removeClass("show"); - - // Remove drag preview - if ($dragPreview) { - $dragPreview.remove(); - $dragPreview = null; - } - } - isDragging = false; - }); - } - - private setupColumnDropZone($columnEl: JQuery, column: string) { - $columnEl.on("dragover", (e) => { - e.preventDefault(); - const originalEvent = e.originalEvent as DragEvent; - if (originalEvent.dataTransfer) { - originalEvent.dataTransfer.dropEffect = "move"; - } - - if (this.draggedNote) { - $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"); - $columnEl.find(".board-drop-indicator").removeClass("show"); - } - }); - - $columnEl.on("drop", async (e) => { - e.preventDefault(); - $columnEl.removeClass("drag-over"); - - const draggedNoteElement = this.draggedNoteElement; - const draggedNote = this.draggedNote; - const draggedBranch = this.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"; - } - } - - // Now remove the drop indicator - $columnEl.find(".board-drop-indicator").removeClass("show"); - - 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") { - console.log("Move before branch:", draggedBranch.branchId, "to", targetBranchId); - await branchService.moveBeforeBranch([draggedBranch.branchId], targetBranchId); - } else if (moveType === "after") { - console.log("Move after branch:", draggedBranch.branchId, "to", targetBranchId); - await branchService.moveAfterBranch([draggedBranch.branchId], targetBranchId); - } - } - - // Update the data attributes - draggedNoteElement.attr("data-current-column", column); - - // Show success feedback - console.log(`Moved note "${draggedNote.title}" from "${currentColumn}" to "${column}"`); - - // Refresh the board to reflect the changes - await this.renderList(); - } catch (error) { - console.error("Failed to update note position:", error); - } - } - }); - } - - private showDropIndicator($columnEl: JQuery, e: JQuery.DragOverEvent) { - const originalEvent = e.originalEvent as DragEvent; - const mouseY = originalEvent.clientY; - const columnRect = $columnEl[0].getBoundingClientRect(); - const relativeY = mouseY - columnRect.top; - - // Find existing drop indicator or create one - let $dropIndicator = $columnEl.find(".board-drop-indicator"); - if ($dropIndicator.length === 0) { - $dropIndicator = $("
").addClass("board-drop-indicator"); - $columnEl.append($dropIndicator); - } - - // Find the best position to insert the note - const $notes = this.draggedNoteElement ? - $columnEl.find(".board-note").not(this.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 showDropIndicatorAtPoint($columnEl: JQuery, touchY: number) { - const columnRect = $columnEl[0].getBoundingClientRect(); - const relativeY = touchY - columnRect.top; - - // Find existing drop indicator or create one - let $dropIndicator = $columnEl.find(".board-drop-indicator"); - if ($dropIndicator.length === 0) { - $dropIndicator = $("
").addClass("board-drop-indicator"); - $columnEl.append($dropIndicator); - } - - // Find the best position to insert the note - const $notes = this.draggedNoteElement ? - $columnEl.find(".board-note").not(this.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 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 async handleNoteDrop($columnEl: JQuery, column: string) { - const draggedNoteElement = this.draggedNoteElement; - const draggedNote = this.draggedNote; - const draggedBranch = this.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") { - console.log("Move before branch:", draggedBranch.branchId, "to", targetBranchId); - await branchService.moveBeforeBranch([draggedBranch.branchId], targetBranchId); - } else if (moveType === "after") { - console.log("Move after branch:", draggedBranch.branchId, "to", targetBranchId); - await branchService.moveAfterBranch([draggedBranch.branchId], targetBranchId); - } - } - - // Update the data attributes - draggedNoteElement.attr("data-current-column", column); - - // Show success feedback - console.log(`Moved note "${draggedNote.title}" from "${currentColumn}" to "${column}"`); - - // Refresh the board to reflect the changes - await this.renderList(); - } catch (error) { - console.error("Failed to update note position:", error); - } - } - } - private createTitleStructure(title: string): { $titleText: JQuery; $editIcon: JQuery } { const $titleText = $("").text(title); const $editIcon = $("") From d98be19c9a1a25af23d73e1a192c1eb830c3d83b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 21 Jul 2025 11:13:41 +0300 Subject: [PATCH 42/53] feat(views/board): set up differential renderer --- .../board_view/differential_renderer.ts | 327 ++++++++++++++++++ .../view_widgets/board_view/drag_handler.ts | 4 + .../widgets/view_widgets/board_view/index.ts | 191 ++++------ 3 files changed, 407 insertions(+), 115 deletions(-) create mode 100644 apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts 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 new file mode 100644 index 000000000..66e91d128 --- /dev/null +++ b/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts @@ -0,0 +1,327 @@ +import { BoardDragHandler, DragContext } 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"; + +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; + + constructor( + $container: JQuery, + api: BoardApi, + dragHandler: BoardDragHandler, + onCreateNewItem: (column: string) => void, + parentNote: FNote, + viewStorage: ViewModeStorage + ) { + this.$container = $container; + this.api = api; + this.dragHandler = dragHandler; + this.onCreateNewItem = onCreateNewItem; + this.parentNote = parentNote; + this.viewStorage = viewStorage; + } + + async renderBoard(refreshApi: boolean = false): Promise { + // Refresh API data if requested + if (refreshApi) { + this.api = await BoardApi.build(this.parentNote, this.viewStorage); + this.dragHandler.updateApi(this.api); + } + + // 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 { + 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); + } + + this.addAddColumnButton(); + } + + 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 { + // 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 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; + let $existingCard = $cardContainer.find(`[data-note-id="${noteId}"]`); + + if ($existingCard.length) { + // Update existing card if title changed + const currentTitle = $existingCard.text().trim(); + if (currentTitle !== item.note.title) { + $existingCard.contents().filter(function() { + return this.nodeType === 3; // Text nodes + }).remove(); + $existingCard.append(item.note.title); + } + + // 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 { + const $columnEl = $("
") + .addClass("board-column") + .attr("data-column", column); + + // Create header + const $titleEl = $("

").attr("data-column-value", column); + const $titleText = $("").text(column); + const $editIcon = $("") + .addClass("edit-icon icon bx bx-edit-alt") + .attr("title", "Click to edit column title"); + $titleEl.append($titleText, $editIcon); + $columnEl.append($titleEl); + + // 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 zone + this.dragHandler.setupColumnDropZone($columnEl, column); + + // 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") + .attr("data-column", column) + .html('New item'); + + $newItemEl.on("click", () => this.onCreateNewItem(column)); + $columnEl.append($newItemEl); + + return $columnEl; + } + + private createCard(note: any, branch: any, column: string): JQuery { + const $iconEl = $("") + .addClass("icon") + .addClass(note.getIcon()); + + const $noteEl = $("
") + .addClass("board-note") + .attr("data-note-id", note.noteId) + .attr("data-branch-id", branch.branchId) + .attr("data-current-column", column) + .text(note.title); + + $noteEl.prepend($iconEl); + $noteEl.on("click", () => appContext.triggerCommand("openInPopup", { noteIdOrPath: note.noteId })); + + // Setup drag functionality + this.dragHandler.setupNoteDrag($noteEl, note, branch); + + return $noteEl; + } + + private addAddColumnButton(): void { + if (this.$container.find('.board-add-column').length === 0) { + const $addColumnEl = $("
") + .addClass("board-add-column") + .html('Add Column'); + + this.$container.append($addColumnEl); + } + } + + 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 index a11970491..9de3b977f 100644 --- a/apps/client/src/widgets/view_widgets/board_view/drag_handler.ts +++ b/apps/client/src/widgets/view_widgets/board_view/drag_handler.ts @@ -35,6 +35,10 @@ export class BoardDragHandler { this.setupTouchDrag($noteEl, note, branch); } + updateApi(newApi: BoardApi) { + this.api = newApi; + } + private setupMouseDrag($noteEl: JQuery, note: any, branch: any) { $noteEl.on("dragstart", (e) => { this.context.draggedNote = note; 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 23af7a8ee..3a7e5d885 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -8,6 +8,7 @@ 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"; const TPL = /*html*/`
@@ -104,7 +105,26 @@ const TPL = /*html*/` 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; + 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:hover { @@ -223,6 +243,7 @@ export default class BoardView extends ViewMode { private persistentData: BoardData; private api?: BoardApi; private dragHandler?: BoardDragHandler; + private renderer?: DifferentialBoardRenderer; constructor(args: ViewModeArgs) { super(args, "board"); @@ -244,13 +265,17 @@ export default class BoardView extends ViewMode { } async renderList(): Promise | undefined> { - this.$container.empty(); - await this.renderBoard(this.$container[0]); - + if (!this.renderer) { + // First time setup + this.$container.empty(); + await this.initializeRenderer(); + } + + await this.renderer!.renderBoard(); return this.$root; } - private async renderBoard(el: HTMLElement) { + private async initializeRenderer() { this.api = await BoardApi.build(this.parentNote, this.viewStorage); this.dragHandler = new BoardDragHandler( this.$container, @@ -259,100 +284,41 @@ export default class BoardView extends ViewMode { async () => { await this.renderList(); } ); + this.renderer = new DifferentialBoardRenderer( + this.$container, + this.api, + this.dragHandler, + (column: string) => this.createNewItem(column), + this.parentNote, + this.viewStorage + ); + setupContextMenu({ $container: this.$container, api: this.api }); - for (const column of this.api.columns) { - const columnItems = this.api.getColumn(column); - if (!columnItems) { - continue; - } + // Setup column title editing and add column functionality + this.setupBoardInteractions(); + } - // Find the column data to get custom title - const columnTitle = column; - - const $columnEl = $("
") - .addClass("board-column") - .attr("data-column", column); - - const $titleEl = $("

") - .attr("data-column-value", column); - - const { $titleText, $editIcon } = this.createTitleStructure(columnTitle); - $titleEl.append($titleText, $editIcon); - - // Make column title editable - this.setupColumnTitleEdit($titleEl, column, columnItems); - - $columnEl.append($titleEl); - - // Allow vertical scrolling in the column, bypassing the horizontal scroll of the container. - $columnEl.on("wheel", (event) => { - const el = $columnEl[0]; - const needsScroll = el.scrollHeight > el.clientHeight; - if (needsScroll) { - event.stopPropagation(); - } - }); - - // Setup drop zone for the column - this.dragHandler!.setupColumnDropZone($columnEl, column); - - for (const item of columnItems) { - const note = item.note; - const branch = item.branch; - if (!note) { - continue; - } - - const $iconEl = $("") - .addClass("icon") - .addClass(note.getIcon()); - - const $noteEl = $("
") - .addClass("board-note") - .attr("data-note-id", note.noteId) - .attr("data-branch-id", branch.branchId) - .attr("data-current-column", column) - .text(note.title); - - $noteEl.prepend($iconEl); - $noteEl.on("click", () => appContext.triggerCommand("openInPopup", { noteIdOrPath: note.noteId })); - - // Setup drag functionality for the note - this.dragHandler!.setupNoteDrag($noteEl, note, branch); - - $columnEl.append($noteEl); - } - - // Add "New item" link at the bottom of the column - const $newItemEl = $("
") - .addClass("board-new-item") - .attr("data-column", column) - .html('New item'); - - $newItemEl.on("click", () => { - this.createNewItem(column); - }); - - $columnEl.append($newItemEl); - - $(el).append($columnEl); - } - - // Add "Add Column" button at the end - const $addColumnEl = $("
") - .addClass("board-add-column") - .html('Add Column'); - - $addColumnEl.on("click", (e) => { + private setupBoardInteractions() { + // Handle column title editing + this.$container.on('click', 'h3[data-column-value]', (e) => { e.stopPropagation(); - this.startCreatingNewColumn($addColumnEl); + const $titleEl = $(e.currentTarget); + const columnValue = $titleEl.attr('data-column-value'); + if (columnValue) { + const columnItems = this.api?.getColumn(columnValue) || []; + this.startEditingColumnTitle($titleEl, columnValue, columnItems); + } }); - $(el).append($addColumnEl); + // Handle add column button + this.$container.on('click', '.board-add-column', (e) => { + e.stopPropagation(); + this.startCreatingNewColumn($(e.currentTarget)); + }); } private createTitleStructure(title: string): { $titleText: JQuery; $editIcon: JQuery } { @@ -364,13 +330,6 @@ export default class BoardView extends ViewMode { return { $titleText, $editIcon }; } - private setupColumnTitleEdit($titleEl: JQuery, columnValue: string, columnItems: { branch: any; note: any; }[]) { - $titleEl.on("click", (e) => { - e.stopPropagation(); - this.startEditingColumnTitle($titleEl, columnValue, columnItems); - }); - } - private startEditingColumnTitle($titleEl: JQuery, columnValue: string, columnItems: { branch: any; note: any; }[]) { if ($titleEl.hasClass("editing")) { return; // Already editing @@ -461,6 +420,11 @@ export default class BoardView extends ViewMode { } } + forceFullRefresh() { + this.renderer?.forceFullRender(); + return this.renderList(); + } + private startCreatingNewColumn($addColumnEl: JQuery) { if ($addColumnEl.hasClass("editing")) { return; // Already editing @@ -535,26 +499,23 @@ export default class BoardView extends ViewMode { } async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) { - // React to changes in "status" attribute for notes in this board - if (loadResults.getAttributeRows().some(attr => attr.name === "status" && this.noteIds.includes(attr.noteId!))) { - return true; - } - - // React to changes in note title. - if (loadResults.getNoteIds().some(noteId => this.noteIds.includes(noteId))) { - return true; - } - - // React to changes in branches for subchildren (e.g., moved, added, or removed notes) - if (loadResults.getBranchRows().some(branch => this.noteIds.includes(branch.noteId!))) { - return true; - } - - // React to attachment change. - if (loadResults.getAttachmentRows().some(att => att.ownerId === this.parentNote.noteId && att.title === "board.json")) { - return true; + // 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 === "status" && 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 attachment change + loadResults.getAttachmentRows().some(att => att.ownerId === this.parentNote.noteId && att.title === "board.json"); + + 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; } From 545b19f978dc28a42f40421e6fa4db466326b3eb Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 21 Jul 2025 11:19:14 +0300 Subject: [PATCH 43/53] fix(views/board): drop indicator remaining stuck --- .../board_view/differential_renderer.ts | 3 ++ .../view_widgets/board_view/drag_handler.ts | 40 ++++++++++++++----- 2 files changed, 32 insertions(+), 11 deletions(-) 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 66e91d128..149574e1f 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 @@ -56,6 +56,9 @@ export class DifferentialBoardRenderer { } private async performUpdate(): Promise { + // Clean up any stray drag indicators before updating + this.dragHandler.cleanup(); + const currentState = this.getCurrentState(); if (!this.lastState) { 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 index 9de3b977f..c11a68b8a 100644 --- a/apps/client/src/widgets/view_widgets/board_view/drag_handler.ts +++ b/apps/client/src/widgets/view_widgets/board_view/drag_handler.ts @@ -39,6 +39,22 @@ export class BoardDragHandler { this.api = newApi; } + private cleanupAllDropIndicators() { + // Remove all drop indicators from the DOM to prevent layout issues + this.$container.find(".board-drop-indicator").remove(); + } + + private cleanupColumnDropIndicators($columnEl: JQuery) { + // Remove drop indicators from a specific column + $columnEl.find(".board-drop-indicator").remove(); + } + + // Public method to clean up any stray indicators - can be called externally + 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; @@ -60,8 +76,8 @@ export class BoardDragHandler { this.context.draggedBranch = null; this.context.draggedNoteElement = null; - // Remove all drop indicators - this.$container.find(".board-drop-indicator").removeClass("show"); + // Clean up all drop indicators properly + this.cleanupAllDropIndicators(); }); } @@ -120,7 +136,7 @@ export class BoardDragHandler { } else { // Remove all drag indicators if not over a column this.$container.find('.board-column').removeClass('drag-over'); - this.$container.find(".board-drop-indicator").removeClass("show"); + this.cleanupAllDropIndicators(); } } } @@ -147,7 +163,7 @@ export class BoardDragHandler { this.context.draggedBranch = null; this.context.draggedNoteElement = null; this.$container.find('.board-column').removeClass('drag-over'); - this.$container.find(".board-drop-indicator").removeClass("show"); + this.cleanupAllDropIndicators(); // Remove drag preview if ($dragPreview) { @@ -182,7 +198,7 @@ export class BoardDragHandler { if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { $columnEl.removeClass("drag-over"); - $columnEl.find(".board-drop-indicator").removeClass("show"); + this.cleanupColumnDropIndicators($columnEl); } }); @@ -228,12 +244,11 @@ export class BoardDragHandler { const columnRect = $columnEl[0].getBoundingClientRect(); const relativeY = y - columnRect.top; - // Find existing drop indicator or create one - let $dropIndicator = $columnEl.find(".board-drop-indicator"); - if ($dropIndicator.length === 0) { - $dropIndicator = $("
").addClass("board-drop-indicator"); - $columnEl.append($dropIndicator); - } + // Clean up any existing drop indicators in this column first + this.cleanupColumnDropIndicators($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 ? @@ -316,6 +331,9 @@ export class BoardDragHandler { await this.onBoardRefresh(); } catch (error) { console.error("Failed to update note position:", error); + } finally { + // Always clean up drop indicators after drop operation + this.cleanupAllDropIndicators(); } } } From 3a569499cbca84869c71717d19b44c8df887f3d6 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 21 Jul 2025 11:26:38 +0300 Subject: [PATCH 44/53] feat(views/board): edit the note title inline on new --- .../board_view/differential_renderer.ts | 113 +++++++++++++++++- .../widgets/view_widgets/board_view/index.ts | 31 ++++- 2 files changed, 134 insertions(+), 10 deletions(-) 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 149574e1f..f79e5294b 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 @@ -183,6 +183,7 @@ export class DifferentialBoardRenderer { const item = newCards[i]; const noteId = item.note.noteId; let $existingCard = $cardContainer.find(`[data-note-id="${noteId}"]`); + const isNewCard = !oldCardIds.includes(noteId); if ($existingCard.length) { // Update existing card if title changed @@ -197,8 +198,8 @@ export class DifferentialBoardRenderer { // Ensure card is in correct position this.ensureCardPosition($existingCard, i, $cardContainer); } else { - // Create new card - const $newCard = this.createCard(item.note, item.branch, column); + // Create new card (pass isNewCard flag) + const $newCard = this.createCard(item.note, item.branch, column, isNewCard); $newCard.addClass('fade-in').css('opacity', '0'); // Insert at correct position @@ -264,7 +265,7 @@ export class DifferentialBoardRenderer { // Add cards for (const item of columnItems) { if (item.note) { - const $noteEl = this.createCard(item.note, item.branch, column); + const $noteEl = this.createCard(item.note, item.branch, column, false); // false = existing card $columnEl.append($noteEl); } } @@ -281,7 +282,7 @@ export class DifferentialBoardRenderer { return $columnEl; } - private createCard(note: any, branch: any, column: string): JQuery { + private createCard(note: any, branch: any, column: string, isNewCard: boolean = false): JQuery { const $iconEl = $("") .addClass("icon") .addClass(note.getIcon()); @@ -291,10 +292,15 @@ export class DifferentialBoardRenderer { .attr("data-note-id", note.noteId) .attr("data-branch-id", branch.branchId) .attr("data-current-column", column) + .attr("data-icon-class", note.getIcon()) .text(note.title); $noteEl.prepend($iconEl); - $noteEl.on("click", () => appContext.triggerCommand("openInPopup", { noteIdOrPath: note.noteId })); + + // Only add quick edit click handler for existing cards (not new ones) + if (!isNewCard) { + $noteEl.on("click", () => appContext.triggerCommand("openInPopup", { noteIdOrPath: note.noteId })); + } // Setup drag functionality this.dragHandler.setupNoteDrag($noteEl, note, branch); @@ -327,4 +333,101 @@ export class DifferentialBoardRenderer { await this.performUpdate(); } } + + 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: boolean = 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 }) => { + await server.put(`notes/${noteId}/title`, { title: newTitle.trim() }); + finalTitle = newTitle.trim(); + }); + } 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.empty().append($newIcon, finalTitle); + + // 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 3a7e5d885..60e347320 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -139,6 +139,22 @@ const TPL = /*html*/` 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; } @@ -270,7 +286,7 @@ export default class BoardView extends ViewMode { this.$container.empty(); await this.initializeRenderer(); } - + await this.renderer!.renderBoard(); return this.$root; } @@ -402,7 +418,8 @@ export default class BoardView extends ViewMode { // Create a new note as a child of the parent note const { note: newNote } = await noteCreateService.createNote(parentNotePath, { - activate: false + activate: false, + title: "New item" }); if (newNote) { @@ -412,14 +429,18 @@ export default class BoardView extends ViewMode { // Refresh the board to show the new item await this.renderList(); - // Optionally, open the new note for editing - appContext.triggerCommand("openInPopup", { noteIdOrPath: newNote.noteId }); + // Start inline editing of the newly created card + this.startInlineEditingCard(newNote.noteId); } } catch (error) { console.error("Failed to create new item:", error); } } + private startInlineEditingCard(noteId: string) { + this.renderer?.startInlineEditing(noteId); + } + forceFullRefresh() { this.renderer?.forceFullRender(); return this.renderList(); @@ -500,7 +521,7 @@ export default class BoardView extends ViewMode { async onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) { // Check if any changes affect our board - const hasRelevantChanges = + const hasRelevantChanges = // React to changes in "status" attribute for notes in this board loadResults.getAttributeRows().some(attr => attr.name === "status" && this.noteIds.includes(attr.noteId!)) || // React to changes in note title From 96ca3d5e38855eeb7c8e0df56e3706c21693483c Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 21 Jul 2025 13:14:07 +0300 Subject: [PATCH 45/53] fix(views/board): creating new notes would render as HTML --- .../board_view/differential_renderer.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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 f79e5294b..797a525b5 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 @@ -58,7 +58,7 @@ export class DifferentialBoardRenderer { private async performUpdate(): Promise { // Clean up any stray drag indicators before updating this.dragHandler.cleanup(); - + const currentState = this.getCurrentState(); if (!this.lastState) { @@ -192,7 +192,7 @@ export class DifferentialBoardRenderer { $existingCard.contents().filter(function() { return this.nodeType === 3; // Text nodes }).remove(); - $existingCard.append(item.note.title); + $existingCard.append(document.createTextNode(item.note.title)); } // Ensure card is in correct position @@ -296,7 +296,7 @@ export class DifferentialBoardRenderer { .text(note.title); $noteEl.prepend($iconEl); - + // Only add quick edit click handler for existing cards (not new ones) if (!isNewCard) { $noteEl.on("click", () => appContext.triggerCommand("openInPopup", { noteIdOrPath: note.noteId })); @@ -352,7 +352,7 @@ export class DifferentialBoardRenderer { // 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 @@ -413,8 +413,9 @@ export class DifferentialBoardRenderer { // Restore the card content const iconClass = $card.attr('data-icon-class') || 'bx bx-file'; const $newIcon = $('').addClass('icon').addClass(iconClass); - $card.empty().append($newIcon, finalTitle); - + $card.text(finalTitle); + $card.prepend($newIcon); + // Re-attach click handler for quick edit (for existing cards) $card.on('click', () => appContext.triggerCommand("openInPopup", { noteIdOrPath: noteId })); }; From d0ea6d9e8d943d6ab3fb8e7b51504962bb23806b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 21 Jul 2025 14:32:03 +0300 Subject: [PATCH 46/53] feat(views/board): use same note title editing mechanism for insert above/below --- .../widgets/view_widgets/board_view/api.ts | 7 +++++-- .../view_widgets/board_view/context_menu.ts | 8 +++++--- .../widgets/view_widgets/board_view/index.ts | 20 ++++++++++++++++++- 3 files changed, 29 insertions(+), 6 deletions(-) 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 cd94abce9..2cf6b8cc2 100644 --- a/apps/client/src/widgets/view_widgets/board_view/api.ts +++ b/apps/client/src/widgets/view_widgets/board_view/api.ts @@ -1,7 +1,7 @@ import appContext from "../../../components/app_context"; import FNote from "../../../entities/fnote"; import attributes from "../../../services/attributes"; -import bulk_action, { executeBulkActions } from "../../../services/bulk_action"; +import { executeBulkActions } from "../../../services/bulk_action"; import note_create from "../../../services/note_create"; import ViewModeStorage from "../view_mode_storage"; import { BoardData } from "./config"; @@ -40,7 +40,8 @@ export default class BoardApi { const { note } = await note_create.createNote(this._parentNoteId, { activate: false, targetBranchId: relativeToBranchId, - target: direction + target: direction, + title: "New item" }); if (!note) { @@ -52,6 +53,8 @@ export default class BoardApi { if (open) { this.openNote(noteId); } + + return note; } async renameColumn(oldValue: string, newValue: string, noteIds: string[]) { 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 9ee9909e0..f5e792d53 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 @@ -4,13 +4,15 @@ 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 }: ShowNoteContextMenuArgs) { +export function setupContextMenu({ $container, api, boardView }: ShowNoteContextMenuArgs) { $container.on("contextmenu", ".board-note", showNoteContextMenu); $container.on("contextmenu", ".board-column", showColumnContextMenu); @@ -71,12 +73,12 @@ export function setupContextMenu({ $container, api }: ShowNoteContextMenuArgs) { { title: t("board_view.insert-above"), uiIcon: "bx bx-list-plus", - handler: () => api.insertRowAtPosition(column, branchId, "before") + handler: () => boardView.insertItemAtPosition(column, branchId, "before") }, { title: t("board_view.insert-below"), uiIcon: "bx bx-empty", - handler: () => api.insertRowAtPosition(column, branchId, "after") + handler: () => boardView.insertItemAtPosition(column, branchId, "after") }, { title: "----" }, { 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 60e347320..8d28051dc 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -311,7 +311,8 @@ export default class BoardView extends ViewMode { setupContextMenu({ $container: this.$container, - api: this.api + api: this.api, + boardView: this }); // Setup column title editing and add column functionality @@ -437,6 +438,23 @@ 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); } From ff01656268c7c67ff7fde372e83b6ec0e7a876ad Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 21 Jul 2025 14:32:18 +0300 Subject: [PATCH 47/53] chore(vscode): set up NX LLM integration --- .github/instructions/nx.instructions.md | 40 +++++++++++++++++++++++++ .vscode/mcp.json | 8 +++++ .vscode/settings.json | 3 +- 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 .github/instructions/nx.instructions.md create mode 100644 .vscode/mcp.json diff --git a/.github/instructions/nx.instructions.md b/.github/instructions/nx.instructions.md new file mode 100644 index 000000000..fd81bf2ee --- /dev/null +++ b/.github/instructions/nx.instructions.md @@ -0,0 +1,40 @@ +--- +applyTo: '**' +--- + +// This file is automatically generated by Nx Console + +You are in an nx workspace using Nx 21.2.4 and pnpm as the package manager. + +You have access to the Nx MCP server and the tools it provides. Use them. Follow these guidelines in order to best help the user: + +# General Guidelines +- When answering questions, use the nx_workspace tool first to gain an understanding of the workspace architecture +- For questions around nx configuration, best practices or if you're unsure, use the nx_docs tool to get relevant, up-to-date docs!! Always use this instead of assuming things about nx configuration +- If the user needs help with an Nx configuration or project graph error, use the 'nx_workspace' tool to get any errors +- To help answer questions about the workspace structure or simply help with demonstrating how tasks depend on each other, use the 'nx_visualize_graph' tool + +# Generation Guidelines +If the user wants to generate something, use the following flow: + +- learn about the nx workspace and any specifics the user needs by using the 'nx_workspace' tool and the 'nx_project_details' tool if applicable +- get the available generators using the 'nx_generators' tool +- decide which generator to use. If no generators seem relevant, check the 'nx_available_plugins' tool to see if the user could install a plugin to help them +- get generator details using the 'nx_generator_schema' tool +- you may use the 'nx_docs' tool to learn more about a specific generator or technology if you're unsure +- decide which options to provide in order to best complete the user's request. Don't make any assumptions and keep the options minimalistic +- open the generator UI using the 'nx_open_generate_ui' tool +- wait for the user to finish the generator +- read the generator log file using the 'nx_read_generator_log' tool +- use the information provided in the log file to answer the user's question or continue with what they were doing + +# Running Tasks Guidelines +If the user wants help with tasks or commands (which include keywords like "test", "build", "lint", or other similar actions), use the following flow: +- Use the 'nx_current_running_tasks_details' tool to get the list of tasks (this can include tasks that were completed, stopped or failed). +- If there are any tasks, ask the user if they would like help with a specific task then use the 'nx_current_running_task_output' tool to get the terminal output for that task/command +- Use the terminal output from 'nx_current_running_task_output' to see what's wrong and help the user fix their problem. Use the appropriate tools if necessary +- If the user would like to rerun the task or command, always use `nx run ` to rerun in the terminal. This will ensure that the task will run in the nx context and will be run the same way it originally executed +- If the task was marked as "continuous" do not offer to rerun the task. This task is already running and the user can see the output in the terminal. You can use 'nx_current_running_task_output' to get the output of the task to verify the output. + + + diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 000000000..28994bb29 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,8 @@ +{ + "servers": { + "nx-mcp": { + "type": "http", + "url": "http://localhost:9461/mcp" + } + } +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 9ee96f4c1..4ee21bb3c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -35,5 +35,6 @@ "docs/**/*.png": true, "apps/server/src/assets/doc_notes/**": true, "apps/edit-docs/demo/**": true - } + }, + "nxConsole.generateAiAgentRules": true } \ No newline at end of file From 86911100df03528fa92282ab3ec1016b9d987dce Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 21 Jul 2025 14:40:35 +0300 Subject: [PATCH 48/53] refactor(views/board): use single point for obtaining status attribute --- .../widgets/view_widgets/board_view/api.ts | 19 +++++++++++++------ .../widgets/view_widgets/board_view/index.ts | 8 ++++---- 2 files changed, 17 insertions(+), 10 deletions(-) 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 2cf6b8cc2..e579af428 100644 --- a/apps/client/src/widgets/view_widgets/board_view/api.ts +++ b/apps/client/src/widgets/view_widgets/board_view/api.ts @@ -14,18 +14,23 @@ export default class BoardApi { private _parentNoteId: string, private viewStorage: ViewModeStorage, private byColumn: ColumnMap, - private persistedData: BoardData) {} + 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 changeColumn(noteId: string, newColumn: string) { - await attributes.setLabel(noteId, "status", newColumn); + await attributes.setLabel(noteId, this._statusAttribute, newColumn); } openNote(noteId: string) { @@ -62,7 +67,7 @@ export default class BoardApi { await executeBulkActions(noteIds, [ { name: "updateLabelValue", - labelName: "status", + labelName: this._statusAttribute, labelValue: newValue } ]); @@ -82,7 +87,7 @@ export default class BoardApi { await executeBulkActions(noteIds, [ { name: "deleteLabel", - labelName: "status" + labelName: this._statusAttribute } ]); @@ -106,8 +111,10 @@ export default class BoardApi { } static async build(parentNote: FNote, viewStorage: ViewModeStorage) { + const statusAttribute = "status"; // This should match the attribute used for grouping + let persistedData = await viewStorage.restore() ?? {}; - const { byColumn, newPersistedData } = await getBoardData(parentNote, "status", persistedData); + const { byColumn, newPersistedData } = await getBoardData(parentNote, statusAttribute, persistedData); const columns = Array.from(byColumn.keys()) || []; if (newPersistedData) { @@ -115,7 +122,7 @@ export default class BoardApi { viewStorage.store(persistedData); } - return new BoardApi(columns, parentNote.noteId, viewStorage, byColumn, persistedData); + return new BoardApi(columns, parentNote.noteId, viewStorage, byColumn, persistedData, statusAttribute); } } 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 8d28051dc..19f7f9bd9 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -425,7 +425,7 @@ export default class BoardView extends ViewMode { if (newNote) { // Set the status label to place it in the correct column - await attributeService.setLabel(newNote.noteId, "status", column); + await this.api?.changeColumn(newNote.noteId, column); // Refresh the board to show the new item await this.renderList(); @@ -442,7 +442,7 @@ export default class BoardView extends ViewMode { 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(); @@ -540,8 +540,8 @@ 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 === "status" && this.noteIds.includes(attr.noteId!)) || + // 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) From 08a93d81d7ae0a6b9b113212ec51f041537d72eb Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 21 Jul 2025 14:43:54 +0300 Subject: [PATCH 49/53] feat(views/board): allow changing group by attribute --- apps/client/src/widgets/view_widgets/board_view/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e579af428..66bcc0f10 100644 --- a/apps/client/src/widgets/view_widgets/board_view/api.ts +++ b/apps/client/src/widgets/view_widgets/board_view/api.ts @@ -111,7 +111,7 @@ export default class BoardApi { } static async build(parentNote: FNote, viewStorage: ViewModeStorage) { - const statusAttribute = "status"; // This should match the attribute used for grouping + const statusAttribute = parentNote.getLabelValue("board:groupBy") ?? "status"; let persistedData = await viewStorage.restore() ?? {}; const { byColumn, newPersistedData } = await getBoardData(parentNote, statusAttribute, persistedData); From 2384fdbaada3a54076e499fe2a1bb90c22f51a9f Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 21 Jul 2025 14:45:06 +0300 Subject: [PATCH 50/53] chore(views/board): fix type errors --- apps/client/src/widgets/floating_buttons/help_button.ts | 3 ++- .../src/widgets/ribbon_widgets/book_properties_config.ts | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/client/src/widgets/floating_buttons/help_button.ts b/apps/client/src/widgets/floating_buttons/help_button.ts index 54e4556ff..d5b643116 100644 --- a/apps/client/src/widgets/floating_buttons/help_button.ts +++ b/apps/client/src/widgets/floating_buttons/help_button.ts @@ -35,7 +35,8 @@ export const byBookType: Record = { grid: "8QqnMzx393bx", calendar: "xWbu3jpNWapp", table: "2FvYrpmOXm29", - geoMap: "81SGnPGMk7Xc" + geoMap: "81SGnPGMk7Xc", + board: null }; export default class ContextualHelpButton extends NoteContextAwareWidget { diff --git a/apps/client/src/widgets/ribbon_widgets/book_properties_config.ts b/apps/client/src/widgets/ribbon_widgets/book_properties_config.ts index 6b8beece9..87c2b884d 100644 --- a/apps/client/src/widgets/ribbon_widgets/book_properties_config.ts +++ b/apps/client/src/widgets/ribbon_widgets/book_properties_config.ts @@ -101,5 +101,8 @@ export const bookPropertiesConfig: Record = { width: 65 } ] + }, + board: { + properties: [] } }; From 00cc1ffe746d9a3d41da56f3b81a4c1b7c71361c Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 21 Jul 2025 14:49:13 +0300 Subject: [PATCH 51/53] feat(views/board): add into view type switcher --- .../src/translations/en/translation.json | 3 ++- .../widgets/ribbon_widgets/book_properties.ts | 20 +++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 1ef41d3f0..073edc053 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -762,7 +762,8 @@ "invalid_view_type": "Invalid view type '{{type}}'", "calendar": "Calendar", "table": "Table", - "geo-map": "Geo Map" + "geo-map": "Geo Map", + "board": "Board" }, "edited_notes": { "no_edited_notes_found": "No edited notes on this day yet...", diff --git a/apps/client/src/widgets/ribbon_widgets/book_properties.ts b/apps/client/src/widgets/ribbon_widgets/book_properties.ts index 144491442..bcb39e6ca 100644 --- a/apps/client/src/widgets/ribbon_widgets/book_properties.ts +++ b/apps/client/src/widgets/ribbon_widgets/book_properties.ts @@ -5,6 +5,16 @@ import type FNote from "../../entities/fnote.js"; import type { EventData } from "../../components/app_context.js"; import { bookPropertiesConfig, BookProperty } from "./book_properties_config.js"; import attributes from "../../services/attributes.js"; +import type { ViewTypeOptions } from "../../services/note_list_renderer.js"; + +const VIEW_TYPE_MAPPINGS: Record = { + grid: t("book_properties.grid"), + list: t("book_properties.list"), + calendar: t("book_properties.calendar"), + table: t("book_properties.table"), + geoMap: t("book_properties.geo-map"), + board: t("book_properties.board") +}; const TPL = /*html*/`
@@ -41,11 +51,9 @@ const TPL = /*html*/` ${t("book_properties.view_type")}:   
@@ -115,7 +123,7 @@ export default class BookPropertiesWidget extends NoteContextAwareWidget { return; } - if (!["list", "grid", "calendar", "table", "geoMap"].includes(type)) { + if (!VIEW_TYPE_MAPPINGS.hasOwnProperty(type)) { throw new Error(t("book_properties.invalid_view_type", { type })); } From 8b6826ffa46b4bb157f312083e6ea46069c37d50 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 21 Jul 2025 14:52:29 +0300 Subject: [PATCH 52/53] feat(views/board): react to changes in "groupBy" --- apps/client/src/widgets/view_widgets/board_view/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 19f7f9bd9..cb335ac8f 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -547,7 +547,9 @@ export default class BoardView extends ViewMode { // 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 attachment change - loadResults.getAttachmentRows().some(att => att.ownerId === this.parentNote.noteId && att.title === "board.json"); + 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 From ec021be16c646ad8cbc8fe04d7d3bf60f1fea67e Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 21 Jul 2025 15:01:55 +0300 Subject: [PATCH 53/53] feat(views/board): display even if no children --- apps/client/src/components/note_context.ts | 5 +++-- apps/client/src/widgets/type_widgets/book.ts | 9 ++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/client/src/components/note_context.ts b/apps/client/src/components/note_context.ts index 75c66b1bc..1bc4e5498 100644 --- a/apps/client/src/components/note_context.ts +++ b/apps/client/src/components/note_context.ts @@ -325,8 +325,9 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded"> return false; } - // Some book types must always display a note list, even if no children. - if (["calendar", "table", "geoMap"].includes(note.getLabelValue("viewType") ?? "")) { + // Collections must always display a note list, even if no children. + const viewType = note.getLabelValue("viewType") ?? "grid"; + if (!["list", "grid"].includes(viewType)) { return true; } diff --git a/apps/client/src/widgets/type_widgets/book.ts b/apps/client/src/widgets/type_widgets/book.ts index cc8323e1f..ba32fd3a6 100644 --- a/apps/client/src/widgets/type_widgets/book.ts +++ b/apps/client/src/widgets/type_widgets/book.ts @@ -45,12 +45,11 @@ export default class BookTypeWidget extends TypeWidget { } switch (this.note?.getAttributeValue("label", "viewType")) { - case "calendar": - case "table": - case "geoMap": - return false; - default: + case "list": + case "grid": return true; + default: + return false; } }