import { setupHorizontalScrollViaWheel } from "../../widget_utils"; import ViewMode, { ViewModeArgs } from "../view_mode"; import noteCreateService from "../../../services/note_create"; import { EventData } from "../../../components/app_context"; import { BoardData } from "./config"; import SpacedUpdate from "../../../services/spaced_update"; import { setupContextMenu } from "./context_menu"; import BoardApi from "./api"; import { BoardDragHandler, DragContext } from "./drag_handler"; import { DifferentialBoardRenderer } from "./differential_renderer"; const TPL = /*html*/`
`; export default class BoardView extends ViewMode { private $root: JQuery; private $container: JQuery; private spacedUpdate: SpacedUpdate; private dragContext: DragContext; private persistentData: BoardData; private api?: BoardApi; private dragHandler?: BoardDragHandler; private renderer?: DifferentialBoardRenderer; constructor(args: ViewModeArgs) { super(args, "board"); this.$root = $(TPL); setupHorizontalScrollViaWheel(this.$root); this.$container = this.$root.find(".board-view-container"); this.spacedUpdate = new SpacedUpdate(() => this.onSave(), 5_000); this.persistentData = { columns: [] }; this.dragContext = { draggedNote: null, draggedBranch: null, draggedNoteElement: null, draggedColumn: null, draggedColumnElement: null }; args.$parent.append(this.$root); } async renderList(): Promise | undefined> { if (!this.renderer) { // First time setup this.$container.empty(); await this.initializeRenderer(); } await this.renderer!.renderBoard(); return this.$root; } private async initializeRenderer() { this.api = await BoardApi.build(this.parentNote, this.viewStorage); this.dragHandler = new BoardDragHandler( this.$container, this.api, this.dragContext ); this.renderer = new DifferentialBoardRenderer( this.$container, this.api, this.dragHandler, (column: string) => this.createNewItem(column), this.parentNote, this.viewStorage, () => this.refreshApi() ); setupContextMenu({ $container: this.$container, api: this.api, boardView: this }); // Setup column title editing and add column functionality this.setupBoardInteractions(); } private async refreshApi(): Promise { if (!this.api) { throw new Error("API not initialized"); } await this.api.refresh(this.parentNote); } private setupBoardInteractions() { // Handle column title editing with click detection that works with dragging this.$container.on('mousedown', 'h3[data-column-value]', (e) => { const $titleEl = $(e.currentTarget); const startTime = Date.now(); let hasMoved = false; const startX = e.clientX; const startY = e.clientY; const handleMouseMove = (moveEvent: JQuery.MouseMoveEvent) => { const deltaX = Math.abs(moveEvent.clientX - startX); const deltaY = Math.abs(moveEvent.clientY - startY); if (deltaX > 5 || deltaY > 5) { hasMoved = true; } }; const handleMouseUp = (upEvent: JQuery.MouseUpEvent) => { const duration = Date.now() - startTime; $(document).off('mousemove', handleMouseMove); $(document).off('mouseup', handleMouseUp); // If it was a quick click without much movement, treat as edit request if (duration < 500 && !hasMoved && upEvent.button === 0) { const columnValue = $titleEl.attr('data-column-value'); if (columnValue) { const columnItems = this.api?.getColumn(columnValue) || []; this.startEditingColumnTitle($titleEl, columnValue, columnItems); } } }; $(document).on('mousemove', handleMouseMove); $(document).on('mouseup', handleMouseUp); }); // Handle add column button this.$container.on('click', '.board-add-column', (e) => { e.stopPropagation(); this.startCreatingNewColumn($(e.currentTarget)); }); } private createTitleStructure(title: string): { $titleText: JQuery; $editIcon: JQuery } { const $titleText = $("").text(title); const $editIcon = $("") .addClass("edit-icon icon bx bx-edit-alt") .attr("title", "Click to edit column title"); return { $titleText, $editIcon }; } private startEditingColumnTitle($titleEl: JQuery, columnValue: string, columnItems: { branch: any; note: any; }[]) { if ($titleEl.hasClass("editing")) { return; // Already editing } const $titleSpan = $titleEl.find("span").first(); // Get the text span const currentTitle = $titleSpan.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) // This will trigger onEntitiesReloaded which will automatically refresh the board await this.api?.renameColumn(oldValue, newValue, noteIds); } catch (error) { console.error("Failed to rename column:", error); } } private async createNewItem(column: string) { try { // Get the parent note path const parentNotePath = this.parentNote.noteId; // Create a new note as a child of the parent note const { note: newNote } = await noteCreateService.createNote(parentNotePath, { activate: false, title: "New item" }); if (newNote) { // Set the status label to place it in the correct column await this.api?.changeColumn(newNote.noteId, column); // Refresh the board to show the new item await this.renderList(); // Start inline editing of the newly created card this.startInlineEditingCard(newNote.noteId); } } catch (error) { console.error("Failed to create new item:", error); } } async insertItemAtPosition(column: string, relativeToBranchId: string, direction: "before" | "after"): Promise { try { // Create the note without opening it const newNote = await this.api?.insertRowAtPosition(column, relativeToBranchId, direction, false); if (newNote) { // Refresh the board to show the new item await this.renderList(); // Start inline editing of the newly created card this.startInlineEditingCard(newNote.noteId); } } catch (error) { console.error("Failed to insert new item:", error); } } private startInlineEditingCard(noteId: string) { this.renderer?.startInlineEditing(noteId); } forceFullRefresh() { this.renderer?.forceFullRender(); return this.renderList(); } 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">) { // Check if any changes affect our board const hasRelevantChanges = // React to changes in status attribute for notes in this board loadResults.getAttributeRows().some(attr => attr.name === this.api?.statusAttribute && this.noteIds.includes(attr.noteId!)) || // React to changes in note title loadResults.getNoteIds().some(noteId => this.noteIds.includes(noteId)) || // React to changes in branches for subchildren (e.g., moved, added, or removed notes) loadResults.getBranchRows().some(branch => this.noteIds.includes(branch.noteId!)) || // React to changes in note icon or color. loadResults.getAttributeRows().some(attr => [ "iconClass", "color" ].includes(attr.name ?? "") && this.noteIds.includes(attr.noteId ?? "")) || // React to attachment change loadResults.getAttachmentRows().some(att => att.ownerId === this.parentNote.noteId && att.title === "board.json") || // React to changes in "groupBy" loadResults.getAttributeRows().some(attr => attr.name === "board:groupBy" && attr.noteId === this.parentNote.noteId); if (hasRelevantChanges && this.renderer) { // Use differential rendering with API refresh await this.renderer.renderBoard(true); } // Don't trigger full view refresh - let differential renderer handle it return false; } private onSave() { this.viewStorage.store(this.persistentData); } }