From 4d455650ba2b6d0bc56653dfda80eb8335147db1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 23 Jul 2025 19:16:44 +0300 Subject: [PATCH] refactor(views/board): split row/column handling --- .../board_view/column_drag_handler.ts | 240 ++++++++ .../view_widgets/board_view/drag_handler.ts | 537 +----------------- .../view_widgets/board_view/drag_types.ts | 12 + .../board_view/note_drag_handler.ts | 332 +++++++++++ 4 files changed, 613 insertions(+), 508 deletions(-) create mode 100644 apps/client/src/widgets/view_widgets/board_view/column_drag_handler.ts create mode 100644 apps/client/src/widgets/view_widgets/board_view/drag_types.ts create mode 100644 apps/client/src/widgets/view_widgets/board_view/note_drag_handler.ts diff --git a/apps/client/src/widgets/view_widgets/board_view/column_drag_handler.ts b/apps/client/src/widgets/view_widgets/board_view/column_drag_handler.ts new file mode 100644 index 000000000..18fd9a945 --- /dev/null +++ b/apps/client/src/widgets/view_widgets/board_view/column_drag_handler.ts @@ -0,0 +1,240 @@ +import BoardApi from "./api"; +import { DragContext, BaseDragHandler } from "./drag_types"; + +export class ColumnDragHandler implements BaseDragHandler { + 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; + } + + 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.context.draggedColumn = null; + this.context.draggedColumnElement = null; + this.cleanupColumnDropIndicators(); + this.cleanupGlobalColumnDragTracking(); + }); + } + + 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"; + } + + // Don't highlight columns - we only care about the drop indicator position + } + }); + + $columnEl.on("drop", async (e) => { + if (this.context.draggedColumn && !this.context.draggedNote) { + e.preventDefault(); + console.log("Column drop event triggered for column:", this.context.draggedColumn); + + // Use the drop indicator position to determine where to place the column + await this.handleColumnDrop(); + } + }); + } + + updateApi(newApi: BoardApi) { + this.api = newApi; + } + + cleanup() { + this.cleanupColumnDropIndicators(); + this.context.draggedColumn = null; + this.context.draggedColumnElement = null; + 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); + } + }); + + // Add container-level drop handler for column reordering + this.$container.on("drop.columnDrag", async (e) => { + if (this.context.draggedColumn) { + e.preventDefault(); + console.log("Container drop event triggered for column:", this.context.draggedColumn); + await this.handleColumnDrop(); + } + }); + } + + private cleanupGlobalColumnDragTracking() { + this.$container.off("dragover.columnDrag"); + this.$container.off("drop.columnDrag"); + } + + private cleanupColumnDropIndicators() { + // Remove column drop indicators + this.$container.find(".column-drop-indicator").remove(); + } + + 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 handleColumnDrop() { + console.log("handleColumnDrop called for:", this.context.draggedColumn); + + if (!this.context.draggedColumn || !this.context.draggedColumnElement) { + console.log("No dragged column or element found"); + return; + } + + try { + // Find the drop indicator to determine insert position + const $dropIndicator = this.$container.find(".column-drop-indicator.show"); + console.log("Drop indicator found:", $dropIndicator.length > 0); + + if ($dropIndicator.length > 0) { + // Get current column order from the API (source of truth) + const currentOrder = [...this.api.columns]; + + let newOrder = [...currentOrder]; + + // Remove dragged column from current position + newOrder = newOrder.filter(col => col !== this.context.draggedColumn); + + // Determine insertion position based on drop indicator position + 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 && insertIndex <= newOrder.length) { + newOrder.splice(insertIndex, 0, this.context.draggedColumn); + } else { + // Fallback: insert at the end + newOrder.push(this.context.draggedColumn); + } + + // Update column order in API + await this.api.reorderColumns(newOrder); + + // 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/drag_handler.ts b/apps/client/src/widgets/view_widgets/board_view/drag_handler.ts index e8bd183e7..4f8866cd2 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 @@ -1,19 +1,16 @@ -import branchService from "../../../services/branches"; import BoardApi from "./api"; - -export interface DragContext { - draggedNote: any; - draggedBranch: any; - draggedNoteElement: JQuery | null; - draggedColumn: string | null; - draggedColumnElement: JQuery | null; -} +import { DragContext } from "./drag_types"; +import { NoteDragHandler } from "./note_drag_handler"; +import { ColumnDragHandler } from "./column_drag_handler"; export class BoardDragHandler { private $container: JQuery; private api: BoardApi; private context: DragContext; private onBoardRefresh: () => Promise; + + private noteDragHandler: NoteDragHandler; + private columnDragHandler: ColumnDragHandler; constructor( $container: JQuery, @@ -25,518 +22,42 @@ export class BoardDragHandler { this.api = api; this.context = context; this.onBoardRefresh = onBoardRefresh; + + // Initialize specialized drag handlers + this.noteDragHandler = new NoteDragHandler($container, api, context, onBoardRefresh); + this.columnDragHandler = new ColumnDragHandler($container, api, context, onBoardRefresh); } + // Note drag methods - delegate to NoteDragHandler 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); - } - - 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.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; - } - - 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() { - // 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(); - } - - // Public method to clean up any stray indicators - can be called externally - cleanup() { - this.cleanupAllDropIndicators(); - this.$container.find('.board-column').removeClass('drag-over'); - this.context.draggedColumn = null; - this.context.draggedColumnElement = null; - this.cleanupGlobalColumnDragTracking(); - } - - 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; - }); + this.noteDragHandler.setupNoteDrag($noteEl, note, branch); } setupNoteDropZone($columnEl: JQuery, column: string) { - $columnEl.on("dragover", (e) => { - // 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"; - } + this.noteDragHandler.setupNoteDropZone($columnEl, column); + } - $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.cleanupNoteDropIndicators($columnEl); - } - }); - - $columnEl.on("drop", async (e) => { - 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); - } - } - }); + // Column drag methods - delegate to ColumnDragHandler + setupColumnDrag($columnEl: JQuery, columnValue: string) { + this.columnDragHandler.setupColumnDrag($columnEl, columnValue); } 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"; - } - - // Don't highlight columns - we only care about the drop indicator position - } - }); - - $columnEl.on("drop", async (e) => { - if (this.context.draggedColumn && !this.context.draggedNote) { - e.preventDefault(); - - // Use the drop indicator position to determine where to place the column - await this.handleColumnDrop(); - } - }); + this.columnDragHandler.setupColumnDropZone($columnEl, columnValue); } - 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; + // Common methods + updateApi(newApi: BoardApi) { + this.api = newApi; + this.noteDragHandler.updateApi(newApi); + this.columnDragHandler.updateApi(newApi); } - 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.cleanupNoteDropIndicators($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 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; - 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(); - } - } - } - - private async handleColumnDrop() { - if (!this.context.draggedColumn || !this.context.draggedColumnElement) { - return; - } - - try { - // Find the drop indicator to determine insert position - const $dropIndicator = this.$container.find(".column-drop-indicator.show"); - - if ($dropIndicator.length > 0) { - // Get current column order from the API (source of truth) - const currentOrder = [...this.api.columns]; - - let newOrder = [...currentOrder]; - - // Remove dragged column from current position - newOrder = newOrder.filter(col => col !== this.context.draggedColumn); - - // Determine insertion position based on drop indicator position - 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 && insertIndex <= newOrder.length) { - newOrder.splice(insertIndex, 0, this.context.draggedColumn); - } else { - // Fallback: insert at the end - newOrder.push(this.context.draggedColumn); - } - - // Update column order in API - await this.api.reorderColumns(newOrder); - - // 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(); - } + cleanup() { + this.noteDragHandler.cleanup(); + this.columnDragHandler.cleanup(); } } + +// Export the drag context type for external use +export type { DragContext } from "./drag_types"; diff --git a/apps/client/src/widgets/view_widgets/board_view/drag_types.ts b/apps/client/src/widgets/view_widgets/board_view/drag_types.ts new file mode 100644 index 000000000..ff3cde8c7 --- /dev/null +++ b/apps/client/src/widgets/view_widgets/board_view/drag_types.ts @@ -0,0 +1,12 @@ +export interface DragContext { + draggedNote: any; + draggedBranch: any; + draggedNoteElement: JQuery | null; + draggedColumn: string | null; + draggedColumnElement: JQuery | null; +} + +export interface BaseDragHandler { + cleanup(): void; + updateApi(api: any): void; +} diff --git a/apps/client/src/widgets/view_widgets/board_view/note_drag_handler.ts b/apps/client/src/widgets/view_widgets/board_view/note_drag_handler.ts new file mode 100644 index 000000000..042f1a630 --- /dev/null +++ b/apps/client/src/widgets/view_widgets/board_view/note_drag_handler.ts @@ -0,0 +1,332 @@ +import branchService from "../../../services/branches"; +import BoardApi from "./api"; +import { DragContext, BaseDragHandler } from "./drag_types"; + +export class NoteDragHandler implements BaseDragHandler { + 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); + } + + setupNoteDropZone($columnEl: JQuery, column: string) { + $columnEl.on("dragover", (e) => { + // 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"; + } + + $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.cleanupNoteDropIndicators($columnEl); + } + }); + + $columnEl.on("drop", async (e) => { + 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); + } + } + }); + } + + updateApi(newApi: BoardApi) { + this.api = newApi; + } + + 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; + }); + } + + 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.cleanupNoteDropIndicators($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") { + await branchService.moveBeforeBranch([draggedBranch.branchId], targetBranchId); + } else if (moveType === "after") { + await branchService.moveAfterBranch([draggedBranch.branchId], targetBranchId); + } + } + + // Update the data attributes + draggedNoteElement.attr("data-current-column", 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(); + } + } + } + + private cleanupAllDropIndicators() { + // Remove all drop indicators from the DOM to prevent layout issues + this.$container.find(".board-drop-indicator").remove(); + } + + private cleanupNoteDropIndicators($columnEl: JQuery) { + // Remove note drop indicators from a specific column + $columnEl.find(".board-drop-indicator").remove(); + } +}