From a0661aaa538d8863c8e680f500cb7e7f2829071d Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 21 May 2026 17:35:25 +0200 Subject: [PATCH] feat: add optional insert-card button between cards Adds a hover "+" affordance in the gap between cards (and above the first / below the last) to insert a new card at that position, instead of only appending to the end of the list. Gated behind a new board setting "Show insert card button between cards" (off by default), so existing behavior is unchanged unless enabled. The inserter is marked data-ignore-drag so it does not interfere with drag-and-drop. Co-Authored-By: Claude Opus 4.7 --- src/Settings.ts | 44 ++++++++++++++++++++++++++ src/components/Item/Item.tsx | 60 +++++++++++++++++++++++++++++++++--- src/components/Lane/Lane.tsx | 1 + src/lang/locale/en.ts | 3 ++ src/styles.less | 57 ++++++++++++++++++++++++++++++++++ 5 files changed, 160 insertions(+), 5 deletions(-) diff --git a/src/Settings.ts b/src/Settings.ts index 97fce8f9..094899fc 100644 --- a/src/Settings.ts +++ b/src/Settings.ts @@ -80,6 +80,7 @@ export interface KanbanSettings { 'show-archive-all'?: boolean; 'show-board-settings'?: boolean; 'show-checkboxes'?: boolean; + 'show-insert-card-button'?: boolean; 'show-relative-date'?: boolean; 'show-search'?: boolean; 'show-set-view'?: boolean; @@ -128,6 +129,7 @@ export const settingKeyLookup: Set = new Set([ 'show-archive-all', 'show-board-settings', 'show-checkboxes', + 'show-insert-card-button', 'show-relative-date', 'show-search', 'show-set-view', @@ -332,6 +334,48 @@ export class SettingsManager { }); }); + new Setting(contentEl) + .setName(t('Show insert card button between cards')) + .setDesc( + t('When toggled, a + button appears on hover between cards to insert a new card there') + ) + .then((setting) => { + let toggleComponent: ToggleComponent; + + setting + .addToggle((toggle) => { + toggleComponent = toggle; + + const [value, globalValue] = this.getSetting('show-insert-card-button', local); + + if (value !== undefined) { + toggle.setValue(value as boolean); + } else if (globalValue !== undefined) { + toggle.setValue(globalValue as boolean); + } + + toggle.onChange((newValue) => { + this.applySettingsUpdate({ + 'show-insert-card-button': { + $set: newValue, + }, + }); + }); + }) + .addExtraButton((b) => { + b.setIcon('lucide-rotate-ccw') + .setTooltip(t('Reset to default')) + .onClick(() => { + const [, globalValue] = this.getSetting('show-insert-card-button', local); + toggleComponent.setValue(!!globalValue); + + this.applySettingsUpdate({ + $unset: ['show-insert-card-button'], + }); + }); + }); + }); + new Setting(contentEl) .setName(t('List width')) .setDesc(t('Enter a number to set the list width in pixels.')) diff --git a/src/components/Item/Item.tsx b/src/components/Item/Item.tsx index 134426d4..86c26f8f 100644 --- a/src/components/Item/Item.tsx +++ b/src/components/Item/Item.tsx @@ -12,6 +12,7 @@ import { import { Droppable, useNestedEntityPath } from 'src/dnd/components/Droppable'; import { DndManagerContext } from 'src/dnd/components/context'; import { useDragHandle } from 'src/dnd/managers/DragManager'; +import { Path } from 'src/dnd/types'; import { frontmatterKey } from 'src/parsers/common'; import { KanbanContext, SearchContext } from '../context'; @@ -181,30 +182,79 @@ export const DraggableItem = memo(function DraggableItem(props: DraggableItemPro ); }); +interface ItemInserterProps { + laneIndex: number; + insertIndex: number; +} + +// Thin hover zone rendered inline between cards. Shows a '+' button to insert a +// new card at that position. Marked data-ignore-drag so a press here is ignored +// by the drag system (same mechanism the card's menu button uses) rather than +// blocking drag-and-drop. +function ItemInserter({ laneIndex, insertIndex }: ItemInserterProps) { + const { boardModifiers, stateManager } = useContext(KanbanContext); + + const handleInsert = useCallback(() => { + const path: Path = [laneIndex, insertIndex]; + boardModifiers.insertItems(path, [stateManager.getNewItem('', ' ', true)]); + }, [boardModifiers, stateManager, laneIndex, insertIndex]); + + return ( +
+ +
+ ); +} + interface ItemsProps { isStatic?: boolean; items: Item[]; shouldMarkItemsComplete: boolean; + laneIndex: number; } -export const Items = memo(function Items({ isStatic, items, shouldMarkItemsComplete }: ItemsProps) { +export const Items = memo(function Items({ + isStatic, + items, + shouldMarkItemsComplete, + laneIndex, +}: ItemsProps) { const search = useContext(SearchContext); - const { view } = useContext(KanbanContext); + const { view, stateManager } = useContext(KanbanContext); const boardView = view.useViewState(frontmatterKey); + const insertButtonEnabled = stateManager.useSetting('show-insert-card-button'); + + // Inserters are opt-in (setting), and skipped in static/search views (indices + // would be unreliable while filtered, and drag isn't active there anyway). + const showInserters = !!insertButtonEnabled && !isStatic && !search?.query; return ( <> {items.map((item, i) => { - return search?.query && !search.items.has(item) ? null : ( + if (search?.query && !search.items.has(item)) return null; + return [ + showInserters && ( + + ), - ); + />, + ]; })} + {/* Trailing inserter after the last card (insert at end). */} + {showInserters && items.length > 0 && ( + + )} ); }); diff --git a/src/components/Lane/Lane.tsx b/src/components/Lane/Lane.tsx index 0a2fd040..9a5e6e91 100644 --- a/src/components/Lane/Lane.tsx +++ b/src/components/Lane/Lane.tsx @@ -195,6 +195,7 @@ function DraggableLaneRaw({ items={lane.children} isStatic={isStatic} shouldMarkItemsComplete={shouldMarkItemsComplete} + laneIndex={laneIndex} />