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);
}
}