Kanban board (#6402)

This commit is contained in:
Elian Doran 2025-07-21 18:45:41 +03:00 committed by GitHub
commit b08fda5e10
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1831 additions and 40 deletions

40
.github/instructions/nx.instructions.md vendored Normal file
View File

@ -0,0 +1,40 @@
---
applyTo: '**'
---
// This file is automatically generated by Nx Console
You are in an nx workspace using Nx 21.2.4 and pnpm as the package manager.
You have access to the Nx MCP server and the tools it provides. Use them. Follow these guidelines in order to best help the user:
# General Guidelines
- When answering questions, use the nx_workspace tool first to gain an understanding of the workspace architecture
- For questions around nx configuration, best practices or if you're unsure, use the nx_docs tool to get relevant, up-to-date docs!! Always use this instead of assuming things about nx configuration
- If the user needs help with an Nx configuration or project graph error, use the 'nx_workspace' tool to get any errors
- To help answer questions about the workspace structure or simply help with demonstrating how tasks depend on each other, use the 'nx_visualize_graph' tool
# Generation Guidelines
If the user wants to generate something, use the following flow:
- learn about the nx workspace and any specifics the user needs by using the 'nx_workspace' tool and the 'nx_project_details' tool if applicable
- get the available generators using the 'nx_generators' tool
- decide which generator to use. If no generators seem relevant, check the 'nx_available_plugins' tool to see if the user could install a plugin to help them
- get generator details using the 'nx_generator_schema' tool
- you may use the 'nx_docs' tool to learn more about a specific generator or technology if you're unsure
- decide which options to provide in order to best complete the user's request. Don't make any assumptions and keep the options minimalistic
- open the generator UI using the 'nx_open_generate_ui' tool
- wait for the user to finish the generator
- read the generator log file using the 'nx_read_generator_log' tool
- use the information provided in the log file to answer the user's question or continue with what they were doing
# Running Tasks Guidelines
If the user wants help with tasks or commands (which include keywords like "test", "build", "lint", or other similar actions), use the following flow:
- Use the 'nx_current_running_tasks_details' tool to get the list of tasks (this can include tasks that were completed, stopped or failed).
- If there are any tasks, ask the user if they would like help with a specific task then use the 'nx_current_running_task_output' tool to get the terminal output for that task/command
- Use the terminal output from 'nx_current_running_task_output' to see what's wrong and help the user fix their problem. Use the appropriate tools if necessary
- If the user would like to rerun the task or command, always use `nx run <taskId>` to rerun in the terminal. This will ensure that the task will run in the nx context and will be run the same way it originally executed
- If the task was marked as "continuous" do not offer to rerun the task. This task is already running and the user can see the output in the terminal. You can use 'nx_current_running_task_output' to get the output of the task to verify the output.

8
.vscode/mcp.json vendored Normal file
View File

@ -0,0 +1,8 @@
{
"servers": {
"nx-mcp": {
"type": "http",
"url": "http://localhost:9461/mcp"
}
}
}

View File

@ -35,5 +35,6 @@
"docs/**/*.png": true,
"apps/server/src/assets/doc_notes/**": true,
"apps/edit-docs/demo/**": true
}
},
"nxConsole.generateAiAgentRules": true
}

View File

@ -325,8 +325,9 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
return false;
}
// Some book types must always display a note list, even if no children.
if (["calendar", "table", "geoMap"].includes(note.getLabelValue("viewType") ?? "")) {
// Collections must always display a note list, even if no children.
const viewType = note.getLabelValue("viewType") ?? "grid";
if (!["list", "grid"].includes(viewType)) {
return true;
}

View File

@ -91,10 +91,10 @@ function parseActions(note: FNote) {
.filter((action) => !!action);
}
export async function executeBulkActions(parentNoteId: string, actions: BulkAction[]) {
export async function executeBulkActions(targetNoteIds: string[], actions: BulkAction[], includeDescendants = false) {
await server.post("bulk-action/execute", {
noteIds: [ parentNoteId ],
includeDescendants: true,
noteIds: targetNoteIds,
includeDescendants,
actions
});

View File

@ -1,4 +1,5 @@
import type FNote from "../entities/fnote.js";
import BoardView from "../widgets/view_widgets/board_view/index.js";
import CalendarView from "../widgets/view_widgets/calendar_view.js";
import GeoView from "../widgets/view_widgets/geo_view/index.js";
import ListOrGridView from "../widgets/view_widgets/list_or_grid_view.js";
@ -6,8 +7,9 @@ import TableView from "../widgets/view_widgets/table_view/index.js";
import type { ViewModeArgs } from "../widgets/view_widgets/view_mode.js";
import type ViewMode from "../widgets/view_widgets/view_mode.js";
const allViewTypes = ["list", "grid", "calendar", "table", "geoMap", "board"] as const;
export type ArgsWithoutNoteId = Omit<ViewModeArgs, "noteIds">;
export type ViewTypeOptions = "list" | "grid" | "calendar" | "table" | "geoMap";
export type ViewTypeOptions = typeof allViewTypes[number];
export default class NoteListRenderer {
@ -23,7 +25,7 @@ export default class NoteListRenderer {
#getViewType(parentNote: FNote): ViewTypeOptions {
const viewType = parentNote.getLabelValue("viewType");
if (!["list", "grid", "calendar", "table", "geoMap"].includes(viewType || "")) {
if (!(allViewTypes as readonly string[]).includes(viewType || "")) {
// when not explicitly set, decide based on the note type
return parentNote.type === "search" ? "list" : "grid";
} else {
@ -57,6 +59,8 @@ export default class NoteListRenderer {
return new TableView(args);
case "geoMap":
return new GeoView(args);
case "board":
return new BoardView(args);
case "list":
case "grid":
default:

View File

@ -762,7 +762,8 @@
"invalid_view_type": "Invalid view type '{{type}}'",
"calendar": "Calendar",
"table": "Table",
"geo-map": "Geo Map"
"geo-map": "Geo Map",
"board": "Board"
},
"edited_notes": {
"no_edited_notes_found": "No edited notes on this day yet...",
@ -1968,5 +1969,13 @@
},
"table_context_menu": {
"delete_row": "Delete row"
},
"board_view": {
"delete-note": "Delete Note",
"move-to": "Move to",
"insert-above": "Insert above",
"insert-below": "Insert below",
"delete-column": "Delete column",
"delete-column-confirmation": "Are you sure you want to delete this column? The corresponding attribute will be deleted in the notes under this column as well."
}
}

View File

@ -35,7 +35,8 @@ export const byBookType: Record<ViewTypeOptions, string | null> = {
grid: "8QqnMzx393bx",
calendar: "xWbu3jpNWapp",
table: "2FvYrpmOXm29",
geoMap: "81SGnPGMk7Xc"
geoMap: "81SGnPGMk7Xc",
board: null
};
export default class ContextualHelpButton extends NoteContextAwareWidget {

View File

@ -5,6 +5,16 @@ import type FNote from "../../entities/fnote.js";
import type { EventData } from "../../components/app_context.js";
import { bookPropertiesConfig, BookProperty } from "./book_properties_config.js";
import attributes from "../../services/attributes.js";
import type { ViewTypeOptions } from "../../services/note_list_renderer.js";
const VIEW_TYPE_MAPPINGS: Record<ViewTypeOptions, string> = {
grid: t("book_properties.grid"),
list: t("book_properties.list"),
calendar: t("book_properties.calendar"),
table: t("book_properties.table"),
geoMap: t("book_properties.geo-map"),
board: t("book_properties.board")
};
const TPL = /*html*/`
<div class="book-properties-widget">
@ -41,11 +51,9 @@ const TPL = /*html*/`
<span style="white-space: nowrap">${t("book_properties.view_type")}:&nbsp; &nbsp;</span>
<select class="view-type-select form-select form-select-sm">
<option value="grid">${t("book_properties.grid")}</option>
<option value="list">${t("book_properties.list")}</option>
<option value="calendar">${t("book_properties.calendar")}</option>
<option value="table">${t("book_properties.table")}</option>
<option value="geoMap">${t("book_properties.geo-map")}</option>
${Object.entries(VIEW_TYPE_MAPPINGS).map(([type, label]) => `
<option value="${type}">${label}</option>
`).join("")}
</select>
</div>
@ -115,7 +123,7 @@ export default class BookPropertiesWidget extends NoteContextAwareWidget {
return;
}
if (!["list", "grid", "calendar", "table", "geoMap"].includes(type)) {
if (!VIEW_TYPE_MAPPINGS.hasOwnProperty(type)) {
throw new Error(t("book_properties.invalid_view_type", { type }));
}

View File

@ -101,5 +101,8 @@ export const bookPropertiesConfig: Record<ViewTypeOptions, BookConfig> = {
width: 65
}
]
},
board: {
properties: []
}
};

View File

@ -8,6 +8,7 @@ import appContext, { type CommandNames, type CommandListenerData, type EventData
import froca from "../services/froca.js";
import attributeService from "../services/attributes.js";
import type NoteContext from "../components/note_context.js";
import { setupHorizontalScrollViaWheel } from "./widget_utils.js";
const isDesktop = utils.isDesktop();
@ -386,15 +387,7 @@ export default class TabRowWidget extends BasicWidget {
};
setupScrollEvents() {
this.$tabScrollingContainer.on('wheel', (event) => {
const wheelEvent = event.originalEvent as WheelEvent;
if (utils.isCtrlKey(event) || event.altKey || event.shiftKey) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
event.currentTarget.scrollLeft += wheelEvent.deltaY + wheelEvent.deltaX;
});
setupHorizontalScrollViaWheel(this.$tabScrollingContainer);
this.$scrollButtonLeft[0].addEventListener('click', () => this.scrollTabContainer(-210));
this.$scrollButtonRight[0].addEventListener('click', () => this.scrollTabContainer(210));

View File

@ -45,12 +45,11 @@ export default class BookTypeWidget extends TypeWidget {
}
switch (this.note?.getAttributeValue("label", "viewType")) {
case "calendar":
case "table":
case "geoMap":
return false;
default:
case "list":
case "grid":
return true;
default:
return false;
}
}

View File

@ -0,0 +1,128 @@
import appContext from "../../../components/app_context";
import FNote from "../../../entities/fnote";
import attributes from "../../../services/attributes";
import { executeBulkActions } from "../../../services/bulk_action";
import note_create from "../../../services/note_create";
import ViewModeStorage from "../view_mode_storage";
import { BoardData } from "./config";
import { ColumnMap, getBoardData } from "./data";
export default class BoardApi {
private constructor(
private _columns: string[],
private _parentNoteId: string,
private viewStorage: ViewModeStorage<BoardData>,
private byColumn: ColumnMap,
private persistedData: BoardData,
private _statusAttribute: string) {}
get columns() {
return this._columns;
}
get statusAttribute() {
return this._statusAttribute;
}
getColumn(column: string) {
return this.byColumn.get(column);
}
async changeColumn(noteId: string, newColumn: string) {
await attributes.setLabel(noteId, this._statusAttribute, newColumn);
}
openNote(noteId: string) {
appContext.triggerCommand("openInPopup", { noteIdOrPath: noteId });
}
async insertRowAtPosition(
column: string,
relativeToBranchId: string,
direction: "before" | "after",
open: boolean = true) {
const { note } = await note_create.createNote(this._parentNoteId, {
activate: false,
targetBranchId: relativeToBranchId,
target: direction,
title: "New item"
});
if (!note) {
throw new Error("Failed to create note");
}
const { noteId } = note;
await this.changeColumn(noteId, column);
if (open) {
this.openNote(noteId);
}
return note;
}
async renameColumn(oldValue: string, newValue: string, noteIds: string[]) {
// Change the value in the notes.
await executeBulkActions(noteIds, [
{
name: "updateLabelValue",
labelName: this._statusAttribute,
labelValue: newValue
}
]);
// Rename the column in the persisted data.
for (const column of this.persistedData.columns || []) {
if (column.value === oldValue) {
column.value = newValue;
}
}
await this.viewStorage.store(this.persistedData);
}
async removeColumn(column: string) {
// Remove the value from the notes.
const noteIds = this.byColumn.get(column)?.map(item => item.note.noteId) || [];
await executeBulkActions(noteIds, [
{
name: "deleteLabel",
labelName: this._statusAttribute
}
]);
this.persistedData.columns = (this.persistedData.columns ?? []).filter(col => col.value !== column);
this.viewStorage.store(this.persistedData);
}
async createColumn(columnValue: string) {
// Add the new column to persisted data if it doesn't exist
if (!this.persistedData.columns) {
this.persistedData.columns = [];
}
const existingColumn = this.persistedData.columns.find(col => col.value === columnValue);
if (!existingColumn) {
this.persistedData.columns.push({ value: columnValue });
await this.viewStorage.store(this.persistedData);
}
return columnValue;
}
static async build(parentNote: FNote, viewStorage: ViewModeStorage<BoardData>) {
const statusAttribute = parentNote.getLabelValue("board:groupBy") ?? "status";
let persistedData = await viewStorage.restore() ?? {};
const { byColumn, newPersistedData } = await getBoardData(parentNote, statusAttribute, persistedData);
const columns = Array.from(byColumn.keys()) || [];
if (newPersistedData) {
persistedData = newPersistedData;
viewStorage.store(persistedData);
}
return new BoardApi(columns, parentNote.noteId, viewStorage, byColumn, persistedData, statusAttribute);
}
}

View File

@ -0,0 +1,7 @@
export interface BoardColumnData {
value: string;
}
export interface BoardData {
columns?: BoardColumnData[];
}

View File

@ -0,0 +1,93 @@
import contextMenu, { ContextMenuEvent } from "../../../menus/context_menu.js";
import link_context_menu from "../../../menus/link_context_menu.js";
import branches from "../../../services/branches.js";
import dialog from "../../../services/dialog.js";
import { t } from "../../../services/i18n.js";
import BoardApi from "./api.js";
import type BoardView from "./index.js";
interface ShowNoteContextMenuArgs {
$container: JQuery<HTMLElement>;
api: BoardApi;
boardView: BoardView;
}
export function setupContextMenu({ $container, api, boardView }: ShowNoteContextMenuArgs) {
$container.on("contextmenu", ".board-note", showNoteContextMenu);
$container.on("contextmenu", ".board-column", showColumnContextMenu);
function showColumnContextMenu(event: ContextMenuEvent) {
event.preventDefault();
event.stopPropagation();
const $el = $(event.currentTarget);
const column = $el.closest(".board-column").data("column");
contextMenu.show({
x: event.pageX,
y: event.pageY,
items: [
{
title: t("board_view.delete-column"),
uiIcon: "bx bx-trash",
async handler() {
const confirmed = await dialog.confirm(t("board_view.delete-column-confirmation"));
if (!confirmed) {
return;
}
await api.removeColumn(column);
}
}
],
selectMenuItemHandler() {}
});
}
function showNoteContextMenu(event: ContextMenuEvent) {
event.preventDefault();
event.stopPropagation();
const $el = $(event.currentTarget);
const noteId = $el.data("note-id");
const branchId = $el.data("branch-id");
const column = $el.closest(".board-column").data("column");
if (!noteId) return;
contextMenu.show({
x: event.pageX,
y: event.pageY,
items: [
...link_context_menu.getItems(),
{ title: "----" },
{
title: t("board_view.move-to"),
uiIcon: "bx bx-transfer",
items: api.columns.map(columnToMoveTo => ({
title: columnToMoveTo,
enabled: columnToMoveTo !== column,
handler: () => api.changeColumn(noteId, columnToMoveTo)
}))
},
{ title: "----" },
{
title: t("board_view.insert-above"),
uiIcon: "bx bx-list-plus",
handler: () => boardView.insertItemAtPosition(column, branchId, "before")
},
{
title: t("board_view.insert-below"),
uiIcon: "bx bx-empty",
handler: () => boardView.insertItemAtPosition(column, branchId, "after")
},
{ title: "----" },
{
title: t("board_view.delete-note"),
uiIcon: "bx bx-trash",
handler: () => branches.deleteNotes([ branchId ], false, false)
}
],
selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, noteId),
});
}
}

View File

@ -0,0 +1,66 @@
import FBranch from "../../../entities/fbranch";
import FNote from "../../../entities/fnote";
import { BoardData } from "./config";
export type ColumnMap = Map<string, {
branch: FBranch;
note: FNote;
}[]>;
export async function getBoardData(parentNote: FNote, groupByColumn: string, persistedData: BoardData) {
const byColumn: ColumnMap = new Map();
// Add back existing columns.
for (const column of persistedData.columns || []) {
byColumn.set(column.value, []);
}
await recursiveGroupBy(parentNote.getChildBranches(), byColumn, groupByColumn);
let newPersistedData: BoardData | undefined;
// Check if we have new columns.
const existingColumns = persistedData.columns?.map(c => c.value) || [];
for (const column of existingColumns) {
if (!byColumn.has(column)) {
byColumn.set(column, []);
}
}
const newColumns = [...byColumn.keys()]
.filter(column => !existingColumns.includes(column))
.map(column => ({ value: column }));
if (newColumns.length > 0) {
newPersistedData = {
...persistedData,
columns: [...(persistedData.columns || []), ...newColumns]
};
}
return {
byColumn,
newPersistedData
};
}
async function recursiveGroupBy(branches: FBranch[], byColumn: ColumnMap, groupByColumn: string) {
for (const branch of branches) {
const note = await branch.getNote();
if (!note) {
continue;
}
const group = note.getLabelValue(groupByColumn);
if (!group) {
continue;
}
if (!byColumn.has(group)) {
byColumn.set(group, []);
}
byColumn.get(group)!.push({
branch,
note
});
}
}

View File

@ -0,0 +1,434 @@
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> {
// Clean up any stray drag indicators before updating
this.dragHandler.cleanup();
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}"]`);
const isNewCard = !oldCardIds.includes(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(document.createTextNode(item.note.title));
}
// Ensure card is in correct position
this.ensureCardPosition($existingCard, i, $cardContainer);
} else {
// Create new card (pass isNewCard flag)
const $newCard = this.createCard(item.note, item.branch, column, isNewCard);
$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, false); // false = existing card
$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, isNewCard: boolean = false): 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)
.attr("data-icon-class", note.getIcon())
.text(note.title);
$noteEl.prepend($iconEl);
// Only add quick edit click handler for existing cards (not new ones)
if (!isNewCard) {
$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();
}
}
startInlineEditing(noteId: string): void {
// Use setTimeout to ensure the card is rendered before trying to edit it
setTimeout(() => {
const $card = this.$container.find(`[data-note-id="${noteId}"]`);
if ($card.length) {
this.makeCardEditable($card, noteId);
}
}, 100);
}
private makeCardEditable($card: JQuery<HTMLElement>, noteId: string): void {
if ($card.hasClass('editing')) {
return; // Already editing
}
// Get the current title (get text without icon)
const $icon = $card.find('.icon');
const currentTitle = $card.text().trim();
// Add editing class and store original click handler
$card.addClass('editing');
$card.off('click'); // Remove any existing click handlers temporarily
// Create input element
const $input = $('<input>')
.attr('type', 'text')
.val(currentTitle)
.css({
background: 'transparent',
border: 'none',
outline: 'none',
fontFamily: 'inherit',
fontSize: 'inherit',
color: 'inherit',
flex: '1',
minWidth: '0',
padding: '0',
marginLeft: '0.25em'
});
// Create a flex container to keep icon and input inline
const $editContainer = $('<div>')
.css({
display: 'flex',
alignItems: 'center',
width: '100%'
});
// Replace content with icon + input in flex container
$editContainer.append($icon.clone(), $input);
$card.empty().append($editContainer);
$input.focus().select();
const finishEdit = async (save: boolean = true) => {
if (!$card.hasClass('editing')) {
return; // Already finished
}
$card.removeClass('editing');
let finalTitle = currentTitle;
if (save) {
const newTitle = $input.val() as string;
if (newTitle.trim() && newTitle !== currentTitle) {
try {
// Update the note title using the board view's server call
import('../../../services/server').then(async ({ default: server }) => {
await server.put(`notes/${noteId}/title`, { title: newTitle.trim() });
finalTitle = newTitle.trim();
});
} catch (error) {
console.error("Failed to update note title:", error);
}
}
}
// Restore the card content
const iconClass = $card.attr('data-icon-class') || 'bx bx-file';
const $newIcon = $('<span>').addClass('icon').addClass(iconClass);
$card.text(finalTitle);
$card.prepend($newIcon);
// Re-attach click handler for quick edit (for existing cards)
$card.on('click', () => appContext.triggerCommand("openInPopup", { noteIdOrPath: noteId }));
};
$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);
}
});
}
}

View File

@ -0,0 +1,340 @@
import branchService from "../../../services/branches";
import BoardApi from "./api";
export interface DragContext {
draggedNote: any;
draggedBranch: any;
draggedNoteElement: JQuery<HTMLElement> | null;
}
export class BoardDragHandler {
private $container: JQuery<HTMLElement>;
private api: BoardApi;
private context: DragContext;
private onBoardRefresh: () => Promise<void>;
constructor(
$container: JQuery<HTMLElement>,
api: BoardApi,
context: DragContext,
onBoardRefresh: () => Promise<void>
) {
this.$container = $container;
this.api = api;
this.context = context;
this.onBoardRefresh = onBoardRefresh;
}
setupNoteDrag($noteEl: JQuery<HTMLElement>, note: any, branch: any) {
$noteEl.attr("draggable", "true");
// Mouse drag events
this.setupMouseDrag($noteEl, note, branch);
// Touch drag events
this.setupTouchDrag($noteEl, note, branch);
}
updateApi(newApi: BoardApi) {
this.api = newApi;
}
private cleanupAllDropIndicators() {
// Remove all drop indicators from the DOM to prevent layout issues
this.$container.find(".board-drop-indicator").remove();
}
private cleanupColumnDropIndicators($columnEl: JQuery<HTMLElement>) {
// Remove drop indicators from a specific column
$columnEl.find(".board-drop-indicator").remove();
}
// Public method to clean up any stray indicators - can be called externally
cleanup() {
this.cleanupAllDropIndicators();
this.$container.find('.board-column').removeClass('drag-over');
}
private setupMouseDrag($noteEl: JQuery<HTMLElement>, note: any, branch: any) {
$noteEl.on("dragstart", (e) => {
this.context.draggedNote = note;
this.context.draggedBranch = branch;
this.context.draggedNoteElement = $noteEl;
$noteEl.addClass("dragging");
// Set drag data
const originalEvent = e.originalEvent as DragEvent;
if (originalEvent.dataTransfer) {
originalEvent.dataTransfer.effectAllowed = "move";
originalEvent.dataTransfer.setData("text/plain", note.noteId);
}
});
$noteEl.on("dragend", () => {
$noteEl.removeClass("dragging");
this.context.draggedNote = null;
this.context.draggedBranch = null;
this.context.draggedNoteElement = null;
// Clean up all drop indicators properly
this.cleanupAllDropIndicators();
});
}
private setupTouchDrag($noteEl: JQuery<HTMLElement>, note: any, branch: any) {
let isDragging = false;
let startY = 0;
let startX = 0;
let dragThreshold = 10; // Minimum distance to start dragging
let $dragPreview: JQuery<HTMLElement> | null = null;
$noteEl.on("touchstart", (e) => {
const touch = (e.originalEvent as TouchEvent).touches[0];
startX = touch.clientX;
startY = touch.clientY;
isDragging = false;
$dragPreview = null;
});
$noteEl.on("touchmove", (e) => {
e.preventDefault(); // Prevent scrolling
const touch = (e.originalEvent as TouchEvent).touches[0];
const deltaX = Math.abs(touch.clientX - startX);
const deltaY = Math.abs(touch.clientY - startY);
// Start dragging if we've moved beyond threshold
if (!isDragging && (deltaX > dragThreshold || deltaY > dragThreshold)) {
isDragging = true;
this.context.draggedNote = note;
this.context.draggedBranch = branch;
this.context.draggedNoteElement = $noteEl;
$noteEl.addClass("dragging");
// Create drag preview
$dragPreview = this.createDragPreview($noteEl, touch.clientX, touch.clientY);
}
if (isDragging && $dragPreview) {
// Update drag preview position
$dragPreview.css({
left: touch.clientX - ($dragPreview.outerWidth() || 0) / 2,
top: touch.clientY - ($dragPreview.outerHeight() || 0) / 2
});
// Find element under touch point
const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY);
if (elementBelow) {
const $columnEl = $(elementBelow).closest('.board-column');
if ($columnEl.length > 0) {
// Remove drag-over from all columns
this.$container.find('.board-column').removeClass('drag-over');
$columnEl.addClass('drag-over');
// Show drop indicator
this.showDropIndicatorAtPoint($columnEl, touch.clientY);
} else {
// Remove all drag indicators if not over a column
this.$container.find('.board-column').removeClass('drag-over');
this.cleanupAllDropIndicators();
}
}
}
});
$noteEl.on("touchend", async (e) => {
if (isDragging) {
const touch = (e.originalEvent as TouchEvent).changedTouches[0];
const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY);
if (elementBelow) {
const $columnEl = $(elementBelow).closest('.board-column');
if ($columnEl.length > 0) {
const column = $columnEl.attr('data-column');
if (column && this.context.draggedNote && this.context.draggedNoteElement && this.context.draggedBranch) {
await this.handleNoteDrop($columnEl, column);
}
}
}
// Clean up
$noteEl.removeClass("dragging");
this.context.draggedNote = null;
this.context.draggedBranch = null;
this.context.draggedNoteElement = null;
this.$container.find('.board-column').removeClass('drag-over');
this.cleanupAllDropIndicators();
// Remove drag preview
if ($dragPreview) {
$dragPreview.remove();
$dragPreview = null;
}
}
isDragging = false;
});
}
setupColumnDropZone($columnEl: JQuery<HTMLElement>, column: string) {
$columnEl.on("dragover", (e) => {
e.preventDefault();
const originalEvent = e.originalEvent as DragEvent;
if (originalEvent.dataTransfer) {
originalEvent.dataTransfer.dropEffect = "move";
}
if (this.context.draggedNote) {
$columnEl.addClass("drag-over");
this.showDropIndicator($columnEl, e);
}
});
$columnEl.on("dragleave", (e) => {
// Only remove drag-over if we're leaving the column entirely
const rect = $columnEl[0].getBoundingClientRect();
const originalEvent = e.originalEvent as DragEvent;
const x = originalEvent.clientX;
const y = originalEvent.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
$columnEl.removeClass("drag-over");
this.cleanupColumnDropIndicators($columnEl);
}
});
$columnEl.on("drop", async (e) => {
e.preventDefault();
$columnEl.removeClass("drag-over");
if (this.context.draggedNote && this.context.draggedNoteElement && this.context.draggedBranch) {
await this.handleNoteDrop($columnEl, column);
}
});
}
private createDragPreview($noteEl: JQuery<HTMLElement>, x: number, y: number): JQuery<HTMLElement> {
// Clone the note element for the preview
const $preview = $noteEl.clone();
$preview
.addClass('board-drag-preview')
.css({
position: 'fixed',
left: x - ($noteEl.outerWidth() || 0) / 2,
top: y - ($noteEl.outerHeight() || 0) / 2,
pointerEvents: 'none',
zIndex: 10000
})
.appendTo('body');
return $preview;
}
private showDropIndicator($columnEl: JQuery<HTMLElement>, e: JQuery.DragOverEvent) {
const originalEvent = e.originalEvent as DragEvent;
const mouseY = originalEvent.clientY;
this.showDropIndicatorAtY($columnEl, mouseY);
}
private showDropIndicatorAtPoint($columnEl: JQuery<HTMLElement>, touchY: number) {
this.showDropIndicatorAtY($columnEl, touchY);
}
private showDropIndicatorAtY($columnEl: JQuery<HTMLElement>, y: number) {
const columnRect = $columnEl[0].getBoundingClientRect();
const relativeY = y - columnRect.top;
// Clean up any existing drop indicators in this column first
this.cleanupColumnDropIndicators($columnEl);
// Create a new drop indicator
const $dropIndicator = $("<div>").addClass("board-drop-indicator");
// Find the best position to insert the note
const $notes = this.context.draggedNoteElement ?
$columnEl.find(".board-note").not(this.context.draggedNoteElement) :
$columnEl.find(".board-note");
let insertAfterElement: HTMLElement | null = null;
$notes.each((_, noteEl) => {
const noteRect = noteEl.getBoundingClientRect();
const noteMiddle = noteRect.top + noteRect.height / 2 - columnRect.top;
if (relativeY > noteMiddle) {
insertAfterElement = noteEl;
}
});
// Position the drop indicator
if (insertAfterElement) {
$(insertAfterElement).after($dropIndicator);
} else {
// Insert at the beginning (after the header)
const $header = $columnEl.find("h3");
$header.after($dropIndicator);
}
$dropIndicator.addClass("show");
}
private async handleNoteDrop($columnEl: JQuery<HTMLElement>, column: string) {
const draggedNoteElement = this.context.draggedNoteElement;
const draggedNote = this.context.draggedNote;
const draggedBranch = this.context.draggedBranch;
if (draggedNote && draggedNoteElement && draggedBranch) {
const currentColumn = draggedNoteElement.attr("data-current-column");
// Capture drop indicator position BEFORE removing it
const dropIndicator = $columnEl.find(".board-drop-indicator.show");
let targetBranchId: string | null = null;
let moveType: "before" | "after" | null = null;
if (dropIndicator.length > 0) {
// Find the note element that the drop indicator is positioned relative to
const nextNote = dropIndicator.next(".board-note");
const prevNote = dropIndicator.prev(".board-note");
if (nextNote.length > 0) {
targetBranchId = nextNote.attr("data-branch-id") || null;
moveType = "before";
} else if (prevNote.length > 0) {
targetBranchId = prevNote.attr("data-branch-id") || null;
moveType = "after";
}
}
try {
// Handle column change
if (currentColumn !== column) {
await this.api.changeColumn(draggedNote.noteId, column);
}
// Handle position change (works for both same column and different column moves)
if (targetBranchId && moveType) {
if (moveType === "before") {
console.log("Move before branch:", draggedBranch.branchId, "to", targetBranchId);
await branchService.moveBeforeBranch([draggedBranch.branchId], targetBranchId);
} else if (moveType === "after") {
console.log("Move after branch:", draggedBranch.branchId, "to", targetBranchId);
await branchService.moveAfterBranch([draggedBranch.branchId], targetBranchId);
}
}
// Update the data attributes
draggedNoteElement.attr("data-current-column", column);
// Show success feedback
console.log(`Moved note "${draggedNote.title}" from "${currentColumn}" to "${column}"`);
// Refresh the board to reflect the changes
await this.onBoardRefresh();
} catch (error) {
console.error("Failed to update note position:", error);
} finally {
// Always clean up drop indicators after drop operation
this.cleanupAllDropIndicators();
}
}
}
}

View File

@ -0,0 +1,567 @@
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 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<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 === 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 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);
}
}

View File

@ -2,30 +2,30 @@ import { executeBulkActions } from "../../../services/bulk_action.js";
export async function renameColumn(parentNoteId: string, type: "label" | "relation", originalName: string, newName: string) {
if (type === "label") {
return executeBulkActions(parentNoteId, [{
return executeBulkActions([parentNoteId], [{
name: "renameLabel",
oldLabelName: originalName,
newLabelName: newName
}]);
}], true);
} else {
return executeBulkActions(parentNoteId, [{
return executeBulkActions([parentNoteId], [{
name: "renameRelation",
oldRelationName: originalName,
newRelationName: newName
}]);
}], true);
}
}
export async function deleteColumn(parentNoteId: string, type: "label" | "relation", columnName: string) {
if (type === "label") {
return executeBulkActions(parentNoteId, [{
return executeBulkActions([parentNoteId], [{
name: "deleteLabel",
labelName: columnName
}]);
}], true);
} else {
return executeBulkActions(parentNoteId, [{
return executeBulkActions([parentNoteId], [{
name: "deleteRelation",
relationName: columnName
}]);
}], true);
}
}

View File

@ -0,0 +1,18 @@
import utils from "../services/utils.js";
/**
* Enables scrolling of a container horizontally using the mouse wheel, instead of having to use the scrollbar or keep Shift pressed.
*
* @param $container the jQuery-wrapped container element to enable horizontal scrolling for.
*/
export function setupHorizontalScrollViaWheel($container: JQuery<HTMLElement>) {
$container.on("wheel", (event) => {
const wheelEvent = event.originalEvent as WheelEvent;
if (utils.isCtrlKey(event) || event.altKey || event.shiftKey) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
event.currentTarget.scrollLeft += wheelEvent.deltaY + wheelEvent.deltaX;
});
}

View File

@ -317,6 +317,14 @@
"start-time": "Start Time",
"end-time": "End Time",
"geolocation": "Geolocation",
"built-in-templates": "Built-in templates"
"built-in-templates": "Built-in templates",
"board": "Board",
"status": "Status",
"board_note_first": "First note",
"board_note_second": "Second note",
"board_note_third": "Third note",
"board_status_todo": "To Do",
"board_status_progress": "In Progress",
"board_status_done": "Done"
}
}

View File

@ -170,7 +170,70 @@ export default function buildHiddenSubtreeTemplates() {
isInheritable: true
}
]
}
},
{
id: "_template_board",
type: "book",
title: t("hidden_subtree_templates.board"),
icon: "bx bx-columns",
attributes: [
{
name: "template",
type: "label"
},
{
name: "collection",
type: "label"
},
{
name: "viewType",
type: "label",
value: "board"
},
{
name: "hidePromotedAttributes",
type: "label"
},
{
name: "label:status",
type: "label",
value: `promoted,alias=${t("hidden_subtree_templates.status")},single,text`,
isInheritable: true
}
],
children: [
{
id: "_template_board_first",
title: t("hidden_subtree_templates.board_note_first"),
attributes: [{
name: "status",
value: t("hidden_subtree_templates.board_status_todo"),
type: "label"
}],
type: "text"
},
{
id: "_template_board_second",
title: t("hidden_subtree_templates.board_note_second"),
attributes: [{
name: "status",
value: t("hidden_subtree_templates.board_status_progress"),
type: "label"
}],
type: "text"
},
{
id: "_template_board_third",
title: t("hidden_subtree_templates.board_note_third"),
attributes: [{
name: "status",
value: t("hidden_subtree_templates.board_status_done"),
type: "label"
}],
type: "text"
}
]
},
]
};