diff --git a/packages/note-boards/src/board-card.tsx b/packages/note-boards/src/board-card.tsx index 6ce1d63..23f5920 100644 --- a/packages/note-boards/src/board-card.tsx +++ b/packages/note-boards/src/board-card.tsx @@ -1,7 +1,7 @@ import { MarkdownEditor } from "@notes/editor"; import { PopupMenu, useDraggable, usePreventChildDrag } from "@notes/ui"; import { DragEvent, useEffect, useRef, useState } from "react"; -import type { BoardColumn, RichCard } from "./board-format"; +import type { IBoardColumn, RichCard } from "./board-format"; const LABEL_COLORS = ["#e2f0fb", "#fde8d8", "#d9f2e8", "#f5e6fb", "#fef9c3"]; @@ -26,7 +26,7 @@ export function BoardCard({ onDragEnter: (cardId: string, columnName: string) => void; handleDeleteCard: (cardId: string) => void; updateCardState: (card: RichCard) => void; - column: BoardColumn; + column: IBoardColumn; onOpenModal: (card: RichCard) => void; isColumnDragActive?: boolean; }) { diff --git a/packages/note-boards/src/board-column.tsx b/packages/note-boards/src/board-column.tsx new file mode 100644 index 0000000..f81c363 --- /dev/null +++ b/packages/note-boards/src/board-column.tsx @@ -0,0 +1,182 @@ +import { useDraggable } from "@notes/ui"; +import { Fragment, DragEvent } from "react"; +import { BoardCard } from "./board-card"; +import type { IBoardColumn, RichCard } from "./board-format"; + +export function BoardColumn({ + column, + cards, + onRenameColumn, + onDeleteColumn, + onColumnDragStart, + onColumnDragEnd, + draggingColumnName, + setColumnDropBefore, + onDropColumn, + dropTarget, + setDropTarget, + onAddCard, + onMoveCard, + newCardId, + updateCardState, + handleDeleteCard, + setModalCard, + dragRef, +}: { + column: IBoardColumn; + cards: Map; + onRenameColumn: (columnName: string) => void; + onDeleteColumn: (columnName: string) => void; + onColumnDragStart: (e: DragEvent, columnName: string) => void; + onColumnDragEnd: () => void; + draggingColumnName: string | null; + setColumnDropBefore: (columnName: string | null) => void; + onDropColumn: (e: DragEvent, beforeColumnName: string | null) => void; + dropTarget: { column: string; beforeCardId: string | null } | null; + setDropTarget: (target: { column: string; beforeCardId: string | null } | null) => void; + onAddCard: (columnName: string) => void; + onMoveCard: ( + drag: { + cardId: string; + fromColumn: string; + }, + toColumn: string, + beforeCardId: string | null, + ) => void; + newCardId: string | null; + updateCardState: (updated: RichCard) => void; + handleDeleteCard: (cardId: string) => void; + setModalCard: (card: RichCard | null) => void; + dragRef: React.RefObject<{ cardId: string; fromColumn: string } | null>; +}) { + const dragHandle = useDraggable( + { + onDragStart: onColumnDragStart, + onDragEnd: onColumnDragEnd, + }, + column.name, + ); + + const onCardDragStart = (event: DragEvent, cardId: string, fromColumn: string) => { + dragRef.current = { cardId, fromColumn }; + event.dataTransfer.effectAllowed = "move"; + // Mark this drag as a card drag so nested elements can distinguish it + try { + event.dataTransfer.setData("application/x-notes-board-card", cardId); + } catch (error) { + console.error("Failed to set drag data:", error); + } + }; + + const onCardDragEnd = () => { + setDropTarget(null); + dragRef.current = null; + }; + + const onDropCard = (event: DragEvent, toColumn: string, beforeCardId: string | null) => { + event.preventDefault(); + event.stopPropagation(); + setDropTarget(null); + const drag = dragRef.current; + dragRef.current = null; + if (!drag || drag.cardId === beforeCardId) return; + void onMoveCard(drag, toColumn, beforeCardId); + }; + + const onCardDragEnter = (cardId: string, columnName: string) => { + setDropTarget({ column: columnName, beforeCardId: cardId }); + }; + + return ( +
{ + e.stopPropagation(); + if (draggingColumnName) { + setColumnDropBefore(column.name); + return; + } + setDropTarget({ column: column.name, beforeCardId: null }); + }} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + if (draggingColumnName) { + onDropColumn(e, column.name); + return; + } + // Use the tracked dropTarget so drops on silhouette land in the right spot + const beforeId = dropTarget?.column === column.name ? dropTarget.beforeCardId : null; + onDropCard(e, column.name, beforeId); + }} + onDragLeave={(e) => { + if (e.currentTarget.contains(e.relatedTarget as Node)) { + return; + } + if (!draggingColumnName) { + setDropTarget(null); + } + if (!e.currentTarget.closest(".board-scroll")?.contains(e.relatedTarget as Node)) { + setColumnDropBefore(null); + } + }} + > +
+ void onRenameColumn(column.name)}> + {column.name} + + {column.cards.length} + +
+ +
+ {column.cards.map((cardId) => { + const card = cards.get(cardId); + if (!card) return null; + return ( + + {dropTarget?.column === column.name && dropTarget.beforeCardId === cardId && ( +
e.stopPropagation()} + onDragOver={(e) => e.preventDefault()} + /> + )} + + + ); + })} + {/* Silhouette at end of column */} + {dropTarget?.column === column.name && dropTarget.beforeCardId === null && ( +
e.stopPropagation()} + onDragOver={(e) => e.preventDefault()} + /> + )} +
+ + +
+ ); +} diff --git a/packages/note-boards/src/board-format.ts b/packages/note-boards/src/board-format.ts index c2ed0f8..c6a8957 100644 --- a/packages/note-boards/src/board-format.ts +++ b/packages/note-boards/src/board-format.ts @@ -17,7 +17,7 @@ export interface RichCard { frontmatter?: FrontmatterProp[]; } -export interface BoardColumn { +export interface IBoardColumn { name: string; /** Card IDs referencing files in the companion dot-folder. */ cards: string[]; @@ -25,7 +25,7 @@ export interface BoardColumn { export interface BoardModel { frontmatter: FrontmatterProp[]; - columns: BoardColumn[]; + columns: IBoardColumn[]; } let counter = 0; @@ -41,7 +41,7 @@ export function newCardId(): string { */ export function parseBoard(markdown: string): BoardModel { const parsed = parseFrontmatter(markdown); - const columns = getFrontmatterField(parsed.props, "columns") ?? []; + const columns = getFrontmatterField(parsed.props, "columns") ?? []; if (columns.length === 0) { columns.push({ name: "Todo", cards: [] }); diff --git a/packages/note-boards/src/board-view.tsx b/packages/note-boards/src/board-view.tsx index 45f70c4..9a483f2 100644 --- a/packages/note-boards/src/board-view.tsx +++ b/packages/note-boards/src/board-view.tsx @@ -1,10 +1,10 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from "react"; import { usePromptDialog } from "@notes/editor"; import { type NoteViewContextMenuBuilder } from "@notes/ui"; -import { BoardCard } from "./board-card"; +import { BoardColumn } from "./board-column"; import { BoardCardModal } from "./board-card-modal"; import { - type BoardColumn, + type IBoardColumn, BoardModel, parseBoard, type RichCard, @@ -49,9 +49,7 @@ export function BoardView({ value, onChange, path, onRegisterContextMenu }: Boar // Column drag state const [draggingColumnName, setDraggingColumnName] = useState(null); - const columnDragRef = useRef(null); const [columnDropBefore, setColumnDropBefore] = useState(null); - const columnRefs = useRef(new Map()); const boardScrollRef = useRef(null); const lastValue = useRef(value); @@ -92,7 +90,7 @@ export function BoardView({ value, onChange, path, onRegisterContextMenu }: Boar // Persist column layout changes to the board file. const commitColumns = useCallback( - (newColumns: BoardColumn[]) => { + (newColumns: IBoardColumn[]) => { setModel((prev) => ({ ...prev, columns: newColumns })); const md = serializeBoard({ ...model, columns: newColumns }); lastValue.current = md; @@ -146,45 +144,90 @@ export function BoardView({ value, onChange, path, onRegisterContextMenu }: Boar [apiSaveCard], ); - const handleAddCard = async (colName: string) => { - const res = await fetch("/api/card/create", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ boardPath: path, column: colName }), - }); - if (!res.ok) return; - const card = (await res.json()) as RichCard; - setCards((prev) => new Map(prev).set(card.id, card)); - setModel((prev) => ({ - ...prev, - columns: prev.columns.map((col) => - col.name === colName ? { ...col, cards: [...col.cards, card.id] } : col, - ), - })); - setNewCardId(card.id); - // Clear the "new" marker after enough time for the card to mount and focus - setTimeout(() => setNewCardId(null), 600); - }; + const handleAddCard = useCallback( + async (colName: string) => { + const res = await fetch("/api/card/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ boardPath: path, column: colName }), + }); + if (!res.ok) return; + const card = (await res.json()) as RichCard; + setCards((prev) => new Map(prev).set(card.id, card)); + setModel((prev) => ({ + ...prev, + columns: prev.columns.map((col) => + col.name === colName ? { ...col, cards: [...col.cards, card.id] } : col, + ), + })); + setNewCardId(card.id); + // Clear the "new" marker after enough time for the card to mount and focus + setTimeout(() => setNewCardId(null), 600); + }, + [path], + ); - const handleDeleteCard = async (cardId: string) => { - await fetch( - `/api/card?boardPath=${encodeURIComponent(path)}&cardId=${encodeURIComponent(cardId)}`, - { method: "DELETE" }, - ); - setCards((prev) => { - const next = new Map(prev); - next.delete(cardId); - return next; - }); - setModel((prev) => ({ - ...prev, - columns: prev.columns.map((col) => ({ - ...col, - cards: col.cards.filter((id) => id !== cardId), - })), - })); - setModalCard((prev) => (prev?.id === cardId ? null : prev)); - }; + const handleDeleteCard = useCallback( + async (cardId: string) => { + await fetch( + `/api/card?boardPath=${encodeURIComponent(path)}&cardId=${encodeURIComponent(cardId)}`, + { method: "DELETE" }, + ); + setCards((prev) => { + const next = new Map(prev); + next.delete(cardId); + return next; + }); + setModel((prev) => ({ + ...prev, + columns: prev.columns.map((col) => ({ + ...col, + cards: col.cards.filter((id) => id !== cardId), + })), + })); + setModalCard((prev) => (prev?.id === cardId ? null : prev)); + }, + [path], + ); + + const handleMoveCard = useCallback( + async (drag: CardDrag, toColumn: string, beforeCardId: string | null) => { + const targetCol = model.columns.find((c) => c.name === toColumn); + let toIndex = + beforeCardId && targetCol + ? targetCol.cards.indexOf(beforeCardId) + : (targetCol?.cards.length ?? 0); + if (toIndex === -1) { + toIndex = targetCol?.cards.length ?? 0; + } + await fetch("/api/card/move", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ boardPath: path, cardId: drag.cardId, toColumn, toIndex }), + }); + setModel((prev) => { + const stripped = prev.columns.map((col) => ({ + ...col, + cards: col.cards.filter((id) => id !== drag.cardId), + })); + return { + ...prev, + columns: stripped.map((col) => { + if (col.name !== toColumn) return col; + const clampedIdx = Math.max(0, Math.min(toIndex, col.cards.length)); + const next = [...col.cards]; + next.splice(clampedIdx, 0, drag.cardId); + return { ...col, cards: next }; + }), + }; + }); + const card = cards.get(drag.cardId); + if (card && card.column !== toColumn) { + setCards((prev) => new Map(prev).set(drag.cardId, { ...card, column: toColumn })); + } + }, + [path, model, cards], + ); const duplicateCard = useCallback( async (original: RichCard) => { @@ -253,69 +296,6 @@ export function BoardView({ value, onChange, path, onRegisterContextMenu }: Boar return () => onRegisterContextMenu(null); }, [onRegisterContextMenu]); - const handleMoveCard = async (drag: CardDrag, toColumn: string, toIndex: number) => { - await fetch("/api/card/move", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ boardPath: path, cardId: drag.cardId, toColumn, toIndex }), - }); - setModel((prev) => { - const stripped = prev.columns.map((col) => ({ - ...col, - cards: col.cards.filter((id) => id !== drag.cardId), - })); - return { - ...prev, - columns: stripped.map((col) => { - if (col.name !== toColumn) return col; - const clampedIdx = Math.max(0, Math.min(toIndex, col.cards.length)); - const next = [...col.cards]; - next.splice(clampedIdx, 0, drag.cardId); - return { ...col, cards: next }; - }), - }; - }); - const card = cards.get(drag.cardId); - if (card && card.column !== toColumn) { - setCards((prev) => new Map(prev).set(drag.cardId, { ...card, column: toColumn })); - } - }; - - const onDragStart = (event: DragEvent, cardId: string, fromColumn: string) => { - dragRef.current = { cardId, fromColumn }; - event.dataTransfer.effectAllowed = "move"; - // Mark this drag as a card drag so nested elements can distinguish it - try { - event.dataTransfer.setData("application/x-notes-board-card", cardId); - } catch (error) { - console.error("Failed to set drag data:", error); - } - }; - - const onDragEnd = () => { - setDropTarget(null); - dragRef.current = null; - }; - - const onDropCard = (event: DragEvent, toColumn: string, beforeCardId: string | null) => { - event.preventDefault(); - event.stopPropagation(); - setDropTarget(null); - const drag = dragRef.current; - dragRef.current = null; - if (!drag || drag.cardId === beforeCardId) return; - const targetCol = model.columns.find((c) => c.name === toColumn); - const toIndex = - beforeCardId && targetCol - ? targetCol.cards.indexOf(beforeCardId) - : (targetCol?.cards.length ?? 0); - void handleMoveCard(drag, toColumn, toIndex === -1 ? (targetCol?.cards.length ?? 0) : toIndex); - }; - - const onCardDragEnter = (cardId: string, columnName: string) => { - setDropTarget({ column: columnName, beforeCardId: cardId }); - }; - const addColumn = async () => { const values = await openPrompt({ title: "Add column", @@ -354,7 +334,6 @@ export function BoardView({ value, onChange, path, onRegisterContextMenu }: Boar // Column drag handlers const onColumnDragStart = (event: DragEvent, columnName: string) => { - columnDragRef.current = columnName; event.dataTransfer.effectAllowed = "move"; try { event.dataTransfer.setData("application/x-notes-board-column", columnName); @@ -367,14 +346,12 @@ export function BoardView({ value, onChange, path, onRegisterContextMenu }: Boar const onColumnDragEnd = () => { setColumnDropBefore(null); setDraggingColumnName(null); - columnDragRef.current = null; }; const onDropColumn = (event: DragEvent, beforeColumnName: string | null) => { event.preventDefault(); event.stopPropagation(); - const from = columnDragRef.current ?? draggingColumnName; - columnDragRef.current = null; + const from = draggingColumnName; setDraggingColumnName(null); setColumnDropBefore(null); if (!from) return; @@ -397,202 +374,52 @@ export function BoardView({ value, onChange, path, onRegisterContextMenu }: Boar
{ - // If a column is being dragged, treat entering the scroll area as a drop-at-end - if (columnDragRef.current || draggingColumnName) { - e.preventDefault(); - setColumnDropBefore(null); - } - }} - onDragOver={(e) => { - if (!(columnDragRef.current || draggingColumnName)) return; - e.preventDefault(); - const x = e.clientX; - let foundBefore: string | null = null; - for (const col of model.columns) { - const el = columnRefs.current.get(col.name); - if (!el) continue; - const rect = el.getBoundingClientRect(); - // choose before if cursor is left of column midpoint - if (x < rect.left + rect.width / 2) { - foundBefore = col.name; - break; - } - } - setColumnDropBefore(foundBefore); - }} + onDragEnter={(e) => e.stopPropagation()} + onDragOver={(e) => e.preventDefault()} > - {model.columns.map((column) => { - const isDragOver = dropTarget?.column === column.name; - const isDraggingColumn = draggingColumnName === column.name; - return ( - - {columnDropBefore === column.name && - (columnDragRef.current || draggingColumnName) && ( -
{ - e.stopPropagation(); - setColumnDropBefore(column.name); - }} - onDragOver={(e) => e.preventDefault()} - onDragLeave={(e) => { - if (!e.currentTarget.contains(e.relatedTarget as Node)) - setColumnDropBefore(null); - }} - onDrop={(e) => onDropColumn(e, column.name)} - /> - )} - -
{ - if (el) columnRefs.current.set(column.name, el); - else columnRefs.current.delete(column.name); - }} - className={`board-column${isDragOver ? " board-column--drag-over" : ""}${isDraggingColumn ? " board-column--dragging" : ""}`} - onDragEnter={(e) => { - if (columnDragRef.current || draggingColumnName) { - e.preventDefault(); - setColumnDropBefore(column.name); - return; - } - // Only fires when cursor enters column area not covered by a card or silhouette - // (those stop propagation). Set drop to end-of-column. - e.preventDefault(); - setDropTarget({ column: column.name, beforeCardId: null }); - }} - onDragOver={(e) => { - if (columnDragRef.current || draggingColumnName) { - e.preventDefault(); - return; - } - e.preventDefault(); - }} - onDrop={(e) => { - if (columnDragRef.current || draggingColumnName) { - onDropColumn(e, column.name); - return; - } - // Use the tracked dropTarget so drops on silhouette land in the right spot - const beforeId = - dropTarget?.column === column.name ? dropTarget.beforeCardId : null; - onDropCard(e, column.name, beforeId); - }} + {model.columns.map((column) => ( + + {columnDropBefore === column.name && draggingColumnName && ( +
e.stopPropagation()} + onDragOver={(e) => e.preventDefault()} onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) { - if (!(columnDragRef.current || draggingColumnName)) { - setDropTarget(null); - } - // If column drag left the column entirely, clear the column drop indicator - if (columnDragRef.current || draggingColumnName) { - if ( - !e.currentTarget - .closest(".board-scroll") - ?.contains(e.relatedTarget as Node) - ) { - setColumnDropBefore(null); - } - } + setColumnDropBefore(null); } }} - > -
{ - const parent = e.currentTarget.parentElement; - setTimeout(() => { - parent?.classList.add("drag-hidden"); - }); - onColumnDragStart(e, column.name); - }} - onDragEnd={(e) => { - e.currentTarget.parentElement?.classList.remove("drag-hidden"); - onColumnDragEnd(); - }} - > - void renameColumn(column.name)} - > - {column.name} - - {column.cards.length} - -
- -
- {column.cards.map((cardId) => { - const card = cards.get(cardId); - if (!card) return null; - const isDraggedCard = dragRef.current?.cardId === cardId; - const showSilhouette = - !isDraggedCard && - dropTarget?.column === column.name && - dropTarget.beforeCardId === cardId; - return ( - - {showSilhouette && ( -
e.stopPropagation()} - onDragOver={(e) => e.preventDefault()} - /> - )} - - - ); - })} - {/* Silhouette at end of column */} - {dropTarget?.column === column.name && dropTarget.beforeCardId === null && ( -
e.stopPropagation()} - onDragOver={(e) => e.preventDefault()} - /> - )} -
- - -
- - ); - })} + onDrop={(e) => onDropColumn(e, column.name)} + /> + )} + + + ))} {/* Silhouette at end of board for dropping columns at the end */} - {(columnDragRef.current || draggingColumnName) && columnDropBefore === null && ( + {draggingColumnName && columnDropBefore === null && (
{ - e.stopPropagation(); - setColumnDropBefore(null); - }} + onDragEnter={(e) => e.stopPropagation()} onDragOver={(e) => e.preventDefault()} onDrop={(e) => onDropColumn(e, null)} /> diff --git a/packages/note-boards/src/styles.css b/packages/note-boards/src/styles.css index 558a436..5296634 100644 --- a/packages/note-boards/src/styles.css +++ b/packages/note-boards/src/styles.css @@ -32,11 +32,6 @@ background 0.15s; } -.board-column--drag-over { - border-color: var(--accent, #4f8ef7); - background: color-mix(in srgb, var(--bg-elevated) 92%, var(--accent, #4f8ef7) 8%); -} - .board-column-head { display: flex; align-items: center; @@ -99,11 +94,6 @@ flex-shrink: 0; } -.board-column--dragging { - opacity: 0.45; - border-style: dashed; -} - .board-card { display: flex; align-items: flex-start; diff --git a/packages/ui/src/use-draggable.tsx b/packages/ui/src/use-draggable.tsx index 16fee80..6735c09 100644 --- a/packages/ui/src/use-draggable.tsx +++ b/packages/ui/src/use-draggable.tsx @@ -6,9 +6,9 @@ export function useDraggable( onDragStart = () => {}, onDragEnd = () => {}, }: { - isDraggable: boolean; - onDragStart: (event: DragEvent, ...data: T[]) => void; - onDragEnd: () => void; + isDraggable?: boolean; + onDragStart?: (event: DragEvent, ...data: T[]) => void; + onDragEnd?: () => void; }, ...data: T[] ) {