From d98be19c9a1a25af23d73e1a192c1eb830c3d83b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 21 Jul 2025 11:13:41 +0300 Subject: [PATCH] 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; }