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"; const TPL = /*html*/`
`; 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; private api?: BoardApi; constructor(args: ViewModeArgs) { super(args, "board"); this.$root = $(TPL); setupHorizontalScrollViaWheel(this.$root); this.$container = this.$root.find(".board-view-container"); this.spacedUpdate = new SpacedUpdate(() => this.onSave(), 5_000); this.persistentData = { columns: [] }; args.$parent.append(this.$root); } async renderList(): Promise | undefined> { this.$container.empty(); await this.renderBoard(this.$container[0]); return this.$root; } private async renderBoard(el: HTMLElement) { this.api = await BoardApi.build(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; } // 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.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.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) => { e.stopPropagation(); this.startCreatingNewColumn($addColumnEl); }); $(el).append($addColumnEl); } 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"); // 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"); }); } 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 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}"`); } catch (error) { console.error("Failed to update note position:", 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"); } 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 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); } } 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!))) { 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; } return false; } private onSave() { this.viewStorage.store(this.persistentData); } }