mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 10:26:08 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			566 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			566 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import { setupHorizontalScrollViaWheel } from "../../widget_utils";
 | |
| import ViewMode, { ViewModeArgs } from "../view_mode";
 | |
| import attributeService from "../../../services/attributes";
 | |
| 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";
 | |
| import { DifferentialBoardRenderer } from "./differential_renderer";
 | |
| 
 | |
| const TPL = /*html*/`
 | |
| <div class="board-view">
 | |
|     <style>
 | |
|         .board-view {
 | |
|             overflow-x: auto;
 | |
|             position: relative;
 | |
|             height: 100%;
 | |
|             user-select: none;
 | |
|         }
 | |
| 
 | |
|         .board-view-container {
 | |
|             height: 100%;
 | |
|             display: flex;
 | |
|             gap: 1.5em;
 | |
|             padding: 1em;
 | |
|         }
 | |
| 
 | |
|         .board-view-container .board-column {
 | |
|             width: 250px;
 | |
|             flex-shrink: 0;
 | |
|             min-height: 200px;
 | |
|             border: 2px solid transparent;
 | |
|             border-radius: 8px;
 | |
|             padding: 0.5em;
 | |
|             background-color: var(--accented-background-color);
 | |
|             transition: border-color 0.2s ease;
 | |
|             overflow-y: auto;
 | |
|         }
 | |
| 
 | |
|         .board-view-container .board-column.drag-over {
 | |
|             border-color: var(--main-text-color);
 | |
|             background-color: var(--hover-item-background-color);
 | |
|         }
 | |
| 
 | |
|         .board-view-container .board-column h3 {
 | |
|             font-size: 1em;
 | |
|             margin-bottom: 0.75em;
 | |
|             padding: 0.5em 0.5em 0.5em 0.5em;
 | |
|             border-bottom: 1px solid var(--main-border-color);
 | |
|             cursor: pointer;
 | |
|             position: relative;
 | |
|             transition: background-color 0.2s ease, border-radius 0.2s ease;
 | |
|             display: flex;
 | |
|             align-items: center;
 | |
|             justify-content: space-between;
 | |
|             box-sizing: border-box;
 | |
|             background-color: transparent;
 | |
|         }
 | |
| 
 | |
|         .board-view-container .board-column h3:hover {
 | |
|             background-color: var(--hover-item-background-color);
 | |
|             border-radius: 4px;
 | |
|         }
 | |
| 
 | |
|         .board-view-container .board-column h3.editing {
 | |
|             background-color: var(--main-background-color);
 | |
|             border: 1px solid var(--main-text-color);
 | |
|             border-radius: 4px;
 | |
|         }
 | |
| 
 | |
|         .board-view-container .board-column h3 input {
 | |
|             background: transparent;
 | |
|             border: none;
 | |
|             outline: none;
 | |
|             font-size: inherit;
 | |
|             font-weight: inherit;
 | |
|             color: inherit;
 | |
|             width: 100%;
 | |
|             font-family: inherit;
 | |
|         }
 | |
| 
 | |
|         .board-view-container .board-column h3 .edit-icon {
 | |
|             opacity: 0;
 | |
|             margin-left: 0.5em;
 | |
|             transition: opacity 0.2s ease;
 | |
|             color: var(--muted-text-color);
 | |
|         }
 | |
| 
 | |
|         .board-view-container .board-column h3:hover .edit-icon {
 | |
|             opacity: 1;
 | |
|         }
 | |
| 
 | |
|         .board-view-container .board-column h3.editing .edit-icon {
 | |
|             display: none;
 | |
|         }
 | |
| 
 | |
|         .board-view-container .board-note {
 | |
|             box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.25);
 | |
|             margin: 0.65em 0;
 | |
|             padding: 0.5em;
 | |
|             border-radius: 5px;
 | |
|             cursor: move;
 | |
|             position: relative;
 | |
|             background-color: var(--main-background-color);
 | |
|             border: 1px solid var(--main-border-color);
 | |
|             transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.15s ease;
 | |
|             opacity: 1;
 | |
|         }
 | |
| 
 | |
|         .board-view-container .board-note.fade-in {
 | |
|             animation: fadeIn 0.15s ease-in;
 | |
|         }
 | |
| 
 | |
|         .board-view-container .board-note.fade-out {
 | |
|             animation: fadeOut 0.15s ease-out forwards;
 | |
|         }
 | |
| 
 | |
|         @keyframes fadeIn {
 | |
|             from { opacity: 0; transform: translateY(-10px); }
 | |
|             to { opacity: 1; transform: translateY(0); }
 | |
|         }
 | |
| 
 | |
|         @keyframes fadeOut {
 | |
|             from { opacity: 1; transform: translateY(0); }
 | |
|             to { opacity: 0; transform: translateY(-10px); }
 | |
|         }
 | |
| 
 | |
|         .board-view-container .board-note:hover {
 | |
|             transform: translateY(-2px);
 | |
|             box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.35);
 | |
|         }
 | |
| 
 | |
|         .board-view-container .board-note.dragging {
 | |
|             opacity: 0.8;
 | |
|             transform: rotate(5deg);
 | |
|             z-index: 1000;
 | |
|             box-shadow: 4px 8px 16px rgba(0, 0, 0, 0.5);
 | |
|         }
 | |
| 
 | |
|         .board-view-container .board-note.editing {
 | |
|             box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.35);
 | |
|             border-color: var(--main-text-color);
 | |
|         }
 | |
| 
 | |
|         .board-view-container .board-note.editing input {
 | |
|             background: transparent;
 | |
|             border: none;
 | |
|             outline: none;
 | |
|             font-family: inherit;
 | |
|             font-size: inherit;
 | |
|             color: inherit;
 | |
|             width: 100%;
 | |
|             padding: 0;
 | |
|         }
 | |
| 
 | |
|         .board-view-container .board-note .icon {
 | |
|             margin-right: 0.25em;
 | |
|         }
 | |
| 
 | |
|         .board-drop-indicator {
 | |
|             height: 3px;
 | |
|             background-color: var(--main-text-color);
 | |
|             border-radius: 2px;
 | |
|             margin: 0.25em 0;
 | |
|             opacity: 0;
 | |
|             transition: opacity 0.2s ease;
 | |
|         }
 | |
| 
 | |
|         .board-drop-indicator.show {
 | |
|             opacity: 1;
 | |
|         }
 | |
| 
 | |
|         .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;
 | |
|         }
 | |
| 
 | |
|         .board-add-column {
 | |
|             width: 180px;
 | |
|             flex-shrink: 0;
 | |
|             height: 60px;
 | |
|             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);
 | |
|             font-size: 0.9em;
 | |
|             align-self: flex-start;
 | |
|         }
 | |
| 
 | |
|         .board-add-column:hover {
 | |
|             border-color: var(--main-text-color);
 | |
|             color: var(--main-text-color);
 | |
|             background-color: var(--hover-item-background-color);
 | |
|         }
 | |
| 
 | |
|         .board-add-column.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;
 | |
|         }
 | |
| 
 | |
|         .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;
 | |
|         }
 | |
|     </style>
 | |
| 
 | |
|     <div class="board-view-container"></div>
 | |
| </div>
 | |
| `;
 | |
| 
 | |
| export default class BoardView extends ViewMode<BoardData> {
 | |
| 
 | |
|     private $root: JQuery<HTMLElement>;
 | |
|     private $container: JQuery<HTMLElement>;
 | |
|     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
 | |
|         };
 | |
| 
 | |
|         args.$parent.append(this.$root);
 | |
|     }
 | |
| 
 | |
|     async renderList(): Promise<JQuery<HTMLElement> | 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,
 | |
|             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,
 | |
|             boardView: this
 | |
|         });
 | |
| 
 | |
|         // Setup column title editing and add column functionality
 | |
|         this.setupBoardInteractions();
 | |
|     }
 | |
| 
 | |
|     private setupBoardInteractions() {
 | |
|         // Handle column title editing
 | |
|         this.$container.on('click', 'h3[data-column-value]', (e) => {
 | |
|             e.stopPropagation();
 | |
|             const $titleEl = $(e.currentTarget);
 | |
|             const columnValue = $titleEl.attr('data-column-value');
 | |
|             if (columnValue) {
 | |
|                 const columnItems = this.api?.getColumn(columnValue) || [];
 | |
|                 this.startEditingColumnTitle($titleEl, columnValue, columnItems);
 | |
|             }
 | |
|         });
 | |
| 
 | |
|         // Handle add column button
 | |
|         this.$container.on('click', '.board-add-column', (e) => {
 | |
|             e.stopPropagation();
 | |
|             this.startCreatingNewColumn($(e.currentTarget));
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     private createTitleStructure(title: string): { $titleText: JQuery<HTMLElement>; $editIcon: JQuery<HTMLElement> } {
 | |
|         const $titleText = $("<span>").text(title);
 | |
|         const $editIcon = $("<span>")
 | |
|             .addClass("edit-icon icon bx bx-edit-alt")
 | |
|             .attr("title", "Click to edit column title");
 | |
| 
 | |
|         return { $titleText, $editIcon };
 | |
|     }
 | |
| 
 | |
|     private startEditingColumnTitle($titleEl: JQuery<HTMLElement>, 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 = $("<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,
 | |
|                 title: "New item"
 | |
|             });
 | |
| 
 | |
|             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();
 | |
| 
 | |
|                 // 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<void> {
 | |
|         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<HTMLElement>) {
 | |
|         if ($addColumnEl.hasClass("editing")) {
 | |
|             return; // Already editing
 | |
|         }
 | |
| 
 | |
|         $addColumnEl.addClass("editing");
 | |
| 
 | |
|         const $input = $("<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('<span class="icon bx bx-plus"></span>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 === "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;
 | |
|     }
 | |
| 
 | |
|     private onSave() {
 | |
|         this.viewStorage.store(this.persistentData);
 | |
|     }
 | |
| 
 | |
| }
 |