diff --git a/.github/instructions/nx.instructions.md b/.github/instructions/nx.instructions.md new file mode 100644 index 000000000..fd81bf2ee --- /dev/null +++ b/.github/instructions/nx.instructions.md @@ -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 ` 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. + + + diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 000000000..28994bb29 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,8 @@ +{ + "servers": { + "nx-mcp": { + "type": "http", + "url": "http://localhost:9461/mcp" + } + } +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 9ee96f4c1..4ee21bb3c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -35,5 +35,6 @@ "docs/**/*.png": true, "apps/server/src/assets/doc_notes/**": true, "apps/edit-docs/demo/**": true - } + }, + "nxConsole.generateAiAgentRules": true } \ No newline at end of file diff --git a/apps/client/src/components/note_context.ts b/apps/client/src/components/note_context.ts index 75c66b1bc..1bc4e5498 100644 --- a/apps/client/src/components/note_context.ts +++ b/apps/client/src/components/note_context.ts @@ -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; } diff --git a/apps/client/src/services/bulk_action.ts b/apps/client/src/services/bulk_action.ts index 57b880c36..d894ee17e 100644 --- a/apps/client/src/services/bulk_action.ts +++ b/apps/client/src/services/bulk_action.ts @@ -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 }); diff --git a/apps/client/src/services/note_list_renderer.ts b/apps/client/src/services/note_list_renderer.ts index 08af048f0..50556715a 100644 --- a/apps/client/src/services/note_list_renderer.ts +++ b/apps/client/src/services/note_list_renderer.ts @@ -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; -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: diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 35e835e30..073edc053 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -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." } } diff --git a/apps/client/src/widgets/floating_buttons/help_button.ts b/apps/client/src/widgets/floating_buttons/help_button.ts index 54e4556ff..d5b643116 100644 --- a/apps/client/src/widgets/floating_buttons/help_button.ts +++ b/apps/client/src/widgets/floating_buttons/help_button.ts @@ -35,7 +35,8 @@ export const byBookType: Record = { grid: "8QqnMzx393bx", calendar: "xWbu3jpNWapp", table: "2FvYrpmOXm29", - geoMap: "81SGnPGMk7Xc" + geoMap: "81SGnPGMk7Xc", + board: null }; export default class ContextualHelpButton extends NoteContextAwareWidget { diff --git a/apps/client/src/widgets/ribbon_widgets/book_properties.ts b/apps/client/src/widgets/ribbon_widgets/book_properties.ts index 144491442..bcb39e6ca 100644 --- a/apps/client/src/widgets/ribbon_widgets/book_properties.ts +++ b/apps/client/src/widgets/ribbon_widgets/book_properties.ts @@ -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 = { + 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*/`
@@ -41,11 +51,9 @@ const TPL = /*html*/` ${t("book_properties.view_type")}:   
@@ -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 })); } diff --git a/apps/client/src/widgets/ribbon_widgets/book_properties_config.ts b/apps/client/src/widgets/ribbon_widgets/book_properties_config.ts index 6b8beece9..87c2b884d 100644 --- a/apps/client/src/widgets/ribbon_widgets/book_properties_config.ts +++ b/apps/client/src/widgets/ribbon_widgets/book_properties_config.ts @@ -101,5 +101,8 @@ export const bookPropertiesConfig: Record = { width: 65 } ] + }, + board: { + properties: [] } }; diff --git a/apps/client/src/widgets/tab_row.ts b/apps/client/src/widgets/tab_row.ts index 9d868a811..cf1367878 100644 --- a/apps/client/src/widgets/tab_row.ts +++ b/apps/client/src/widgets/tab_row.ts @@ -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)); diff --git a/apps/client/src/widgets/type_widgets/book.ts b/apps/client/src/widgets/type_widgets/book.ts index cc8323e1f..ba32fd3a6 100644 --- a/apps/client/src/widgets/type_widgets/book.ts +++ b/apps/client/src/widgets/type_widgets/book.ts @@ -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; } } diff --git a/apps/client/src/widgets/view_widgets/board_view/api.ts b/apps/client/src/widgets/view_widgets/board_view/api.ts new file mode 100644 index 000000000..66bcc0f10 --- /dev/null +++ b/apps/client/src/widgets/view_widgets/board_view/api.ts @@ -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, + 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) { + 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); + } + +} diff --git a/apps/client/src/widgets/view_widgets/board_view/config.ts b/apps/client/src/widgets/view_widgets/board_view/config.ts new file mode 100644 index 000000000..92dd99f5f --- /dev/null +++ b/apps/client/src/widgets/view_widgets/board_view/config.ts @@ -0,0 +1,7 @@ +export interface BoardColumnData { + value: string; +} + +export interface BoardData { + columns?: BoardColumnData[]; +} diff --git a/apps/client/src/widgets/view_widgets/board_view/context_menu.ts b/apps/client/src/widgets/view_widgets/board_view/context_menu.ts new file mode 100644 index 000000000..f5e792d53 --- /dev/null +++ b/apps/client/src/widgets/view_widgets/board_view/context_menu.ts @@ -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; + 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), + }); + } +} diff --git a/apps/client/src/widgets/view_widgets/board_view/data.ts b/apps/client/src/widgets/view_widgets/board_view/data.ts new file mode 100644 index 000000000..828181896 --- /dev/null +++ b/apps/client/src/widgets/view_widgets/board_view/data.ts @@ -0,0 +1,66 @@ +import FBranch from "../../../entities/fbranch"; +import FNote from "../../../entities/fnote"; +import { BoardData } from "./config"; + +export type ColumnMap = Map; + +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 + }); + } +} 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..797a525b5 --- /dev/null +++ b/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts @@ -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; + 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 { + // 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 { + 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}"]`); + 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, 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, false); // false = existing card + $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, isNewCard: boolean = false): 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) + .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 = $("
") + .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(); + } + } + + 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, 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 = $('') + .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 = $('
') + .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 = $('').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); + } + }); + } +} 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 new file mode 100644 index 000000000..c11a68b8a --- /dev/null +++ b/apps/client/src/widgets/view_widgets/board_view/drag_handler.ts @@ -0,0 +1,340 @@ +import branchService from "../../../services/branches"; +import BoardApi from "./api"; + +export interface DragContext { + draggedNote: any; + draggedBranch: any; + draggedNoteElement: JQuery | null; +} + +export class BoardDragHandler { + private $container: JQuery; + private api: BoardApi; + private context: DragContext; + private onBoardRefresh: () => Promise; + + constructor( + $container: JQuery, + api: BoardApi, + context: DragContext, + onBoardRefresh: () => Promise + ) { + this.$container = $container; + this.api = api; + this.context = context; + this.onBoardRefresh = onBoardRefresh; + } + + setupNoteDrag($noteEl: JQuery, 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) { + // 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, 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, note: any, branch: any) { + let isDragging = false; + let startY = 0; + let startX = 0; + let dragThreshold = 10; // Minimum distance to start dragging + let $dragPreview: JQuery | 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, 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, x: number, y: number): JQuery { + // 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, e: JQuery.DragOverEvent) { + const originalEvent = e.originalEvent as DragEvent; + const mouseY = originalEvent.clientY; + this.showDropIndicatorAtY($columnEl, mouseY); + } + + private showDropIndicatorAtPoint($columnEl: JQuery, touchY: number) { + this.showDropIndicatorAtY($columnEl, touchY); + } + + private showDropIndicatorAtY($columnEl: JQuery, 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 = $("
").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, 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(); + } + } + } +} diff --git a/apps/client/src/widgets/view_widgets/board_view/index.ts b/apps/client/src/widgets/view_widgets/board_view/index.ts new file mode 100644 index 000000000..cb335ac8f --- /dev/null +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -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*/` +
+ + +
+
+`; + +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 + }; + + 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, + 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; $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 $titleText = $titleEl.find("span").first(); + const currentTitle = $titleText.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) + 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 { + 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 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); + } + +} diff --git a/apps/client/src/widgets/view_widgets/table_view/bulk_actions.ts b/apps/client/src/widgets/view_widgets/table_view/bulk_actions.ts index b0f590c16..010bd1c48 100644 --- a/apps/client/src/widgets/view_widgets/table_view/bulk_actions.ts +++ b/apps/client/src/widgets/view_widgets/table_view/bulk_actions.ts @@ -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); } } diff --git a/apps/client/src/widgets/widget_utils.ts b/apps/client/src/widgets/widget_utils.ts new file mode 100644 index 000000000..f27fe6814 --- /dev/null +++ b/apps/client/src/widgets/widget_utils.ts @@ -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) { + $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; + }); +} diff --git a/apps/server/src/assets/translations/en/server.json b/apps/server/src/assets/translations/en/server.json index 97920deca..3f1553d6b 100644 --- a/apps/server/src/assets/translations/en/server.json +++ b/apps/server/src/assets/translations/en/server.json @@ -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" } } diff --git a/apps/server/src/services/hidden_subtree_templates.ts b/apps/server/src/services/hidden_subtree_templates.ts index 6cc35fff9..11ed6c66b 100644 --- a/apps/server/src/services/hidden_subtree_templates.ts +++ b/apps/server/src/services/hidden_subtree_templates.ts @@ -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" + } + ] + }, ] };