feat(views/board): basic column drag support

This commit is contained in:
Elian Doran 2025-07-23 18:18:40 +03:00
parent 43229f0b99
commit cb37724879
No known key found for this signature in database
5 changed files with 397 additions and 27 deletions

View File

@ -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:

View File

@ -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<BoardData>) {
const statusAttribute = parentNote.getLabelValue("board:groupBy") ?? "status";
let persistedData = await viewStorage.restore() ?? {};
const { byColumn, newPersistedData } = await getBoardData(parentNote, statusAttribute, persistedData);
const columns = Array.from(byColumn.keys()) || [];
// 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;

View File

@ -241,15 +241,33 @@ export class DifferentialBoardRenderer {
.addClass("board-column")
.attr("data-column", column);
// Create header
// Create header with drag handle
const $titleEl = $("<h3>").attr("data-column-value", column);
// Create drag handle
const $dragHandle = $("<span>")
.addClass("column-drag-handle icon bx bx-menu")
.attr("title", "Drag to reorder column");
// Create title text
const $titleText = $("<span>").text(column);
// Create title content container
const $titleContent = $("<div>")
.addClass("column-title-content")
.append($dragHandle, $titleText);
// Create edit icon
const $editIcon = $("<span>")
.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

View File

@ -5,6 +5,8 @@ export interface DragContext {
draggedNote: any;
draggedBranch: any;
draggedNoteElement: JQuery<HTMLElement> | null;
draggedColumn: string | null;
draggedColumnElement: JQuery<HTMLElement> | null;
}
export class BoardDragHandler {
@ -35,6 +37,54 @@ export class BoardDragHandler {
this.setupTouchDrag($noteEl, note, branch);
}
setupColumnDrag($columnEl: JQuery<HTMLElement>, 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<HTMLElement>) {
// Remove drop indicators from a specific column
private cleanupColumnDropIndicators() {
// Remove column drop indicators
this.$container.find(".column-drop-indicator").remove();
}
private cleanupNoteDropIndicators($columnEl: JQuery<HTMLElement>) {
// 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<HTMLElement>, note: any, branch: any) {
@ -175,15 +235,16 @@ export class BoardDragHandler {
});
}
setupColumnDropZone($columnEl: JQuery<HTMLElement>, column: string) {
setupNoteDropZone($columnEl: JQuery<HTMLElement>, 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";
}
if (this.context.draggedNote) {
$columnEl.addClass("drag-over");
this.showDropIndicator($columnEl, e);
}
@ -198,17 +259,60 @@ 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) => {
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);
}
}
});
}
setupColumnDropZone($columnEl: JQuery<HTMLElement>, 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 = $("<div>").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<HTMLElement> = $();
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 = $("<div>").addClass("column-drop-indicator");
if (insertBefore) {
$targetColumn.before($dropIndicator);
} else {
$targetColumn.after($dropIndicator);
}
$dropIndicator.addClass("show");
}
}
private async handleNoteDrop($columnEl: JQuery<HTMLElement>, column: string) {
const draggedNoteElement = this.context.draggedNoteElement;
const draggedNote = this.context.draggedNote;
@ -337,4 +498,74 @@ export class BoardDragHandler {
}
}
}
private async handleColumnDrop($targetColumnEl: JQuery<HTMLElement>, 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();
}
}
}

View File

@ -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<BoardData> {
this.dragContext = {
draggedNote: null,
draggedBranch: null,
draggedNoteElement: null
draggedNoteElement: null,
draggedColumn: null,
draggedColumnElement: null
};
args.$parent.append(this.$root);
@ -320,8 +379,25 @@ export default class BoardView extends ViewMode<BoardData> {
}
private setupBoardInteractions() {
// Handle column title editing
// 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).closest('h3[data-column-value]');
const columnValue = $titleEl.attr('data-column-value');
if (columnValue) {
const columnItems = this.api?.getColumn(columnValue) || [];
this.startEditingColumnTitle($titleEl, columnValue, columnItems);
}
});
// 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');
@ -329,6 +405,7 @@ export default class BoardView extends ViewMode<BoardData> {
const columnItems = this.api?.getColumn(columnValue) || [];
this.startEditingColumnTitle($titleEl, columnValue, columnItems);
}
}
});
// Handle add column button
@ -339,12 +416,21 @@ export default class BoardView extends ViewMode<BoardData> {
}
private createTitleStructure(title: string): { $titleText: JQuery<HTMLElement>; $editIcon: JQuery<HTMLElement> } {
const $dragHandle = $("<span>")
.addClass("column-drag-handle icon bx bx-menu")
.attr("title", "Drag to reorder column");
const $titleText = $("<span>").text(title);
const $titleContent = $("<div>")
.addClass("column-title-content")
.append($dragHandle, $titleText);
const $editIcon = $("<span>")
.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<HTMLElement>, columnValue: string, columnItems: { branch: any; note: any; }[]) {
@ -352,8 +438,9 @@ export default class BoardView extends ViewMode<BoardData> {
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 = $("<input>")