feat(views/board): set up differential renderer

This commit is contained in:
Elian Doran 2025-07-21 11:13:41 +03:00
parent 4826898c55
commit d98be19c9a
No known key found for this signature in database
3 changed files with 407 additions and 115 deletions

View File

@ -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<HTMLElement>;
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<BoardData>;
constructor(
$container: JQuery<HTMLElement>,
api: BoardApi,
dragHandler: BoardDragHandler,
onCreateNewItem: (column: string) => void,
parentNote: FNote,
viewStorage: ViewModeStorage<BoardData>
) {
this.$container = $container;
this.api = api;
this.dragHandler = dragHandler;
this.onCreateNewItem = onCreateNewItem;
this.parentNote = parentNote;
this.viewStorage = viewStorage;
}
async renderBoard(refreshApi: boolean = false): Promise<void> {
// 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<void> {
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<void> {
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<void> {
// 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<HTMLElement>, targetIndex: number, $container: JQuery<HTMLElement>): 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<HTMLElement> {
const $columnEl = $("<div>")
.addClass("board-column")
.attr("data-column", column);
// Create header
const $titleEl = $("<h3>").attr("data-column-value", column);
const $titleText = $("<span>").text(column);
const $editIcon = $("<span>")
.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 = $("<div>")
.addClass("board-new-item")
.attr("data-column", column)
.html('<span class="icon bx bx-plus"></span>New item');
$newItemEl.on("click", () => this.onCreateNewItem(column));
$columnEl.append($newItemEl);
return $columnEl;
}
private createCard(note: any, branch: any, column: string): JQuery<HTMLElement> {
const $iconEl = $("<span>")
.addClass("icon")
.addClass(note.getIcon());
const $noteEl = $("<div>")
.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 = $("<div>")
.addClass("board-add-column")
.html('<span class="icon bx bx-plus"></span>Add Column');
this.$container.append($addColumnEl);
}
}
forceFullRender(): void {
this.lastState = null;
if (this.updateTimeout) {
clearTimeout(this.updateTimeout);
this.updateTimeout = null;
}
}
async flushPendingUpdates(): Promise<void> {
if (this.updateTimeout) {
clearTimeout(this.updateTimeout);
this.updateTimeout = null;
await this.performUpdate();
}
}
}

View File

@ -35,6 +35,10 @@ export class BoardDragHandler {
this.setupTouchDrag($noteEl, note, branch);
}
updateApi(newApi: BoardApi) {
this.api = newApi;
}
private setupMouseDrag($noteEl: JQuery<HTMLElement>, note: any, branch: any) {
$noteEl.on("dragstart", (e) => {
this.context.draggedNote = note;

View File

@ -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*/`
<div class="board-view">
@ -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<BoardData> {
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<BoardData> {
}
async renderList(): Promise<JQuery<HTMLElement> | 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<BoardData> {
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 = $("<div>")
.addClass("board-column")
.attr("data-column", column);
const $titleEl = $("<h3>")
.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 = $("<span>")
.addClass("icon")
.addClass(note.getIcon());
const $noteEl = $("<div>")
.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 = $("<div>")
.addClass("board-new-item")
.attr("data-column", column)
.html('<span class="icon bx bx-plus"></span>New item');
$newItemEl.on("click", () => {
this.createNewItem(column);
});
$columnEl.append($newItemEl);
$(el).append($columnEl);
}
// Add "Add Column" button at the end
const $addColumnEl = $("<div>")
.addClass("board-add-column")
.html('<span class="icon bx bx-plus"></span>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<HTMLElement>; $editIcon: JQuery<HTMLElement> } {
@ -364,13 +330,6 @@ export default class BoardView extends ViewMode<BoardData> {
return { $titleText, $editIcon };
}
private setupColumnTitleEdit($titleEl: JQuery<HTMLElement>, columnValue: string, columnItems: { branch: any; note: any; }[]) {
$titleEl.on("click", (e) => {
e.stopPropagation();
this.startEditingColumnTitle($titleEl, columnValue, columnItems);
});
}
private startEditingColumnTitle($titleEl: JQuery<HTMLElement>, columnValue: string, columnItems: { branch: any; note: any; }[]) {
if ($titleEl.hasClass("editing")) {
return; // Already editing
@ -461,6 +420,11 @@ export default class BoardView extends ViewMode<BoardData> {
}
}
forceFullRefresh() {
this.renderer?.forceFullRender();
return this.renderList();
}
private startCreatingNewColumn($addColumnEl: JQuery<HTMLElement>) {
if ($addColumnEl.hasClass("editing")) {
return; // Already editing
@ -535,26 +499,23 @@ export default class BoardView extends ViewMode<BoardData> {
}
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;
}