From cb377248791ed98ebe745d8f340b293afb5dd1ae Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 23 Jul 2025 18:18:40 +0300 Subject: [PATCH] feat(views/board): basic column drag support --- .github/instructions/nx.instructions.md | 2 +- .../widgets/view_widgets/board_view/api.ts | 35 ++- .../board_view/differential_renderer.ts | 25 +- .../view_widgets/board_view/drag_handler.ts | 261 +++++++++++++++++- .../widgets/view_widgets/board_view/index.ts | 101 ++++++- 5 files changed, 397 insertions(+), 27 deletions(-) diff --git a/.github/instructions/nx.instructions.md b/.github/instructions/nx.instructions.md index 6c63651c8..c467932a2 100644 --- a/.github/instructions/nx.instructions.md +++ b/.github/instructions/nx.instructions.md @@ -4,7 +4,7 @@ applyTo: '**' // This file is automatically generated by Nx Console -You are in an nx workspace using Nx 21.3.1 and pnpm as the package manager. +You are in an nx workspace using Nx 21.3.2 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: diff --git a/apps/client/src/widgets/view_widgets/board_view/api.ts b/apps/client/src/widgets/view_widgets/board_view/api.ts index 66bcc0f10..44dc2d6b2 100644 --- a/apps/client/src/widgets/view_widgets/board_view/api.ts +++ b/apps/client/src/widgets/view_widgets/board_view/api.ts @@ -110,12 +110,45 @@ export default class BoardApi { return columnValue; } + async reorderColumns(newColumnOrder: string[]) { + console.log("API: Reordering columns to:", newColumnOrder); + + // Update the column order in persisted data + if (!this.persistedData.columns) { + this.persistedData.columns = []; + } + + // Create a map of existing column data + const columnDataMap = new Map(); + this.persistedData.columns.forEach(col => { + columnDataMap.set(col.value, col); + }); + + // Reorder columns based on new order + this.persistedData.columns = newColumnOrder.map(columnValue => { + return columnDataMap.get(columnValue) || { value: columnValue }; + }); + + // Update internal columns array + this._columns = newColumnOrder; + + console.log("API: Updated internal columns to:", this._columns); + console.log("API: Updated persisted data:", this.persistedData.columns); + + await this.viewStorage.store(this.persistedData); + } + 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()) || []; + + // Use the order from persistedData.columns, then add any new columns found + const orderedColumns = persistedData.columns?.map(col => col.value) || []; + const allColumns = Array.from(byColumn.keys()); + const newColumns = allColumns.filter(col => !orderedColumns.includes(col)); + const columns = [...orderedColumns, ...newColumns]; if (newPersistedData) { persistedData = newPersistedData; 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 index 797a525b5..7a2c682a7 100644 --- a/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts +++ b/apps/client/src/widgets/view_widgets/board_view/differential_renderer.ts @@ -241,15 +241,33 @@ export class DifferentialBoardRenderer { .addClass("board-column") .attr("data-column", column); - // Create header + // Create header with drag handle const $titleEl = $("

").attr("data-column-value", column); + + // Create drag handle + const $dragHandle = $("") + .addClass("column-drag-handle icon bx bx-menu") + .attr("title", "Drag to reorder column"); + + // Create title text const $titleText = $("").text(column); + + // Create title content container + const $titleContent = $("
") + .addClass("column-title-content") + .append($dragHandle, $titleText); + + // Create edit icon const $editIcon = $("") .addClass("edit-icon icon bx bx-edit-alt") .attr("title", "Click to edit column title"); - $titleEl.append($titleText, $editIcon); + + $titleEl.append($titleContent, $editIcon); $columnEl.append($titleEl); + // Setup column dragging + this.dragHandler.setupColumnDrag($columnEl, column); + // Handle wheel events for scrolling $columnEl.on("wheel", (event) => { const el = $columnEl[0]; @@ -259,7 +277,8 @@ export class DifferentialBoardRenderer { } }); - // Setup drop zone + // Setup drop zones for both notes and columns + this.dragHandler.setupNoteDropZone($columnEl, column); this.dragHandler.setupColumnDropZone($columnEl, column); // Add cards diff --git a/apps/client/src/widgets/view_widgets/board_view/drag_handler.ts b/apps/client/src/widgets/view_widgets/board_view/drag_handler.ts index c11a68b8a..19653ce89 100644 --- a/apps/client/src/widgets/view_widgets/board_view/drag_handler.ts +++ b/apps/client/src/widgets/view_widgets/board_view/drag_handler.ts @@ -5,6 +5,8 @@ export interface DragContext { draggedNote: any; draggedBranch: any; draggedNoteElement: JQuery | null; + draggedColumn: string | null; + draggedColumnElement: JQuery | null; } export class BoardDragHandler { @@ -35,6 +37,54 @@ export class BoardDragHandler { this.setupTouchDrag($noteEl, note, branch); } + setupColumnDrag($columnEl: JQuery, columnValue: string) { + const $dragHandle = $columnEl.find('.column-drag-handle'); + + $dragHandle.attr("draggable", "true"); + + $dragHandle.on("dragstart", (e) => { + this.context.draggedColumn = columnValue; + this.context.draggedColumnElement = $columnEl; + $columnEl.addClass("column-dragging"); + + const originalEvent = e.originalEvent as DragEvent; + if (originalEvent.dataTransfer) { + originalEvent.dataTransfer.effectAllowed = "move"; + originalEvent.dataTransfer.setData("text/plain", columnValue); + } + + // Prevent note dragging when column is being dragged + e.stopPropagation(); + + // Setup global drag tracking for better drop indicator positioning + this.setupGlobalColumnDragTracking(); + }); + + $dragHandle.on("dragend", () => { + $columnEl.removeClass("column-dragging"); + this.$container.find('.board-column').removeClass('column-drag-over'); + this.context.draggedColumn = null; + this.context.draggedColumnElement = null; + this.cleanupColumnDropIndicators(); + this.cleanupGlobalColumnDragTracking(); + }); + } + + private setupGlobalColumnDragTracking() { + // Add container-level drag tracking for better indicator positioning + this.$container.on("dragover.columnDrag", (e) => { + if (this.context.draggedColumn) { + e.preventDefault(); + const originalEvent = e.originalEvent as DragEvent; + this.showColumnDropIndicator(originalEvent.clientX); + } + }); + } + + private cleanupGlobalColumnDragTracking() { + this.$container.off("dragover.columnDrag"); + } + updateApi(newApi: BoardApi) { this.api = newApi; } @@ -42,10 +92,16 @@ export class BoardDragHandler { private cleanupAllDropIndicators() { // Remove all drop indicators from the DOM to prevent layout issues this.$container.find(".board-drop-indicator").remove(); + this.$container.find(".column-drop-indicator").remove(); } - private cleanupColumnDropIndicators($columnEl: JQuery) { - // Remove drop indicators from a specific column + private cleanupColumnDropIndicators() { + // Remove column drop indicators + this.$container.find(".column-drop-indicator").remove(); + } + + private cleanupNoteDropIndicators($columnEl: JQuery) { + // Remove note drop indicators from a specific column $columnEl.find(".board-drop-indicator").remove(); } @@ -53,6 +109,10 @@ export class BoardDragHandler { cleanup() { this.cleanupAllDropIndicators(); this.$container.find('.board-column').removeClass('drag-over'); + this.$container.find('.board-column').removeClass('column-drag-over'); + this.context.draggedColumn = null; + this.context.draggedColumnElement = null; + this.cleanupGlobalColumnDragTracking(); } private setupMouseDrag($noteEl: JQuery, note: any, branch: any) { @@ -175,15 +235,16 @@ export class BoardDragHandler { }); } - setupColumnDropZone($columnEl: JQuery, column: string) { + setupNoteDropZone($columnEl: JQuery, column: string) { $columnEl.on("dragover", (e) => { - e.preventDefault(); - const originalEvent = e.originalEvent as DragEvent; - if (originalEvent.dataTransfer) { - originalEvent.dataTransfer.dropEffect = "move"; - } + // Only handle note drops when a note is being dragged + if (this.context.draggedNote && !this.context.draggedColumn) { + 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); } @@ -198,16 +259,59 @@ export class BoardDragHandler { if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { $columnEl.removeClass("drag-over"); - this.cleanupColumnDropIndicators($columnEl); + this.cleanupNoteDropIndicators($columnEl); } }); $columnEl.on("drop", async (e) => { - e.preventDefault(); - $columnEl.removeClass("drag-over"); + if (this.context.draggedNote && !this.context.draggedColumn) { + e.preventDefault(); + $columnEl.removeClass("drag-over"); - if (this.context.draggedNote && this.context.draggedNoteElement && this.context.draggedBranch) { - await this.handleNoteDrop($columnEl, column); + if (this.context.draggedNote && this.context.draggedNoteElement && this.context.draggedBranch) { + await this.handleNoteDrop($columnEl, column); + } + } + }); + } + + setupColumnDropZone($columnEl: JQuery, columnValue: string) { + $columnEl.on("dragover", (e) => { + // Only handle column drops when a column is being dragged + if (this.context.draggedColumn && !this.context.draggedNote) { + e.preventDefault(); + const originalEvent = e.originalEvent as DragEvent; + if (originalEvent.dataTransfer) { + originalEvent.dataTransfer.dropEffect = "move"; + } + + if (this.context.draggedColumn !== columnValue) { + $columnEl.addClass("column-drag-over"); + } + } + }); + + $columnEl.on("dragleave", (e) => { + if (this.context.draggedColumn && !this.context.draggedNote) { + 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("column-drag-over"); + } + } + }); + + $columnEl.on("drop", async (e) => { + if (this.context.draggedColumn && !this.context.draggedNote) { + e.preventDefault(); + $columnEl.removeClass("column-drag-over"); + + if (this.context.draggedColumn !== columnValue) { + await this.handleColumnDrop($columnEl, columnValue); + } } }); } @@ -245,7 +349,7 @@ export class BoardDragHandler { const relativeY = y - columnRect.top; // Clean up any existing drop indicators in this column first - this.cleanupColumnDropIndicators($columnEl); + this.cleanupNoteDropIndicators($columnEl); // Create a new drop indicator const $dropIndicator = $("
").addClass("board-drop-indicator"); @@ -277,6 +381,63 @@ export class BoardDragHandler { $dropIndicator.addClass("show"); } + private showColumnDropIndicator(mouseX: number) { + // Clean up existing indicators + this.cleanupColumnDropIndicators(); + + // Get all columns (excluding the dragged one if it exists) + let $allColumns = this.$container.find('.board-column'); + if (this.context.draggedColumnElement) { + $allColumns = $allColumns.not(this.context.draggedColumnElement); + } + + let $targetColumn: JQuery = $(); + let insertBefore = false; + + // Find which column the mouse is closest to + $allColumns.each((_, columnEl) => { + const $column = $(columnEl); + const rect = columnEl.getBoundingClientRect(); + const columnMiddle = rect.left + rect.width / 2; + + if (mouseX >= rect.left && mouseX <= rect.right) { + // Mouse is over this column + $targetColumn = $column; + insertBefore = mouseX < columnMiddle; + return false; // Break the loop + } + }); + + // If no column found under mouse, find the closest one + if ($targetColumn.length === 0) { + let closestDistance = Infinity; + $allColumns.each((_, columnEl) => { + const $column = $(columnEl); + const rect = columnEl.getBoundingClientRect(); + const columnCenter = rect.left + rect.width / 2; + const distance = Math.abs(mouseX - columnCenter); + + if (distance < closestDistance) { + closestDistance = distance; + $targetColumn = $column; + insertBefore = mouseX < columnCenter; + } + }); + } + + if ($targetColumn.length > 0) { + const $dropIndicator = $("
").addClass("column-drop-indicator"); + + if (insertBefore) { + $targetColumn.before($dropIndicator); + } else { + $targetColumn.after($dropIndicator); + } + + $dropIndicator.addClass("show"); + } + } + private async handleNoteDrop($columnEl: JQuery, column: string) { const draggedNoteElement = this.context.draggedNoteElement; const draggedNote = this.context.draggedNote; @@ -337,4 +498,74 @@ export class BoardDragHandler { } } } + + private async handleColumnDrop($targetColumnEl: JQuery, targetColumnValue: string) { + if (!this.context.draggedColumn || !this.context.draggedColumnElement) { + return; + } + + try { + // Get current column order from the DOM + const currentOrder = Array.from(this.$container.find('.board-column')).map(el => + $(el).attr('data-column') + ).filter(col => col) as string[]; + + console.log("Current order:", currentOrder); + console.log("Dragged column:", this.context.draggedColumn); + console.log("Target column:", targetColumnValue); + + // Find the drop indicator to determine insert position + const $dropIndicator = this.$container.find(".column-drop-indicator.show"); + + if ($dropIndicator.length > 0) { + let newOrder = [...currentOrder]; + + // Remove dragged column from current position + newOrder = newOrder.filter(col => col !== this.context.draggedColumn); + + // Determine insertion position based on drop indicator + const $nextColumn = $dropIndicator.next('.board-column'); + const $prevColumn = $dropIndicator.prev('.board-column'); + + let insertIndex = -1; + + if ($nextColumn.length > 0) { + // Insert before the next column + const nextColumnValue = $nextColumn.attr('data-column'); + insertIndex = newOrder.indexOf(nextColumnValue!); + } else if ($prevColumn.length > 0) { + // Insert after the previous column + const prevColumnValue = $prevColumn.attr('data-column'); + insertIndex = newOrder.indexOf(prevColumnValue!) + 1; + } else { + // Insert at the beginning + insertIndex = 0; + } + + // Insert the dragged column at the determined position + if (insertIndex >= 0) { + newOrder.splice(insertIndex, 0, this.context.draggedColumn); + } else { + // Fallback: insert at the end + newOrder.push(this.context.draggedColumn); + } + + console.log("New order:", newOrder); + + // Update column order in API + await this.api.reorderColumns(newOrder); + + console.log(`Moved column "${this.context.draggedColumn}" to new position`); + + // Refresh the board to reflect the changes + await this.onBoardRefresh(); + } else { + console.warn("No drop indicator found for column drop"); + } + } catch (error) { + console.error("Failed to reorder columns:", error); + } finally { + this.cleanupColumnDropIndicators(); + } + } } diff --git a/apps/client/src/widgets/view_widgets/board_view/index.ts b/apps/client/src/widgets/view_widgets/board_view/index.ts index a438d6282..cadb3d38e 100644 --- a/apps/client/src/widgets/view_widgets/board_view/index.ts +++ b/apps/client/src/widgets/view_widgets/board_view/index.ts @@ -70,6 +70,47 @@ const TPL = /*html*/` border-radius: 4px; } + .board-view-container .board-column h3 .column-title-content { + display: flex; + align-items: center; + flex: 1; + min-width: 0; /* Allow text to truncate */ + } + + .board-view-container .board-column h3 .column-drag-handle { + margin-right: 0.5em; + color: var(--muted-text-color); + cursor: grab; + opacity: 0; + transition: opacity 0.2s ease; + padding: 0.25em; + border-radius: 3px; + } + + .board-view-container .board-column h3:hover .column-drag-handle { + opacity: 1; + } + + .board-view-container .board-column h3 .column-drag-handle:hover { + background-color: var(--main-background-color); + color: var(--main-text-color); + } + + .board-view-container .board-column h3 .column-drag-handle:active { + cursor: grabbing; + } + + .board-view-container .board-column.column-dragging { + opacity: 0.6; + transform: scale(0.98); + transition: opacity 0.2s ease, transform 0.2s ease; + } + + .board-view-container .board-column.column-drag-over { + border-color: var(--main-text-color); + background-color: var(--hover-item-background-color); + } + .board-view-container .board-column h3 input { background: transparent; border: none; @@ -172,6 +213,22 @@ const TPL = /*html*/` opacity: 1; } + .column-drop-indicator { + width: 4px; + background-color: var(--main-text-color); + border-radius: 2px; + opacity: 0; + transition: opacity 0.2s ease; + height: 100%; + z-index: 1000; + box-shadow: 0 0 8px rgba(0, 0, 0, 0.3); + flex-shrink: 0; + } + + .column-drop-indicator.show { + opacity: 1; + } + .board-new-item { margin-top: 0.5em; padding: 0.5em; @@ -274,7 +331,9 @@ export default class BoardView extends ViewMode { this.dragContext = { draggedNote: null, draggedBranch: null, - draggedNoteElement: null + draggedNoteElement: null, + draggedColumn: null, + draggedColumnElement: null }; args.$parent.append(this.$root); @@ -320,10 +379,10 @@ export default class BoardView extends ViewMode { } private setupBoardInteractions() { - // Handle column title editing - this.$container.on('click', 'h3[data-column-value]', (e) => { + // Handle column title editing - listen for clicks on the title content, not the drag handle + this.$container.on('click', 'h3[data-column-value] .column-title-content span:not(.column-drag-handle)', (e) => { e.stopPropagation(); - const $titleEl = $(e.currentTarget); + const $titleEl = $(e.currentTarget).closest('h3[data-column-value]'); const columnValue = $titleEl.attr('data-column-value'); if (columnValue) { const columnItems = this.api?.getColumn(columnValue) || []; @@ -331,6 +390,24 @@ export default class BoardView extends ViewMode { } }); + // Also handle clicks on the h3 element itself (but not on the drag handle) + this.$container.on('click', 'h3[data-column-value]', (e) => { + // Only proceed if the click wasn't on the drag handle or edit icon + if (!$(e.target).hasClass('column-drag-handle') && + !$(e.target).hasClass('edit-icon') && + !$(e.target).hasClass('bx-menu') && + !$(e.target).hasClass('bx-edit-alt')) { + + 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(); @@ -339,12 +416,21 @@ export default class BoardView extends ViewMode { } private createTitleStructure(title: string): { $titleText: JQuery; $editIcon: JQuery } { + const $dragHandle = $("") + .addClass("column-drag-handle icon bx bx-menu") + .attr("title", "Drag to reorder column"); + const $titleText = $("").text(title); + + const $titleContent = $("
") + .addClass("column-title-content") + .append($dragHandle, $titleText); + const $editIcon = $("") .addClass("edit-icon icon bx bx-edit-alt") .attr("title", "Click to edit column title"); - return { $titleText, $editIcon }; + return { $titleText: $titleContent, $editIcon }; } private startEditingColumnTitle($titleEl: JQuery, columnValue: string, columnItems: { branch: any; note: any; }[]) { @@ -352,8 +438,9 @@ export default class BoardView extends ViewMode { return; // Already editing } - const $titleText = $titleEl.find("span").first(); - const currentTitle = $titleText.text(); + const $titleContent = $titleEl.find(".column-title-content"); + const $titleSpan = $titleContent.find("span").last(); // Get the text span, not the drag handle + const currentTitle = $titleSpan.text(); $titleEl.addClass("editing"); const $input = $("")