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
5 changed files with 397 additions and 27 deletions

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) => {
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<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,10 +379,10 @@ export default class BoardView extends ViewMode<BoardData> {
}
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<BoardData> {
}
});
// 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<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>")