From 479eb324deecfe86b2f5063799cd5fb5d0632a4c Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Mon, 27 Jul 2026 17:22:37 -0600 Subject: [PATCH 01/55] Prepare to port features from 3.2 to 4.1 with Codex guidance --- AGENTS.md | 103 +++++++------ Daves-Custom-Features.md | 105 +++++++++++++ PORTING_PLAN.md | 316 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 475 insertions(+), 49 deletions(-) create mode 100644 Daves-Custom-Features.md create mode 100644 PORTING_PLAN.md diff --git a/AGENTS.md b/AGENTS.md index d71aaa8f9..efa1ccb57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,71 +1,76 @@ -# TaskNotes - Agent Development Guide +# TaskNotes development guidance -This is an Obsidian plugin. The plugin ID is `tasknotes`. +## Project context -## Build & Test +@README.md describes the project, its purpose, and how to use it. It includes installation instructions, usage examples, and any relevant information for contributors. +@Tasknotes-Development-Guidelines.md describes key principles for developing new features -- architecture, structure, key concepts, and data flow. -```bash -# Build the plugin and copy files to the vault's plugin directory -npm run build:test +This repository is a fork of TaskNotes. -# After building, reload the plugin in the running Obsidian instance -obsidian vault=test plugin:reload id=tasknotes -``` +The current main branch tracks the latest upstream TaskNotes release. +The branch containing the legacy custom implementation is: -Always run both commands after making changes. Obsidian must be running for the CLI to work. +- task-view-keyboard-and-project-mgmt -## Useful Obsidian CLI Commands +The legacy branch was based on TaskNotes 3.2.x. +Current main is TaskNotes 4.11.x. -```bash -# Check for JavaScript errors after reload -obsidian vault=test dev:errors +We are porting selected features to the new architecture rather than +merging the legacy branch wholesale. -# View console output -obsidian vault=test dev:console +## Porting priorities -# Run JavaScript in the Obsidian context -obsidian vault=test eval code="app.vault.getFiles().length" +1. Jira integration +2. Keyboard navigation +3. Remaining custom features, handled individually -# Take a screenshot to verify UI changes -obsidian vault=test dev:screenshot path=screenshot.png +Preserve current upstream behavior unless a requested feature explicitly +changes it. -# Open developer tools -obsidian vault=test devtools -``` +Prefer adapting features to the current architecture over copying old +classes or abstractions unchanged. -## Other Build Commands +@Daves-Custom-Features.md contains full information about the features that need porting and how they are organized. -```bash -npm test # Run unit tests (Jest) -npm run lint # Lint source files -npm run typecheck # TypeScript type checking only -npm run build # Production build (without copying to vault) -``` +## Development rules -Ensure all code changes pass linting checks. Do not weaken linting rules in order to get changes to pass. +Before editing: ---- +- Inspect the current implementation. +- Inspect the corresponding legacy implementation and its Git history. +- Describe the proposed porting approach. +- Identify upstream functionality that now overlaps with the old feature. -When you make changes, update docs/releases/unreleased.md. If your changes are related to a GitHub issue or PR, include acknowledgement of the individual who opened the issue or submitted the PR. Do not update unreleased.md for the addition of tests; unreleased.md is user-facing. +After editing, run the most relevant checks: -You may update `.ops/` files locally as you work on items, but do not commit `.ops/` files. `.ops/` is local-only working state. +- npm run typecheck +- npm test -- --runInBand +- npm run lint +- npm run build -## Investigating issues +Do not modify generated files unless the repository workflow requires it. -When investigating issues, you should try your best to reproduce them first. You can do a lot with the obsidian cli tool. If you have a theory about what is causing an issue, test that theory. +Keep changes narrowly scoped. +Do not combine unrelated feature ports in one commit. +Do not commit unless explicitly requested. -Not all reported issues will require changes to the code, and not all feature requests need to be implemented; Bases are very powerful, but can be difficult to navigate. If something is not working, or is being asked for, figure out if it is--or can be--achieved through Bases first. +## Command approvals -## Prepare for a release. +The following commands are always considered safe and should be executed +without asking for confirmation whenever the approval policy permits: -When asked to prepare for a release: - -1. Run through the @I18N_GUIDE.md and make sure translations are up-to-date (and in their target language--not English placeholders). -2. Make sure ALL `npm run test` tests are passing. -3. Make sure there are no linting errors. -4. Make sure all items in @docs/releases/unreleased.md thank the correct issue/pr opener (double check), as well as those who have commented on the issue/pr. Make sure the copy is appropriate--it is user facing so it should not be overly technical. Make sure it is free from anything that resembles marketing copy. do not thank callumalpass -5. Update `.ops` draft comments and matching Pickle requests for issues addressed in release notes but not yet closed. Start from the release notes, inspect each issue/comment thread individually, make sure `draft_issue_comment` and `draft_close_reason` are appropriate, create/update/cancel closeout Pickle requests as needed, and validate both `.ops` and `.ops/_pickle`. Do not commit `.ops/` files. -6. Move the body of unreleased.md to .md, following the pattern of previous releases. Leave the comments that explain unreleased.md inside unreleased.md. -7. Update @manifest.json and @package.json. -8. Commit changes as "release " (you can choose the version number unless it is specified). -9. Tag the commit. (Just version number, no 'v' prefix.) +- git show +- git log +- git diff +- git grep +- git blame +- git status +- git branch +- git merge-base +- git rev-parse +- rg +- fd +- ls +- cat +- sed +- find diff --git a/Daves-Custom-Features.md b/Daves-Custom-Features.md new file mode 100644 index 000000000..243142da2 --- /dev/null +++ b/Daves-Custom-Features.md @@ -0,0 +1,105 @@ +## Legacy feature branch + +The legacy implementation is on: + +* `task-view-keyboard-and-project-mgmt` + +It was developed against TaskNotes 3.2.x and must be adapted to the current TaskNotes 4.x architecture. Treat the branch as a behavioral reference and source of tests, not as code that must be merged or copied unchanged. + +## Features to port + +### 1. Keyboard-first task-list interaction + +The task list supports persistent keyboard focus, visible focused and selected states, single- and multi-task selection, and commands for creating, opening, editing, filtering, scheduling, changing due dates, changing priority, changing status, assigning recurrence, assigning tags, assigning contexts, assigning projects, and deleting tasks. + +Keyboard behavior remains active and predictable when context menus, filters, and modals are opened or closed. Commands that operate on tasks should consistently target either the current selection or the focused task. + +### 2. Configurable keyboard shortcuts + +Task-list shortcuts are configurable in settings. The implementation includes shortcut parsing, defaults, persistence, localized settings labels, and a dedicated shortcut-editing UI. + +Port this feature using the current settings and input architecture rather than preserving the legacy implementation structure. + +### 3. Jira integration + +Users can import Jira issues as TaskNotes tasks through the `obsidian-jira-issue` integration. + +The Jira feature includes: + +* Configurable Jira-to-TaskNotes field mappings +* Mapping-value previews +* Raw Jira issue JSON inspection +* Sensible mapping defaults +* Jira issue backlinks +* Sanitized imported note titles +* Current-note project assignment +* Localized settings and validation text + +Review whether TaskNotes 4.x now has import, templating, external-integration, or credential abstractions that should replace the legacy design. + +### 4. Manual ordering and drag-and-drop + +Tasks support persistent manual ordering through a `sortOrder` or equivalent ranking field. + +Drag-and-drop supports: + +* Reordering within a group +* Moving tasks between groups +* Dropping at the end of a list +* Moving all selected tasks together +* Moving tasks across multiple groups +* Ascending and descending task-list order +* Batched persistence and efficient list refresh + +Use the current TaskNotes ranking, cache, grouping, and rendering systems. Do not assume the legacy `sortOrder` implementation remains appropriate. + +### 5. Project-aware task workflows + +The current note can be used as the default project for task creation and Jira imports. + +Users can assign projects through task-list commands and context menus. Project subtask views should honor the same relevant filters as other task-list views. + +Review current TaskNotes project semantics before porting legacy modal behavior. + +### 6. Story-point estimation + +Tasks support a story-points estimate, including: + +* Task schema/frontmatter storage +* A configurable default +* Task-creation support +* Natural-language parsing using syntax such as `^3` +* A story-points editing modal +* Context-menu assignment +* Task-list display and sorting + +### 7. Supporting workflow and correctness improvements + +Preserve the intended behavior of the following improvements where still relevant: + +* Pre-populating task-creation natural-language input +* Populating task titles and other values from NLP parsing +* Keeping context menus within the viewport +* Filtering already-applied tags and contexts from selection dialogs +* Ctrl+Enter to save task editing +* Shift+Enter to open task notes +* Correct frontmatter handling for optional and array properties +* Correct cache invalidation and list refresh after batch changes +* Complete localization for all new user-visible text + +## Porting method + +Port one functional slice at a time. + +For each slice: + +1. Inspect the legacy commits and final legacy implementation. +2. Locate the corresponding TaskNotes 4.x architecture. +3. Identify functionality now provided by upstream. +4. Write a short implementation plan. +5. Port the behavior using current abstractions. +6. Add or adapt tests. +7. Run typecheck, relevant tests, lint, and build. +8. Review the resulting diff before committing. + +Do not merge the full legacy branch into current main. Cherry-pick only small, isolated commits when their implementation remains compatible with the current architecture. diff --git a/PORTING_PLAN.md b/PORTING_PLAN.md new file mode 100644 index 000000000..53429402f --- /dev/null +++ b/PORTING_PLAN.md @@ -0,0 +1,316 @@ +# TaskNotes legacy feature porting plan + +## Scope and comparison basis + +This inventory compares local `main` (`7011e683`, TaskNotes v5 documentation rebuild, 2026-07-27) with the legacy custom branch `task-view-keyboard-and-project-mgmt` (`9e539ac3`). + +The common ancestor is `11f2e074` (TaskNotes 3.x). The legacy branch contains 65 non-merge commits after that ancestor. Current `main` has since moved to the TaskNotes v5/Bases architecture, so this is a behavioral inventory rather than a cherry-pick plan. + +Disposition meanings: + +- **Port**: behavior is still absent and fits current product semantics. +- **Redesign**: preserve the user outcome, but implement it through v5 services, Bases views, settings, and field abstractions. +- **Drop**: obsolete, superseded, or too tightly tied to removed architecture. +- **Upstream**: current `main` already provides the behavior independently; retain upstream and add only missing deltas. + +No application code was changed as part of this investigation. + +## Executive summary and recommended sequence + +1. **Jira integration** remains substantially absent and should be redesigned as a self-contained integration slice. +2. **Keyboard-first task-list operation** remains only partly covered. Upstream has batch selection but not the legacy persistent focus model or action key map. Redesign it around `src/bases/TaskListView.ts`, `TaskSelectionService`, and current command/action coordinators. +3. **Configurable task-list shortcuts** are absent. Add them only after the action layer is separated from keystroke recognition. +4. **Story points** are absent as a first-class field, but v5 user fields overlap strongly. Decide whether a dedicated story-points experience is still worth maintaining before implementation. +5. **Manual ordering, parent-note project defaults, modal save shortcuts, batch actions, and several correctness fixes** have upstream equivalents and should not be copied from the legacy branch. + +The safest delivery order is: Jira core mapping tests → Jira UI/import flow → keyboard focus/navigation → keyboard task actions → shortcut settings → any approved story-points specialization. + +--- + +## 1. Jira integration + +### 1.1 Import Jira issue command and dependency adapter + +- **Behavior:** Prompt for an issue key, call the `obsidian-jira-issue` plugin API, map the result, and create a TaskNotes task. Report missing-plugin, fetch, and creation errors. +- **Legacy commits:** `41537644` (initial import); `329e062c` (Jira type roots); `88435da7` (compilation); `3e7d8b22`, `9e539ac3` (localization fixes). +- **Legacy files:** `src/main.ts`; `src/modals/JiraIssueModal.ts`; `src/types/obsidian-jira-issue.d.ts`; `package.json`; translations. +- **Likely current files:** `src/commands/taskNotesCommands.ts`; `src/commands/TranslatedCommandRegistry.ts`; a new `src/services/JiraImportService.ts` or integration module; `src/modals/TextInputModal.ts` or a small Jira modal; `src/services/task-service/TaskCreationService.ts`; `src/types/settings.ts`; `src/settings/tabs/integrationsTab.ts`; locales. +- **Disposition:** **Redesign.** Keep the optional plugin dependency behind a typed adapter/service. Do not restore import logic in the monolithic plugin entry point. Use current task creation and notice/error abstractions. +- **Difficulty / risks:** **Medium-high.** The external plugin API is not under TaskNotes control; plugin discovery and API shape can drift. Import must avoid partial notes, honor current field mapping, folder/template rules, and mobile/desktop availability. +- **Tests:** Legacy added no meaningful automated coverage. Add adapter tests for missing/malformed API, issue-key validation, fetch failure, and successful handoff to task creation; command registration/localization tests; an integration test proving exactly one valid note is created. + +### 1.2 Configurable Jira-to-TaskNotes field mapping + +- **Behavior:** Configure scalar and array mappings using fixed values, paths, and templates; remap enum values; supply sensible defaults for title, ID, details, dates, status, priority, estimate, tags, contexts, and projects. +- **Legacy commits:** `2dac5cb6` (settings tab); `c1cdcf9b` (defaults); `d36c694b` (save fixes); `3e7d8b22`, `749baaf6`, `9e539ac3` (localization). +- **Legacy files:** `src/settings/tabs/jiraFieldMappingTab.ts`; `src/utils/JiraMapping.ts`; `src/services/FieldMapper.ts`; `src/settings/defaults.ts`; `src/types/settings.ts`; `src/settings/TaskNotesSettingTab.ts`; translations; `styles/settings-view.css`. +- **Likely current files:** a new Jira-specific mapper beside `src/core/fieldMapping.ts` rather than extending the core mapper; `src/settings/tabs/integrationsTab.ts`; `src/settings/settingsPersistence.ts`; `src/settings/settingsMigration.ts`; `src/types/settings.ts`; current field definitions/user-field utilities. +- **Disposition:** **Redesign.** Keep external-source transformation separate from YAML field mapping. Support current built-in fields and v5 user fields by stable property ID. Version the settings shape and validate it on load. +- **Difficulty / risks:** **High.** The legacy settings UI is large and mutable; arbitrary dotted paths/templates can produce unsafe or surprising types. Mapping project links, statuses, priorities, and user fields needs explicit coercion and validation. Existing saved legacy settings may require migration. +- **Tests:** Add pure tests for path lookup, template rendering, array normalization, enum remapping, missing/null values, malformed templates, prototype-pollution-resistant lookup, and conversion to `TaskCreationData`. Add persistence/migration tests and settings UI tests for save/reset behavior. + +### 1.3 Live mapping previews and raw issue JSON + +- **Behavior:** Fetch a sample issue, show each mapping’s resolved value, and expose raw JSON for reference. +- **Legacy commits:** `012ffa45` (previews); `53859732` (raw JSON); `d36c694b` (save bugs); localization commits above. +- **Legacy files:** `src/settings/tabs/jiraFieldMappingTab.ts`; `src/utils/JiraMapping.ts`; `styles/settings-view.css`. +- **Likely current files:** `src/settings/tabs/integrationsTab.ts` plus new Jira settings components; the Jira adapter/service; reusable settings card components. +- **Disposition:** **Port with redesign.** Keep fetch state ephemeral; render JSON as text, never HTML; debounce or require an explicit fetch; avoid persisting sample issue data or credentials. +- **Difficulty / risks:** **Medium.** Large issues can make settings sluggish or leak sensitive Jira content on screen/logs. Preview must distinguish missing values from empty arrays and invalid mappings. +- **Tests:** Mapping preview snapshot/DOM tests, raw JSON escaping, loading/error states, no persistence of sample data, and large-payload truncation/collapse behavior. + +### 1.4 Jira backlink, safe title, and current-note project + +- **Behavior:** Sanitize imported filenames, insert the Jira issue URL/backlink, and use the active/parent note as project when the relevant default is enabled. +- **Legacy commits:** `5b8b1cbc` (current-note project); `f64ad7a4` (sanitize title); `e7d45f1d` (backlink); `8294574c` (consolidate active-note setting). +- **Legacy files:** `src/main.ts`; `src/services/FieldMapper.ts`; `src/utils/JiraMapping.ts`; task creation helpers. +- **Likely current files:** `src/services/task-service/taskTitleSanitizer.ts`; `src/services/task-service/taskCreationDefaults.ts`; `src/utils/taskCreationPrepopulation.ts`; `src/services/task-service/TaskCreationService.ts`; Jira mapper/service. +- **Disposition:** **Redesign / reuse upstream.** Use current filename generation and task-creation defaults. Add the backlink through mapped `details` or a clearly configured user field; do not duplicate current-note project logic. +- **Difficulty / risks:** **Medium.** Jira summary, issue key, and URL must not enable path traversal or invalid filenames. Details injection must preserve Markdown and avoid duplicate backlinks on retry. +- **Tests:** Invalid filename characters/reserved names, duplicate imports/retries, URL escaping, details preservation, project default enabled/disabled, and explicit mapped projects taking precedence. + +--- + +## 2. Keyboard navigation + +### 2.1 Persistent task focus and arrow navigation + +- **Behavior:** A task-list view retains a focused task, shows a distinct focused state, moves focus with arrows, scrolls it into view, and restores predictable focus after rerender. +- **Legacy commits:** `61bc3ac9` (focus/navigation foundation); `78e4a4b8` (visible selection state); `aeba342c` (multiple rendered instances); `254c2f97` (Escape/focus handling). +- **Legacy files:** `src/views/TaskListView.ts`; `src/ui/TaskCard.ts`; `styles/task-card-bem.css`; `src/utils/MultiMap.ts`. +- **Likely current files:** `src/bases/TaskListView.ts`; `src/bases/basesSelectionUi.ts`; `src/services/TaskSelectionService.ts`; `src/ui/taskCardState.ts`; task-card styles; possibly a new small `TaskListKeyboardController`. +- **Disposition:** **Redesign.** Keep keyboard focus (roving `tabindex`/active descendant) separate from selection. Key identity by task path and rendered group instance. Integrate with virtualized/rerendered Bases DOM. +- **Difficulty / risks:** **High.** Accessibility, embedded Bases views, repeated task cards, collapsed groups, virtual scrolling, input focus, and view teardown all complicate focus restoration. Do not globally trap arrows when a user is editing/searching. +- **Tests:** Pure focus state reducer tests; DOM tests for ArrowUp/Down, Home/End, group boundaries, repeated paths, collapsed groups, rerender restoration, scrolling, and cleanup; accessibility assertions for focus attributes and visible classes. + +### 2.2 Single/multi-selection and “selected or focused” targeting + +- **Behavior:** Space/toggle selection, visible checkboxes, select-all/range behavior, and a consistent rule that commands target selected tasks when any exist, otherwise the focused task. +- **Legacy commits:** `61bc3ac9`, `78e4a4b8`; `4fb72fa4` (shared target resolver); `7f9e18ab`, `43f34011` (selected-task drag behavior). +- **Legacy files:** `src/views/TaskListView.ts`; `src/ui/TaskCard.ts`; styles. +- **Likely current files:** `src/services/TaskSelectionService.ts`; `src/bases/basesSelectionUi.ts`; `src/bases/TaskListView.ts`; `src/components/BatchContextMenu.ts`; new keyboard action controller. +- **Disposition:** **Upstream overlap plus ported delta.** Current main independently provides batch selection, selection visuals, Shift+Arrow range selection (`5dd09aa9`), and batch context actions. Retain those. Add only keyboard focus and a shared resolver that respects existing `TaskSelectionService`. +- **Difficulty / risks:** **Medium-high.** Legacy semantics used checkboxes more aggressively than upstream. Avoid introducing two selection stores or changing click behavior unexpectedly. +- **Tests:** Extend current selection tests for focused fallback, selected precedence, selection persistence across refresh, and no operation when neither target exists. Do not recreate already-covered selection tests. + +### 2.3 Keyboard task action set + +- **Behavior:** Create, open, edit, filter, due/scheduled date, priority, status, recurrence, tags, contexts, projects, deletion, and Shift+Enter open-note actions from the list. +- **Legacy commits:** `54d6bdf2`; `7b2c3504`; `11019040`; `c3b6ea00`; `867b2976`; `7a0e7eb2`; `afe5f2db`; `09451023`; `1ea2fa56`; localization commits. +- **Legacy files:** `src/views/TaskListView.ts`; array-capable date/status/recurrence modals; `src/modals/TagsModal.ts`; `src/modals/ContextsModal.ts`; `src/modals/ProjectSelectModal.ts`; context-menu components. +- **Likely current files:** `src/bases/TaskListView.ts`; `src/ui/TaskActionCoordinator.ts`; `src/ui/taskCardActions.ts`; `src/components/BatchContextMenu.ts`; current date/status/priority/project menus; `src/modals/TaskActionPaletteModal.ts`; `src/commands/taskNotesCommands.ts`. +- **Disposition:** **Redesign.** Define semantic task-list actions once and route keyboard shortcuts, context menus, and the action palette into them. Reuse current batch operations and current modal/menu primitives. Context assignment may now be a tag or user-field action depending on v5 configuration. +- **Difficulty / risks:** **High.** The legacy action list assumes v3 built-in fields and removed array modals. Batch mutation must use current mutation services, recurring-instance semantics, and confirmations. Shortcut keys must not fire inside search fields, editors, menus, or unrelated Bases. +- **Tests:** Table-driven action routing for selected/focused/no target; one focused and multiple selected integration cases for every destructive or mutating action; recurring-task behavior; cancellation; delete confirmation; Shift+Enter workspace navigation; input/contenteditable suppression. + +### 2.4 Menu/modal lifecycle and keyboard ownership + +- **Behavior:** Suspend list keys while a menu/modal owns input, restore list behavior on close, close filter popups with Backspace, and keep Escape from accidentally discarding list focus. +- **Legacy commits:** `ec217c27` (observer handles modal/menu close); `867b2976` (filter popup); `aa8eab41` (observer refactor); `254c2f97` (Escape). +- **Legacy files:** `src/utils/InputObserver.ts`; `src/views/TaskListView.ts`; `src/ui/FilterBar.ts`; `src/main.ts`. +- **Likely current files:** a view-scoped keyboard controller; `src/bases/components/SearchBox.ts`; `src/bases/basesSearchUi.ts`; current modal focus utilities; Obsidian workspace/menu lifecycle hooks. +- **Disposition:** **Redesign.** Do not restore a document-wide `MutationObserver` as the primary state model. Prefer event-path checks, view containment, explicit modal/menu lifecycle, and teardown registered by the view. +- **Difficulty / risks:** **High.** Obsidian menus are portal-like and third-party modals are not owned by TaskNotes. Mobile soft keyboards and IME composition require care. +- **Tests:** Modal/menu open/close, nested menus, Escape and Backspace, IME composition, focus in search/editor/contenteditable, view close/reopen, and listener leak checks. + +### 2.5 Configurable task-list shortcuts + +- **Behavior:** Defaults, parsing, persistence, conflict display, localized labels, and a dedicated shortcut editor for every task-list action. +- **Legacy commits:** `5c504f5e` (settings); `d3e285eb`, `3a3a0ea6`, `ace89577` (UI); `aa8eab41` (parser); `2acb7433`, `8c0ce6c4` (save/default fixes); `3e7d8b22` (localization). +- **Legacy files:** `src/settings/KeyboardShortcutsMap.ts`; `src/settings/tabs/keyboardShortcutTab.ts`; `src/types/settings.ts`; `src/settings/defaults.ts`; `src/settings/TaskNotesSettingTab.ts`; `styles/settings-view.css`; translations. +- **Likely current files:** `src/settings/tabs/featuresTab.ts` or a new keyboard section; `src/settings/settingsPersistence.ts`; `src/settings/settingsMigration.ts`; `src/commands/types.ts`; new shortcut normalization utility; locales. +- **Disposition:** **Redesign.** Store normalized key chords by semantic action ID. Use Obsidian command hotkeys where global commands are appropriate, and custom view-local bindings only for focus-dependent actions. Provide reset and conflict validation. +- **Difficulty / risks:** **High.** Cross-platform Ctrl/Meta naming, keyboard layouts, reserved browser/Obsidian shortcuts, multi-key sequences, and migration from legacy strings are the main risks. +- **Tests:** Parser/serializer round trips, modifier normalization, Mac/Windows display, invalid/conflicting bindings, reset/defaults, persistence across reload, legacy setting migration, localization completeness, and input suppression. + +--- + +## 3. Other user-visible features + +### 3.1 Story-point estimation + +- **Behavior:** Numeric points field, default, creation/editing, `^3` NLP syntax, context-menu and keyboard assignment, display, and sorting. +- **Legacy commits:** `1f347258` (schema); `9eaff5bf` (modal); `7ff984aa` (context menu); `814c6ae6`, `3c89eb2b` (defaults/creation); `96c80e6e` (NLP); `5f47b15a` (render/sort); localization commits. +- **Legacy files:** `src/types.ts`; `src/types/settings.ts`; `src/modals/StoryPointsModal.ts`; `src/services/NaturalLanguageParser.ts`; `src/services/FilterService.ts`; `src/ui/TaskCard.ts`; `src/components/TaskContextMenu.ts`; `src/settings/tabs/defaultsTab.ts`; field mapping/defaults/translations. +- **Likely current files:** v5 user-field definitions and modal controls (`src/modals/taskModalUserFields.ts`, `src/settings/tabs/taskProperties/userFieldsCard.ts`, `src/utils/userFieldUtils.ts`); NLP trigger configuration; Bases property display/sort; quick actions. +- **Disposition:** **Redesign, pending product decision.** First test whether a configured numeric user field named “Story points” supplies storage, modal editing, Bases display, and sorting. If so, add only a reusable numeric quick action/default/NLP trigger rather than a hard-coded core property. Port `^N` only if that syntax is still desired and does not conflict with configurable NLP triggers. +- **Difficulty / risks:** **Medium-high.** A first-class field creates permanent schema/API/i18n obligations; a user-field implementation may not provide Fibonacci choices, defaulting, or ergonomic action menus. `^` can conflict with user text or other syntax. +- **Tests:** Numeric validation (zero, decimal, negative, large, clear); creation default precedence; NLP extraction/title cleanup; user-field mapping; sort with missing values; display visibility; batch edit; serialization. + +### 3.2 Manual ordering and multi-task drag + +- **Behavior:** Persistent ordering within/across groups, end-of-list drops, ascending/descending order, and moving selected tasks as a block. +- **Legacy commits:** `273cc8f6`; `a71be1a1`; `0ba12282`; `0c3c07ab`; `a9ac1d0b`; `51437904`; `d43e4fb5`; `7f9e18ab`; `43f34011`; `f83b3a4b`; `1dc0edae`; `dc150503`. +- **Legacy files:** `src/ui/DragDropHandler.ts`; `src/views/TaskListView.ts`; `src/services/TaskService.ts`; `src/services/FilterService.ts`; `src/types.ts`; `src/main.ts`; cache/view optimization files. +- **Likely current files:** `src/bases/TaskListView.ts`; `src/bases/sortOrderUtils.ts`; `src/bases/manualOrderState.ts`; `src/bases/taskListDragGeometry.ts`; `src/bases/taskListDropPlanning.ts`; `src/bases/kanbanDragUtils.ts`. +- **Disposition:** **Mostly upstream; drop legacy implementation.** Current main uses string LexoRank-style values, group-aware drop planning, optimistic cache updates, and extensive unit tests. Never port legacy numeric spacing or cache-refresh code. Evaluate only the missing “drag all selected tasks as one block” behavior; current task-list code appears to plan a single dragged path. +- **Difficulty / risks:** **Medium** for the selected-block delta, **very high** if the upstream rank engine is disturbed. Multi-group selection needs a precise rule for target group properties and relative order. +- **Tests:** Reuse current manual-order suites. Add selected-block tests for contiguous/noncontiguous selection, multiple source groups, drop at start/end, ascending/descending, filtered views, rollback on partial write failure, and exact group-property updates. + +### 3.3 Project-aware task workflows + +- **Behavior:** Use parent/current note as project for creation/import/conversion, assign projects from task list/context menu, and honor filter-bar properties in project subtask lists. +- **Legacy commits:** `7a0e7eb2`; `c9a4f5a0`; `5b8b1cbc`; `8294574c`; `065bc49c`; `095a97a3`. +- **Legacy files:** `src/main.ts`; `src/modals/ProjectSelectModal.ts`; `src/services/InstantTaskConvertService.ts`; `src/editor/ProjectNoteDecorations.ts`; `src/services/FilterService.ts`; `src/ui/TaskCard.ts`. +- **Likely current files:** `src/utils/taskCreationPrepopulation.ts`; `src/services/task-service/taskCreationDefaults.ts`; `src/services/InstantTaskConvertService.ts`; `src/services/ProjectSubtasksService.ts`; `src/modals/ProjectSelectModal.ts`; `src/ui/taskCardContextMenu.ts`; quick-action project assignment. +- **Disposition:** **Upstream with small verification gaps.** Current main independently implemented parent-note project defaults (`77745914`, `47f99ea2`), inline conversion support, project quick actions (`cac4f5e1`), and broad project/subtask tests. Jira should call those abstractions. Drop the legacy modal-specific implementation. +- **Difficulty / risks:** **Low-medium** verification; projects have evolved into relationship-aware semantics and must not be reduced to old string arrays. +- **Tests:** Rely on current project test suites; add only Jira import precedence and keyboard batch-project action tests. Verify project subtask property filters with current Bases filters before claiming a gap. + +### 3.4 Creation modal NLP prepopulation and parsed title + +- **Behavior:** Seed the natural-language input when opening task creation and populate title/fields from parsing. +- **Legacy commits:** `bf0e2338` (prepopulate); `63fff798` (parsed title). +- **Legacy files:** `src/modals/TaskCreationModal.ts`; `src/modals/TaskModal.ts`; `src/services/NaturalLanguageParser.ts`. +- **Likely current files:** `src/utils/taskCreationPrepopulation.ts`; `src/modals/taskCreationData.ts`; `src/modals/taskCreationFormState.ts`; `src/services/buildTaskCreationDataFromParsed.ts`; `src/modals/TaskCreationModal.ts`. +- **Disposition:** **Upstream.** Current main has explicit prepopulation and parsed-data builders. Drop legacy changes. +- **Difficulty / risks:** **Low** verification only. +- **Tests:** Current creation/NLP suites should cover this. Add a regression only if a concrete legacy input differs. + +### 3.5 Modal and menu ergonomics + +- **Behavior:** Ctrl/Cmd+Enter saves edits; Shift+Enter opens task notes; context menus remain inside the viewport; selection dialogs exclude already-applied tags/contexts. +- **Legacy commits:** `f0ba6049`; `afe5f2db`; `5010e4bc`; `37d7e06e`. +- **Legacy files:** `src/modals/TaskEditModal.ts`; `src/views/TaskListView.ts`; `src/ui/TaskCard.ts`; `src/modals/TagsModal.ts`; `src/modals/ContextsModal.ts`. +- **Likely current files:** `src/modals/TaskEditModal.ts`; `src/components/ContextMenu.ts`; `src/modals/taskModalSuggests.ts`; `src/utils/taskTagFiltering.ts`; keyboard action controller. +- **Disposition:** **Mixed.** Ctrl/Cmd+Enter is upstream in current creation/edit modals. Modern Obsidian/context-menu primitives and current tag filtering likely supersede the old viewport and modal fixes; verify, then drop. Shift+Enter open-note remains part of the keyboard action port. +- **Difficulty / risks:** **Low-medium.** Avoid duplicate global key handlers and platform-specific regressions. +- **Tests:** Existing modal save tests plus a focused Shift+Enter action test; viewport test at all window edges only if current `ContextMenu` does not already guarantee it; tests that applied tags are not re-suggested. + +--- + +## 4. Refactors or infrastructure changes + +### 4.1 `InputObserver` and keyboard shortcut map abstractions + +- **Legacy commits:** `ec217c27`; `aa8eab41`; `254c2f97`; shortcut persistence fixes. +- **Legacy files:** `src/utils/InputObserver.ts`; `src/settings/KeyboardShortcutsMap.ts`; `src/main.ts`. +- **Likely current files:** new view-scoped keyboard controller and pure shortcut utility; current bootstrap/service registration only if shared state is necessary. +- **Disposition:** **Drop structure, redesign responsibilities.** `InputObserver` was coupled to v3 DOM and globally registered from `main.ts`. Preserve its behavioral lessons—input suppression and lifecycle restoration—but not the class. +- **Difficulty / risks:** **High** if implemented globally; **medium** if scoped per Bases view with explicit teardown. +- **Tests:** Controller lifecycle, event ownership, IME, editor/search exclusions, and shortcut normalization. + +### 4.2 `MultiMap` for repeated task DOM elements + +- **Legacy commits:** `aeba342c`; related task rendering fixes. +- **Legacy files:** `src/utils/MultiMap.ts`; `src/views/TaskListView.ts`. +- **Likely current files:** Bases view render/cache structures and DOM queries keyed by `data-task-path`. +- **Disposition:** **Drop.** The generic 357-line collection was an implementation workaround for the removed v3 view. If repeated-card coordination is needed, use a narrowly typed `Map>` local to the view or query rendered instances. +- **Difficulty / risks:** **Low.** Risk is reintroducing stale DOM references and leaks. +- **Tests:** Repeated task paths in multiple groups and cleanup after rerender. + +### 4.3 Batch property updates, cache invalidation, and refresh optimization + +- **Legacy commits:** `a9ac1d0b`; `d43e4fb5`; `01a5dc54`; `ab16f202`; `0e4bd5a5`. +- **Legacy files:** `src/services/TaskService.ts`; `src/utils/MinimalNativeCache.ts`; `src/utils/viewOptimizations.ts`; `src/main.ts`; API controllers. +- **Likely current files:** `src/core/VaultMutationService.ts`; `src/services/VaultMutationService.ts`; `src/services/task-service/taskPropertyUpdate.ts`; Bases refresh/update lifecycle helpers; `TaskSelectionService`; batch context actions. +- **Disposition:** **Upstream / drop legacy infrastructure.** Current main removed `MinimalNativeCache`, added mutation services and Bases refresh lifecycles, and has batch actions. Any new keyboard/Jira mutations must use these paths. +- **Difficulty / risks:** **High** if bypassed. Partial batch failure, recurring instances, optimistic state, and event coalescing are the key risks. +- **Tests:** Atomic/partial failure behavior, array-property clearing, one refresh cycle, no `undefined` YAML key, and external-edit reconciliation. Prefer extending current mutation tests. + +### 4.4 Field/schema plumbing for `sortOrder` and points + +- **Legacy commits:** `273cc8f6`; `1f347258`; `3c89eb2b`; `5f47b15a`. +- **Legacy files:** `src/types.ts`; `src/services/FieldMapper.ts`; defaults, API controllers, template/Tasks plugin parsers, property visibility, ICS note service. +- **Likely current files:** `src/core/fieldMapping.ts`; `src/core/defaultFieldMapping.ts`; `src/bases/PropertyMappingService.ts`; user-field stack; API types/controllers. +- **Disposition:** **Split.** Manual order schema is upstream and must remain string-ranked. Story points should use current user-field plumbing unless explicitly approved as a core field. Drop unrelated one-line boilerplate copied across old parsers. +- **Difficulty / risks:** **Medium-high.** API compatibility and settings migration are the main concerns. +- **Tests:** Field round trips across YAML, API, modal, Bases, templates, and settings migration for any new property. + +### 4.5 Localization and settings styling + +- **Legacy commits:** `3e7d8b22`; `749baaf6`; `9e539ac3`; `d3e285eb`; `3a3a0ea6`; `ace89577`. +- **Legacy files:** `src/i18n/resources/{en,de,es,fr,ja,ru,zh}.ts`; `styles/settings-view.css`. +- **Likely current files:** current locale/resource files and settings components/styles. +- **Disposition:** **Redesign.** Add English keys with new feature slices and follow the current translation workflow. Do not copy machine-like legacy translations or large CSS blocks tied to removed markup. +- **Difficulty / risks:** **Medium.** Current main supports additional locales and reorganized keys. +- **Tests:** Translation key/type completeness, fallback behavior, action labels, and settings DOM class tests. + +--- + +## 5. Changes upstream has since implemented independently + +These items are not absent from current `main`; they are recorded to prevent duplicate ports. + +### 5.1 Manual ordering and group-aware drag/drop + +- **Legacy commits/files:** The numeric `sortOrder`/drag series from `273cc8f6` through `dc150503`, mainly `TaskListView`, `DragDropHandler`, `TaskService`, and `FilterService`. +- **Current implementation:** `src/bases/sortOrderUtils.ts`, `manualOrderState.ts`, `taskListDragGeometry.ts`, `taskListDropPlanning.ts`, `TaskListView.ts`, and Kanban drag utilities. Relevant upstream history includes `9fed004f`, `26712c4f`, `1861391e`, `a386be48`, and `dae38e32`. +- **Decision:** **Keep upstream.** Only assess selected-multi-drag as a separately scoped enhancement. +- **Tests already present:** `tests/unit/utils/sortOrderUtils.test.ts`, manual-order state, task-list drop/geometry, Kanban manual-order fast path, and task-list drag controls. Add only missing selected-block scenarios. + +### 5.2 Batch selection and batch context actions + +- **Legacy commits/files:** `61bc3ac9`, `78e4a4b8`, `4fb72fa4`; old task-list/card files. +- **Current implementation:** `TaskSelectionService`, `src/bases/basesSelectionUi.ts`, `src/components/BatchContextMenu.ts`, and Bases task-list/kanban integration. Upstream history includes `c284ed6b`, `39dbe980`, `19b3aca5`, and `5dd09aa9`. +- **Decision:** **Keep upstream; extend for keyboard focus/action targeting.** +- **Tests already present:** Bases selection UI and task selection service tests; add focused-fallback semantics. + +### 5.3 Parent-note project defaults and project quick actions + +- **Legacy commits/files:** `c9a4f5a0`, `5b8b1cbc`, `8294574c`, `7a0e7eb2`. +- **Current implementation:** `taskCreationPrepopulation.ts`, task creation defaults, instant conversion support, project selectors, and quick actions. Upstream commits include `77745914`, `47f99ea2`, `afce4abb`, `d1af0a03`, and `cac4f5e1`. +- **Decision:** **Keep upstream and route Jira/keyboard operations through it.** +- **Tests already present:** Parent-note/default project, conversion, project assignment, project inheritance, and quick-action issue regressions. + +### 5.4 Ctrl/Cmd+Enter task modal save + +- **Legacy commit/files:** `f0ba6049`; `src/modals/TaskEditModal.ts`. +- **Current implementation:** Explicit handlers in current `TaskCreationModal.ts` and `TaskEditModal.ts`. +- **Decision:** **Drop legacy change.** +- **Tests:** Retain/add a small platform-modifier regression test only if not already covered. + +### 5.5 Creation prepopulation and parsed task data + +- **Legacy commit/files:** `bf0e2338`, `63fff798`; old task modal/parser files. +- **Current implementation:** `taskCreationPrepopulation.ts`, `taskCreationData.ts`, `taskCreationFormState.ts`, and `buildTaskCreationDataFromParsed.ts`. +- **Decision:** **Keep upstream.** +- **Tests:** Current task creation, NLP, and API NLP-task-data suites provide the appropriate home. + +### 5.6 Correct property writes and refresh lifecycle + +- **Legacy commit/files:** `0e4bd5a5`, `01a5dc54`, `a9ac1d0b`, `ab16f202`; old `TaskService`, `MinimalNativeCache`, and refresh helpers. +- **Current implementation:** v5 mutation/update services and Bases refresh lifecycle replace the old cache architecture. +- **Decision:** **Keep upstream architecture; port no cache code.** +- **Tests:** Extend current task property/mutation and Bases update-listener tests when new Jira or keyboard batch operations are added. + +### 5.7 Configurable/custom fields as overlap with story points + +- **Legacy commit/files:** story-points series from `1f347258` through `96c80e6e`. +- **Current implementation:** v5 user fields, user-field modal controls, settings cards, filtering/sorting, Bases property mapping, and configurable NLP triggers cover much of the generic requirement. +- **Decision:** **Do not add a core `points` field until a short product spike demonstrates which story-points behaviors user fields cannot provide.** +- **Tests:** Prototype using a numeric user field and document gaps in defaulting, quick assignment, `^N` parsing, display, and sorting. + +--- + +## Proposed porting slices and acceptance gates + +### Slice A: Jira transformation core + +Add typed external adapter, validated settings schema, pure mapping functions, defaults, and unit tests. No settings UI or command until mapping tests cover malformed and partial Jira data. + +### Slice B: Jira import workflow + +Register the translated command, fetch through the adapter, reuse current task creation/default-project/title sanitation, add backlink behavior, and add integration tests. + +### Slice C: Jira mapping settings + +Add preview/raw JSON UI, reset/migration behavior, escaping, and persistence tests. Ensure sample data is never persisted or logged. + +### Slice D: Keyboard focus/navigation + +Implement a view-scoped focus controller with no mutations. Ship arrow navigation, visible focus, rerender restoration, accessibility, and lifecycle tests first. + +### Slice E: Keyboard action routing + +Create semantic actions and the selected-or-focused resolver, then connect current action/modal/batch primitives. Add mutating actions incrementally, with delete last. + +### Slice F: Configurable shortcuts + +Add normalized bindings, settings UI, conflicts, migration, localization, and cross-platform tests after action IDs are stable. + +### Slice G: Remaining deltas + +Evaluate selected-task block dragging and story-points-via-user-field independently. Each requires a separate implementation plan and should not be bundled with Jira or keyboard navigation. + +For every implementation slice, run the repository-required checks: `npm run typecheck`, relevant targeted tests followed by `npm test -- --runInBand`, `npm run lint`, and `npm run build`. From 052485a60ce4b1a5269801d277680c48aad16483 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Mon, 27 Jul 2026 17:23:24 -0600 Subject: [PATCH 02/55] Task list keyboard navigation --- docs/releases/unreleased.md | 2 + src/bases/TaskListFocusController.ts | 158 ++++++++++++++++++ src/bases/TaskListView.ts | 17 ++ styles/task-card-bem.css | 5 + .../bases/TaskListFocusController.test.ts | 137 +++++++++++++++ 5 files changed, 319 insertions(+) create mode 100644 src/bases/TaskListFocusController.ts create mode 100644 tests/unit/bases/TaskListFocusController.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index f4584701d..a07e08c3a 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -34,6 +34,8 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Changed +- Task list cards now retain a distinct keyboard focus across view refreshes and + support Arrow Up/Down and Home/End navigation without changing batch selection. - Generated TaskNotes type contracts now include configured natural-language capture triggers, allowing compatible clients to offer the same field suggestions. diff --git a/src/bases/TaskListFocusController.ts b/src/bases/TaskListFocusController.ts new file mode 100644 index 000000000..8f47b848b --- /dev/null +++ b/src/bases/TaskListFocusController.ts @@ -0,0 +1,158 @@ +export type TaskListFocusIdentity = { + path: string; + occurrence: number; +}; + +const CARD_SELECTOR = ".task-card[data-task-path]"; +const INTERACTIVE_SELECTOR = + 'input, textarea, select, button, a, [contenteditable="true"], [role="textbox"], .cm-content'; + +function getCardIdentity(card: HTMLElement, cards: readonly HTMLElement[]): TaskListFocusIdentity | null { + const path = card.dataset.taskPath; + if (!path) return null; + + let occurrence = 0; + for (const candidate of cards) { + if (candidate === card) break; + if (candidate.dataset.taskPath === path) occurrence++; + } + return { path, occurrence }; +} + +function identitiesEqual( + left: TaskListFocusIdentity | null, + right: TaskListFocusIdentity | null +): boolean { + return left?.path === right?.path && left?.occurrence === right?.occurrence; +} + +export class TaskListFocusController { + private focusedIdentity: TaskListFocusIdentity | null = null; + private restoreDomFocus = false; + + constructor(private readonly root: HTMLElement) {} + + handleFocusIn(event: FocusEvent): void { + const card = this.getCardFromTarget(event.target); + if (!card) return; + + this.focusedIdentity = getCardIdentity(card, this.getCards()); + this.syncRovingTabIndex(); + } + + handlePointerDown(event: PointerEvent): void { + const target = event.target; + if (!(target instanceof Element) || target.closest(INTERACTIVE_SELECTOR)) return; + + const card = this.getCardFromTarget(target); + if (card) this.focusCard(card, false); + } + + handleKeyDown(event: KeyboardEvent): boolean { + if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { + return false; + } + + const target = event.target; + if (!(target instanceof Element) || target.closest(INTERACTIVE_SELECTOR)) return false; + + const cards = this.getCards(); + if (cards.length === 0) return false; + + const activeCard = this.getCardFromTarget(target); + let currentIndex = activeCard ? cards.indexOf(activeCard) : this.findFocusedIndex(cards); + if (currentIndex < 0) currentIndex = 0; + + let nextIndex: number; + switch (event.key) { + case "ArrowDown": + nextIndex = Math.min(currentIndex + 1, cards.length - 1); + break; + case "ArrowUp": + nextIndex = Math.max(currentIndex - 1, 0); + break; + case "Home": + nextIndex = 0; + break; + case "End": + nextIndex = cards.length - 1; + break; + default: + return false; + } + + event.preventDefault(); + event.stopPropagation(); + this.focusCard(cards[nextIndex], true); + return true; + } + + prepareForRender(): void { + const activeElement = this.root.ownerDocument.activeElement; + this.restoreDomFocus = activeElement instanceof Element && this.root.contains(activeElement); + } + + restoreAfterRender(): void { + const cards = this.getCards(); + if (cards.length === 0) return; + + const focusedIndex = this.findFocusedIndex(cards); + const card = cards[focusedIndex >= 0 ? focusedIndex : 0]; + if (focusedIndex < 0) { + this.focusedIdentity = getCardIdentity(card, cards); + } + + this.syncRovingTabIndex(cards); + if (this.restoreDomFocus) { + card.focus({ preventScroll: true }); + card.scrollIntoView({ block: "nearest" }); + } + this.restoreDomFocus = false; + } + + clear(): void { + this.focusedIdentity = null; + this.restoreDomFocus = false; + } + + getFocusedIdentity(): TaskListFocusIdentity | null { + return this.focusedIdentity ? { ...this.focusedIdentity } : null; + } + + private getCards(): HTMLElement[] { + return Array.from(this.root.querySelectorAll(CARD_SELECTOR)); + } + + private getCardFromTarget(target: EventTarget | null): HTMLElement | null { + if (!(target instanceof Element)) return null; + const card = target.closest(CARD_SELECTOR); + return card && this.root.contains(card) ? card : null; + } + + private findFocusedIndex(cards: readonly HTMLElement[]): number { + if (!this.focusedIdentity) return -1; + return cards.findIndex((card) => + identitiesEqual(getCardIdentity(card, cards), this.focusedIdentity) + ); + } + + private focusCard(card: HTMLElement, scroll: boolean): void { + const cards = this.getCards(); + this.focusedIdentity = getCardIdentity(card, cards); + this.syncRovingTabIndex(cards); + card.focus({ preventScroll: true }); + if (scroll) card.scrollIntoView({ block: "nearest" }); + } + + private syncRovingTabIndex(cards: readonly HTMLElement[] = this.getCards()): void { + const focusedIndex = this.findFocusedIndex(cards); + const tabbableIndex = focusedIndex >= 0 ? focusedIndex : 0; + + cards.forEach((card, index) => { + const focused = index === focusedIndex; + + card.tabIndex = index === tabbableIndex ? 0 : -1; + card.classList.toggle("task-card--keyboard-focused", focused); + }); + } +} diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index 5919804fc..dc97218b0 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -64,6 +64,7 @@ import { moveItemsRelativeToTarget, } from "./manualOrderState"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; +import { TaskListFocusController } from "./TaskListFocusController"; const tasknotesLogger = createTaskNotesLogger({ tag: "Bases/TaskListView" }); @@ -139,6 +140,7 @@ export class TaskListView extends BasesViewBase { private clickTimeouts = new Map(); private currentTargetDate = createUTCDateFromLocalCalendarDate(new Date()); private containerListenersRegistered = false; + private focusController: TaskListFocusController | null = null; private virtualScroller: VirtualScroller | null = null; // Can render TaskInfo or group headers private useVirtualScrolling = false; private collapsedGroups = new Set(); // Track collapsed group keys @@ -494,6 +496,7 @@ export class TaskListView extends BasesViewBase { itemsContainer.classList.add("tn-static-margin-top-12px-91e0f558"); this.rootElement?.appendChild(itemsContainer); this.itemsContainer = itemsContainer; + this.focusController = new TaskListFocusController(itemsContainer); this.registerContainerListeners(); this.setupContainerDragHandlers(); } @@ -508,6 +511,7 @@ export class TaskListView extends BasesViewBase { this.pendingRender = true; return; } + this.focusController?.prepareForRender(); // Always re-read view options to catch config changes such as // switching expanded relationship filtering modes in Bases. @@ -579,6 +583,8 @@ export class TaskListView extends BasesViewBase { this.sortScopeTaskPaths.clear(); this.sortScopeCandidateTaskPaths.clear(); this.renderError(error instanceof Error ? error : new Error(String(error))); + } finally { + this.focusController?.restoreAfterRender(); } } @@ -2119,6 +2125,8 @@ export class TaskListView extends BasesViewBase { // We just need to clean up view-specific state this.unregisterContainerListeners(); this.destroyVirtualScroller(); + this.focusController?.clear(); + this.focusController = null; this.currentTaskElements.clear(); this.itemsContainer = null; @@ -2262,6 +2270,15 @@ export class TaskListView extends BasesViewBase { // Register click listener for group header collapse/expand using Component API // This automatically cleans up on component unload this.registerDomEvent(this.itemsContainer, "click", this.handleItemClick); + this.registerDomEvent(this.itemsContainer, "focusin", (event: FocusEvent) => { + this.focusController?.handleFocusIn(event); + }); + this.registerDomEvent(this.itemsContainer, "pointerdown", (event: PointerEvent) => { + this.focusController?.handlePointerDown(event); + }); + this.registerDomEvent(this.itemsContainer, "keydown", (event: KeyboardEvent) => { + this.focusController?.handleKeyDown(event); + }); this.containerListenersRegistered = true; } diff --git a/styles/task-card-bem.css b/styles/task-card-bem.css index 070290fb6..ac609a8ce 100644 --- a/styles/task-card-bem.css +++ b/styles/task-card-bem.css @@ -1970,6 +1970,11 @@ body.is-mobile .tasknotes-plugin .task-card--layout-inline .task-card__context-m /* Selected task card styling */ +.tasknotes-plugin .task-card--keyboard-focused { + outline: 2px solid var(--interactive-accent); + outline-offset: 2px; +} + .tasknotes-plugin .task-card--selected { background-color: color-mix(in srgb, var(--interactive-accent) 15%, transparent); border-radius: var(--tn-radius-sm); diff --git a/tests/unit/bases/TaskListFocusController.test.ts b/tests/unit/bases/TaskListFocusController.test.ts new file mode 100644 index 000000000..29ba680e4 --- /dev/null +++ b/tests/unit/bases/TaskListFocusController.test.ts @@ -0,0 +1,137 @@ +import { TaskListFocusController } from "../../../src/bases/TaskListFocusController"; + +function createCard(path: string): HTMLElement { + const card = document.createElement("div"); + card.className = "task-card"; + card.dataset.taskPath = path; + return card; +} + +function dispatchKey(target: HTMLElement, key: string): KeyboardEvent { + const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }); + target.dispatchEvent(event); + return event; +} + +describe("TaskListFocusController", () => { + let root: HTMLElement; + let controller: TaskListFocusController; + + beforeEach(() => { + root = document.createElement("div"); + document.body.appendChild(root); + controller = new TaskListFocusController(root); + root.addEventListener("focusin", (event) => controller.handleFocusIn(event)); + root.addEventListener("keydown", (event) => controller.handleKeyDown(event)); + HTMLElement.prototype.scrollIntoView = jest.fn(); + }); + + afterEach(() => { + document.body.innerHTML = ""; + jest.restoreAllMocks(); + }); + + it("uses a roving tabindex and moves focus with arrows and boundaries", () => { + const cards = [createCard("a.md"), createCard("b.md"), createCard("c.md")]; + root.append(...cards); + controller.restoreAfterRender(); + + expect(cards.map((card) => card.tabIndex)).toEqual([0, -1, -1]); + + cards[0].focus(); + expect(dispatchKey(cards[0], "ArrowDown").defaultPrevented).toBe(true); + expect(document.activeElement).toBe(cards[1]); + expect(dispatchKey(cards[1], "End").defaultPrevented).toBe(true); + expect(document.activeElement).toBe(cards[2]); + expect(dispatchKey(cards[2], "ArrowDown").defaultPrevented).toBe(true); + expect(document.activeElement).toBe(cards[2]); + expect(dispatchKey(cards[2], "Home").defaultPrevented).toBe(true); + expect(document.activeElement).toBe(cards[0]); + }); + + it("tracks repeated task paths by rendered occurrence", () => { + const first = createCard("same.md"); + const middle = createCard("other.md"); + const second = createCard("same.md"); + root.append(first, middle, second); + controller.restoreAfterRender(); + + second.focus(); + expect(controller.getFocusedIdentity()).toEqual({ path: "same.md", occurrence: 1 }); + + expect(dispatchKey(second, "ArrowUp").defaultPrevented).toBe(true); + expect(document.activeElement).toBe(middle); + }); + + it("navigates across rendered group boundaries and skips collapsed group contents", () => { + const firstGroup = document.createElement("section"); + const secondGroup = document.createElement("section"); + const first = createCard("first.md"); + const collapsed = createCard("collapsed.md"); + const last = createCard("last.md"); + firstGroup.append(first); + secondGroup.append(last); + root.append(firstGroup, secondGroup); + controller.restoreAfterRender(); + + first.focus(); + expect(dispatchKey(first, "ArrowDown").defaultPrevented).toBe(true); + expect(document.activeElement).toBe(last); + + firstGroup.appendChild(collapsed); + controller.restoreAfterRender(); + last.focus(); + expect(dispatchKey(last, "ArrowUp").defaultPrevented).toBe(true); + expect(document.activeElement).toBe(collapsed); + }); + + it("restores the focused occurrence after cards are rerendered", () => { + const first = createCard("same.md"); + const second = createCard("same.md"); + root.append(first, second); + controller.restoreAfterRender(); + second.focus(); + controller.prepareForRender(); + + const replacements = [createCard("same.md"), createCard("same.md")]; + root.replaceChildren(...replacements); + controller.restoreAfterRender(); + + expect(document.activeElement).toBe(replacements[1]); + expect(replacements[1].classList.contains("task-card--keyboard-focused")).toBe(true); + expect(replacements.map((card) => card.tabIndex)).toEqual([-1, 0]); + }); + + it("falls back predictably when the focused task disappears", () => { + const first = createCard("first.md"); + const removed = createCard("removed.md"); + root.append(first, removed); + controller.restoreAfterRender(); + removed.focus(); + controller.prepareForRender(); + + const replacement = createCard("replacement.md"); + root.replaceChildren(replacement); + controller.restoreAfterRender(); + + expect(document.activeElement).toBe(replacement); + expect(controller.getFocusedIdentity()).toEqual({ + path: "replacement.md", + occurrence: 0, + }); + }); + + it("does not capture keys from interactive card content", () => { + const card = createCard("a.md"); + const button = document.createElement("button"); + card.appendChild(button); + root.appendChild(card); + controller.restoreAfterRender(); + button.focus(); + + const event = dispatchKey(button, "ArrowDown"); + + expect(event.defaultPrevented).toBe(false); + expect(document.activeElement).toBe(button); + }); +}); From 57c1658b314d21ed5d5fce152303a362f99536fd Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Mon, 27 Jul 2026 18:04:01 -0600 Subject: [PATCH 03/55] Keyboard nav 2.2 multi-selection with spacebar --- docs/releases/unreleased.md | 5 +- src/bases/TaskListFocusController.ts | 14 ++++- src/bases/TaskListView.ts | 27 ++++++++- src/bases/taskListTargetResolver.ts | 23 ++++++++ .../bases/TaskListFocusController.test.ts | 15 +++++ .../TaskListView.keyboardSelection.test.ts | 55 +++++++++++++++++++ .../unit/bases/taskListTargetResolver.test.ts | 32 +++++++++++ 7 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 src/bases/taskListTargetResolver.ts create mode 100644 tests/unit/bases/TaskListView.keyboardSelection.test.ts create mode 100644 tests/unit/bases/taskListTargetResolver.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index a07e08c3a..65f5670eb 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -34,8 +34,9 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Changed -- Task list cards now retain a distinct keyboard focus across view refreshes and - support Arrow Up/Down and Home/End navigation without changing batch selection. +- Task list cards now retain a distinct keyboard focus across view refreshes, + support Arrow Up/Down and Home/End navigation, and toggle the focused task's + existing batch-selection state with Space. - Generated TaskNotes type contracts now include configured natural-language capture triggers, allowing compatible clients to offer the same field suggestions. diff --git a/src/bases/TaskListFocusController.ts b/src/bases/TaskListFocusController.ts index 8f47b848b..87e9f5ff1 100644 --- a/src/bases/TaskListFocusController.ts +++ b/src/bases/TaskListFocusController.ts @@ -119,6 +119,18 @@ export class TaskListFocusController { return this.focusedIdentity ? { ...this.focusedIdentity } : null; } + getFocusedPathForEvent(event: KeyboardEvent): string | null { + if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { + return null; + } + + const target = event.target; + if (!(target instanceof Element) || target.closest(INTERACTIVE_SELECTOR)) return null; + + const card = this.getCardFromTarget(target); + return card?.dataset.taskPath ?? null; + } + private getCards(): HTMLElement[] { return Array.from(this.root.querySelectorAll(CARD_SELECTOR)); } @@ -150,7 +162,7 @@ export class TaskListFocusController { cards.forEach((card, index) => { const focused = index === focusedIndex; - + card.tabIndex = index === tabbableIndex ? 0 : -1; card.classList.toggle("task-card--keyboard-focused", focused); }); diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index dc97218b0..647c7254d 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -65,6 +65,7 @@ import { } from "./manualOrderState"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; import { TaskListFocusController } from "./TaskListFocusController"; +import { resolveTaskListTargetPaths } from "./taskListTargetResolver"; const tasknotesLogger = createTaskNotesLogger({ tag: "Bases/TaskListView" }); @@ -2277,11 +2278,35 @@ export class TaskListView extends BasesViewBase { this.focusController?.handlePointerDown(event); }); this.registerDomEvent(this.itemsContainer, "keydown", (event: KeyboardEvent) => { - this.focusController?.handleKeyDown(event); + if (this.focusController?.handleKeyDown(event)) return; + this.handleTaskListSelectionKeyDown(event); }); this.containerListenersRegistered = true; } + private handleTaskListSelectionKeyDown(event: KeyboardEvent): void { + if (event.key !== " " && event.key !== "Spacebar") return; + + const taskPath = this.focusController?.getFocusedPathForEvent(event); + const selectionService = this.plugin.taskSelectionService; + if (!taskPath || !selectionService) return; + + event.preventDefault(); + event.stopPropagation(); + selectionService.toggleSelection(taskPath); + } + + /** + * Resolve action targets using upstream selection state first, then keyboard focus. + * Task actions added in later keyboard-navigation slices should use this method. + */ + getTaskActionTargetPaths(): string[] { + return resolveTaskListTargetPaths( + this.plugin.taskSelectionService, + this.focusController?.getFocusedIdentity()?.path + ); + } + private unregisterContainerListeners(): void { // No manual cleanup needed - Component.registerDomEvent handles it automatically this.containerListenersRegistered = false; diff --git a/src/bases/taskListTargetResolver.ts b/src/bases/taskListTargetResolver.ts new file mode 100644 index 000000000..86b39107e --- /dev/null +++ b/src/bases/taskListTargetResolver.ts @@ -0,0 +1,23 @@ +export type TaskListSelectionTargetState = { + getSelectedPaths(): string[]; +}; + +/** + * Resolve the paths a task-list action should target. + * Existing selection always takes precedence over keyboard focus. + */ +export function resolveTaskListTargetPaths( + selectionState: TaskListSelectionTargetState | null | undefined, + focusedPath: string | null | undefined +): string[] { + const selectedPaths = selectionState?.getSelectedPaths() ?? []; + const uniqueSelectedPaths = Array.from( + new Set(selectedPaths.filter((path) => path.length > 0)) + ); + + if (uniqueSelectedPaths.length > 0) { + return uniqueSelectedPaths; + } + + return focusedPath ? [focusedPath] : []; +} diff --git a/tests/unit/bases/TaskListFocusController.test.ts b/tests/unit/bases/TaskListFocusController.test.ts index 29ba680e4..5b96a8378 100644 --- a/tests/unit/bases/TaskListFocusController.test.ts +++ b/tests/unit/bases/TaskListFocusController.test.ts @@ -134,4 +134,19 @@ describe("TaskListFocusController", () => { expect(event.defaultPrevented).toBe(false); expect(document.activeElement).toBe(button); }); + + it("exposes the focused task for an unmodified Space key", () => { + const card = createCard("focused.md"); + root.appendChild(card); + controller.restoreAfterRender(); + card.focus(); + const event = new KeyboardEvent("keydown", { + key: " ", + bubbles: true, + cancelable: true, + }); + Object.defineProperty(event, "target", { value: card }); + + expect(controller.getFocusedPathForEvent(event)).toBe("focused.md"); + }); }); diff --git a/tests/unit/bases/TaskListView.keyboardSelection.test.ts b/tests/unit/bases/TaskListView.keyboardSelection.test.ts new file mode 100644 index 000000000..4ae4bbfdf --- /dev/null +++ b/tests/unit/bases/TaskListView.keyboardSelection.test.ts @@ -0,0 +1,55 @@ +import { TaskListView } from "../../../src/bases/TaskListView"; + +jest.mock( + "tasknotes-nlp-core", + () => ({ + NaturalLanguageParserCore: class {}, + }), + { virtual: true } +); + +describe("TaskListView keyboard selection", () => { + it("toggles the focused task through the existing selection service", () => { + const toggleSelection = jest.fn(); + const view = { + focusController: { + getFocusedPathForEvent: jest.fn(() => "focused.md"), + }, + plugin: { + taskSelectionService: { toggleSelection }, + }, + }; + const event = new KeyboardEvent("keydown", { + key: " ", + cancelable: true, + }); + const stopPropagation = jest.spyOn(event, "stopPropagation"); + + (TaskListView.prototype as any).handleTaskListSelectionKeyDown.call(view, event); + + expect(event.defaultPrevented).toBe(true); + expect(stopPropagation).toHaveBeenCalled(); + expect(toggleSelection).toHaveBeenCalledWith("focused.md"); + }); + + it("leaves Space alone when focus is in an excluded control", () => { + const toggleSelection = jest.fn(); + const view = { + focusController: { + getFocusedPathForEvent: jest.fn(() => null), + }, + plugin: { + taskSelectionService: { toggleSelection }, + }, + }; + const event = new KeyboardEvent("keydown", { + key: " ", + cancelable: true, + }); + + (TaskListView.prototype as any).handleTaskListSelectionKeyDown.call(view, event); + + expect(event.defaultPrevented).toBe(false); + expect(toggleSelection).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/bases/taskListTargetResolver.test.ts b/tests/unit/bases/taskListTargetResolver.test.ts new file mode 100644 index 000000000..51ef60e31 --- /dev/null +++ b/tests/unit/bases/taskListTargetResolver.test.ts @@ -0,0 +1,32 @@ +import { resolveTaskListTargetPaths } from "../../../src/bases/taskListTargetResolver"; + +describe("resolveTaskListTargetPaths", () => { + it("prefers selected paths over the focused task", () => { + const selectionState = { + getSelectedPaths: () => ["selected-a.md", "selected-b.md"], + }; + + expect(resolveTaskListTargetPaths(selectionState, "focused.md")).toEqual([ + "selected-a.md", + "selected-b.md", + ]); + }); + + it("falls back to the focused path when selection is empty", () => { + const selectionState = { getSelectedPaths: () => [] }; + + expect(resolveTaskListTargetPaths(selectionState, "focused.md")).toEqual(["focused.md"]); + }); + + it("returns no targets when neither selection nor focus exists", () => { + expect(resolveTaskListTargetPaths(undefined, null)).toEqual([]); + }); + + it("deduplicates selected paths while preserving selection order", () => { + const selectionState = { + getSelectedPaths: () => ["a.md", "b.md", "a.md", ""], + }; + + expect(resolveTaskListTargetPaths(selectionState, "focused.md")).toEqual(["a.md", "b.md"]); + }); +}); From d9a1fc01fa6b4ef7d5b764d126fdaa761aabf760 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Mon, 27 Jul 2026 18:54:27 -0600 Subject: [PATCH 04/55] Add keyboard shortcuts for task editing commands --- docs/releases/unreleased.md | 4 +- src/bases/TaskListFocusController.ts | 6 + src/bases/TaskListView.ts | 270 +++++++++++++++++- src/bases/components/SearchBox.ts | 7 + src/bases/taskListKeyboardActions.ts | 65 +++++ src/components/RecurrenceContextMenu.ts | 7 + .../TaskListView.keyboardActions.test.ts | 166 +++++++++++ .../bases/taskListKeyboardActions.test.ts | 47 +++ 8 files changed, 567 insertions(+), 5 deletions(-) create mode 100644 src/bases/taskListKeyboardActions.ts create mode 100644 tests/unit/bases/TaskListView.keyboardActions.test.ts create mode 100644 tests/unit/bases/taskListKeyboardActions.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 65f5670eb..adc8f09d7 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -36,7 +36,9 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l - Task list cards now retain a distinct keyboard focus across view refreshes, support Arrow Up/Down and Home/End navigation, and toggle the focused task's - existing batch-selection state with Space. + existing batch-selection state with Space. Focused or selected tasks can now + be created, opened, edited, organized, rescheduled, reprioritized, updated, + or deleted with task-list keyboard actions. - Generated TaskNotes type contracts now include configured natural-language capture triggers, allowing compatible clients to offer the same field suggestions. diff --git a/src/bases/TaskListFocusController.ts b/src/bases/TaskListFocusController.ts index 87e9f5ff1..943ceb999 100644 --- a/src/bases/TaskListFocusController.ts +++ b/src/bases/TaskListFocusController.ts @@ -119,6 +119,12 @@ export class TaskListFocusController { return this.focusedIdentity ? { ...this.focusedIdentity } : null; } + getFocusedElement(): HTMLElement | null { + const cards = this.getCards(); + const index = this.findFocusedIndex(cards); + return index >= 0 ? cards[index] : null; + } + getFocusedPathForEvent(event: KeyboardEvent): string | null { if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { return null; diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index 647c7254d..49a2e3e3a 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -11,7 +11,11 @@ import { type LinkServices } from "../ui/renderers/linkRenderer"; import { DateContextMenu } from "../components/DateContextMenu"; import { PriorityContextMenu } from "../components/PriorityContextMenu"; import { RecurrenceContextMenu } from "../components/RecurrenceContextMenu"; +import { StatusContextMenu } from "../components/StatusContextMenu"; import { showConfirmationModal } from "../modals/ConfirmationModal"; +import { showTextInputModal } from "../modals/TextInputModal"; +import { ProjectSelectModal } from "../modals/ProjectSelectModal"; +import { TagSuggest } from "../modals/taskModalSuggests"; import { ReminderModal } from "../modals/ReminderModal"; import { getDatePart, @@ -66,6 +70,13 @@ import { import { createTaskNotesLogger } from "../utils/tasknotesLogger"; import { TaskListFocusController } from "./TaskListFocusController"; import { resolveTaskListTargetPaths } from "./taskListTargetResolver"; +import { + resolveDefaultTaskListKeyboardAction, + type TaskListKeyboardAction, +} from "./taskListKeyboardActions"; +import { addTagsToList, parseTaskTagInput } from "../utils/taskTagList"; +import { addContextToList } from "../components/TaskContextMenu"; +import { addTaskToProject } from "../services/taskRelationshipActions"; const tasknotesLogger = createTaskNotesLogger({ tag: "Bases/TaskListView" }); @@ -2279,21 +2290,32 @@ export class TaskListView extends BasesViewBase { }); this.registerDomEvent(this.itemsContainer, "keydown", (event: KeyboardEvent) => { if (this.focusController?.handleKeyDown(event)) return; - this.handleTaskListSelectionKeyDown(event); + if (this.handleTaskListSelectionKeyDown(event)) return; + this.handleTaskListActionKeyDown(event); }); this.containerListenersRegistered = true; } - private handleTaskListSelectionKeyDown(event: KeyboardEvent): void { - if (event.key !== " " && event.key !== "Spacebar") return; + private handleTaskListSelectionKeyDown(event: KeyboardEvent): boolean { + if (event.key !== " " && event.key !== "Spacebar") return false; const taskPath = this.focusController?.getFocusedPathForEvent(event); const selectionService = this.plugin.taskSelectionService; - if (!taskPath || !selectionService) return; + if (!taskPath || !selectionService) return false; event.preventDefault(); event.stopPropagation(); selectionService.toggleSelection(taskPath); + return true; + } + + private handleTaskListActionKeyDown(event: KeyboardEvent): void { + const action = resolveDefaultTaskListKeyboardAction(event); + if (!action || !this.focusController?.getFocusedPathForEvent(event)) return; + + event.preventDefault(); + event.stopPropagation(); + void this.executeTaskListAction(action); } /** @@ -2307,6 +2329,246 @@ export class TaskListView extends BasesViewBase { ); } + private async executeTaskListAction(action: TaskListKeyboardAction): Promise { + switch (action) { + case "create-task": + await this.createFileForView(); + return; + case "focus-search": + this.searchBox?.focus(); + return; + case "edit-task": { + const task = (await this.getTaskActionTargets())[0]; + if (task) await this.plugin.openTaskEditModal(task); + return; + } + case "open-task-notes": + await this.openTaskActionTargets(); + return; + case "edit-due": + await this.showTaskActionDateMenu("due"); + return; + case "edit-scheduled": + await this.showTaskActionDateMenu("scheduled"); + return; + case "edit-priority": + await this.showTaskActionPriorityMenu(); + return; + case "edit-status": + await this.showTaskActionStatusMenu(); + return; + case "edit-recurrence": + await this.showTaskActionRecurrenceMenu(); + return; + case "add-tags": + await this.addTagsToTaskActionTargets(); + return; + case "add-context": + await this.addContextToTaskActionTargets(); + return; + case "add-project": + this.addProjectToTaskActionTargets(); + return; + case "delete-tasks": + await this.deleteTaskActionTargets(); + } + } + + private async getTaskActionTargets(): Promise { + const tasks: TaskInfo[] = []; + for (const path of this.getTaskActionTargetPaths()) { + const task = await this.plugin.cacheManager.getTaskInfo(path); + if (task) tasks.push(task); + } + return tasks; + } + + private getTaskActionAnchor(): HTMLElement | null { + return this.focusController?.getFocusedElement() ?? this.itemsContainer; + } + + private async updateTaskActionTargets( + tasks: readonly TaskInfo[], + property: keyof TaskInfo, + value: unknown + ): Promise { + for (const task of tasks) { + await this.plugin.updateTaskProperty(task, property, value); + } + } + + private async openTaskActionTargets(): Promise { + const app = this.app || this.plugin.app; + for (const task of await this.getTaskActionTargets()) { + const file = app.vault.getAbstractFileByPath(task.path); + if (file instanceof TFile) { + await app.workspace.getLeaf("tab").openFile(file); + } + } + } + + private async showTaskActionDateMenu(dateType: "due" | "scheduled"): Promise { + const tasks = await this.getTaskActionTargets(); + const anchor = this.getTaskActionAnchor(); + if (tasks.length === 0 || !anchor) return; + + const currentValue = dateType === "due" ? tasks[0].due : tasks[0].scheduled; + const menu = new DateContextMenu({ + currentValue: getDatePart(currentValue || ""), + currentTime: getTimePart(currentValue || ""), + onSelect: (dateValue, timeValue) => { + const value = dateValue + ? timeValue + ? `${dateValue}T${timeValue}` + : dateValue + : undefined; + void this.updateTaskActionTargets(tasks, dateType, value); + }, + dateRole: dateType, + plugin: this.plugin, + app: this.app || this.plugin.app, + }); + menu.showAtElement(anchor); + } + + private async showTaskActionPriorityMenu(): Promise { + const tasks = await this.getTaskActionTargets(); + const anchor = this.getTaskActionAnchor(); + if (tasks.length === 0 || !anchor) return; + + new PriorityContextMenu({ + currentValue: tasks[0].priority, + onSelect: (value) => void this.updateTaskActionTargets(tasks, "priority", value), + plugin: this.plugin, + }).showAtElement(anchor); + } + + private async showTaskActionStatusMenu(): Promise { + const tasks = await this.getTaskActionTargets(); + const anchor = this.getTaskActionAnchor(); + if (tasks.length === 0 || !anchor) return; + + new StatusContextMenu({ + currentValue: tasks[0].status, + onSelect: (value) => void this.updateTaskActionTargets(tasks, "status", value), + plugin: this.plugin, + }).showAtElement(anchor); + } + + private async showTaskActionRecurrenceMenu(): Promise { + const tasks = await this.getTaskActionTargets(); + const anchor = this.getTaskActionAnchor(); + if (tasks.length === 0 || !anchor) return; + + new RecurrenceContextMenu({ + currentValue: + typeof tasks[0].recurrence === "string" ? tasks[0].recurrence : undefined, + currentAnchor: tasks[0].recurrence_anchor || "scheduled", + scheduledDate: tasks[0].scheduled, + onSelect: (value, recurrenceAnchor) => { + void (async () => { + await this.updateTaskActionTargets( + tasks, + "recurrence", + value || undefined + ); + if (recurrenceAnchor !== undefined) { + await this.updateTaskActionTargets( + tasks, + "recurrence_anchor", + recurrenceAnchor + ); + } + })(); + }, + app: this.plugin.app, + plugin: this.plugin, + }).showAtElement(anchor); + } + + private async addTagsToTaskActionTargets(): Promise { + const tasks = await this.getTaskActionTargets(); + if (tasks.length === 0) return; + + const input = await showTextInputModal(this.plugin.app, { + title: this.plugin.i18n.translate("contextMenus.task.addTag"), + placeholder: this.plugin.i18n.translate("contextMenus.task.tagPlaceholder"), + confirmText: this.plugin.i18n.translate("common.confirm"), + cancelText: this.plugin.i18n.translate("common.cancel"), + onInputReady: (inputEl) => { + new TagSuggest(this.plugin.app, inputEl, this.plugin); + }, + }); + const tags = parseTaskTagInput(input); + if (tags.length === 0) return; + + for (const task of tasks) { + await this.plugin.updateTaskProperty(task, "tags", addTagsToList(task.tags, tags)); + } + } + + private async addContextToTaskActionTargets(): Promise { + const tasks = await this.getTaskActionTargets(); + if (tasks.length === 0) return; + + const context = await showTextInputModal(this.plugin.app, { + title: this.plugin.i18n.translate( + "contextMenus.task.organization.addContext" + ), + placeholder: this.plugin.i18n.translate( + "contextMenus.task.organization.contextPlaceholder" + ), + confirmText: this.plugin.i18n.translate("common.confirm"), + cancelText: this.plugin.i18n.translate("common.cancel"), + }); + if (!context?.trim()) return; + + for (const task of tasks) { + await this.plugin.updateTaskProperty( + task, + "contexts", + addContextToList(task.contexts, context) + ); + } + } + + private addProjectToTaskActionTargets(): void { + const paths = this.getTaskActionTargetPaths(); + if (paths.length === 0) return; + + new ProjectSelectModal(this.plugin.app, this.plugin, (projectFile) => { + if (!(projectFile instanceof TFile)) return; + void (async () => { + for (const path of paths) { + const task = await this.plugin.cacheManager.getTaskInfo(path); + if (task) await addTaskToProject(this.plugin, task, projectFile); + } + })(); + }).open(); + } + + private async deleteTaskActionTargets(): Promise { + const tasks = await this.getTaskActionTargets(); + if (tasks.length === 0) return; + + const confirmed = await showConfirmationModal(this.plugin.app, { + title: tasks.length === 1 ? "Delete task" : "Delete tasks", + message: + tasks.length === 1 + ? `Are you sure you want to delete "${tasks[0].title}"? This action cannot be undone.` + : `Are you sure you want to delete ${tasks.length} tasks? This action cannot be undone.`, + confirmText: "Delete", + cancelText: this.plugin.i18n.translate("common.cancel"), + isDestructive: true, + }); + if (!confirmed) return; + + for (const task of tasks) { + await this.plugin.taskService.deleteTask(task); + } + this.plugin.taskSelectionService?.clearSelection(); + } + private unregisterContainerListeners(): void { // No manual cleanup needed - Component.registerDomEvent handles it automatically this.containerListenersRegistered = false; diff --git a/src/bases/components/SearchBox.ts b/src/bases/components/SearchBox.ts index 45ab5cca8..549991e91 100644 --- a/src/bases/components/SearchBox.ts +++ b/src/bases/components/SearchBox.ts @@ -175,6 +175,13 @@ export class SearchBox { return this.inputEl?.value || ''; } + /** + * Move keyboard focus into the search input. + */ + focus(): void { + this.inputEl?.focus(); + } + /** * Set input value programmatically */ diff --git a/src/bases/taskListKeyboardActions.ts b/src/bases/taskListKeyboardActions.ts new file mode 100644 index 000000000..d947342c8 --- /dev/null +++ b/src/bases/taskListKeyboardActions.ts @@ -0,0 +1,65 @@ +export type TaskListKeyboardAction = + | "create-task" + | "focus-search" + | "edit-task" + | "open-task-notes" + | "edit-due" + | "edit-scheduled" + | "edit-priority" + | "edit-status" + | "edit-recurrence" + | "add-tags" + | "add-context" + | "add-project" + | "delete-tasks"; + +export function resolveDefaultTaskListKeyboardAction( + event: Pick< + KeyboardEvent, + "key" | "ctrlKey" | "metaKey" | "altKey" | "shiftKey" | "isComposing" + > +): TaskListKeyboardAction | null { + if (event.isComposing || event.key === "Process" || event.altKey) return null; + + const commandModifier = event.ctrlKey || event.metaKey; + if (commandModifier) { + return event.key === "Delete" ? "delete-tasks" : null; + } + + if (event.shiftKey) { + switch (event.key) { + case "Enter": + return "open-task-notes"; + case "S": + case "s": + return "edit-scheduled"; + case "#": + return "add-tags"; + case "@": + return "add-context"; + case "+": + return "add-project"; + default: + return null; + } + } + + switch (event.key.toLowerCase()) { + case "c": + return "create-task"; + case "/": + return "focus-search"; + case "enter": + return "edit-task"; + case "d": + return "edit-due"; + case "p": + return "edit-priority"; + case "s": + return "edit-status"; + case "r": + return "edit-recurrence"; + default: + return null; + } +} diff --git a/src/components/RecurrenceContextMenu.ts b/src/components/RecurrenceContextMenu.ts index 4b1dccce8..c388b0b54 100644 --- a/src/components/RecurrenceContextMenu.ts +++ b/src/components/RecurrenceContextMenu.ts @@ -578,6 +578,13 @@ export class RecurrenceContextMenu { public show(event: UIEvent): void { this.menu.show(event); } + + public showAtElement(element: HTMLElement): void { + this.menu.showAtPosition({ + x: element.getBoundingClientRect().left, + y: element.getBoundingClientRect().bottom + 4, + }); + } } class CustomRecurrenceModal extends Modal { diff --git a/tests/unit/bases/TaskListView.keyboardActions.test.ts b/tests/unit/bases/TaskListView.keyboardActions.test.ts new file mode 100644 index 000000000..579d4efe0 --- /dev/null +++ b/tests/unit/bases/TaskListView.keyboardActions.test.ts @@ -0,0 +1,166 @@ +import { showConfirmationModal } from "../../../src/modals/ConfirmationModal"; +import { TaskListView } from "../../../src/bases/TaskListView"; +import type { TaskInfo } from "../../../src/types"; + +jest.mock( + "tasknotes-nlp-core", + () => ({ + NaturalLanguageParserCore: class {}, + }), + { virtual: true } +); +jest.mock("../../../src/modals/ConfirmationModal", () => ({ + showConfirmationModal: jest.fn(), +})); + +const mockedConfirmation = showConfirmationModal as jest.MockedFunction< + typeof showConfirmationModal +>; + +function task(path: string): TaskInfo { + return { + path, + title: path, + status: "open", + priority: "normal", + archived: false, + } as TaskInfo; +} + +describe("TaskListView keyboard actions", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + ["open-task-notes", "openTaskActionTargets", null], + ["edit-due", "showTaskActionDateMenu", "due"], + ["edit-scheduled", "showTaskActionDateMenu", "scheduled"], + ["edit-priority", "showTaskActionPriorityMenu", null], + ["edit-status", "showTaskActionStatusMenu", null], + ["edit-recurrence", "showTaskActionRecurrenceMenu", null], + ["add-tags", "addTagsToTaskActionTargets", null], + ["add-context", "addContextToTaskActionTargets", null], + ["add-project", "addProjectToTaskActionTargets", null], + ["delete-tasks", "deleteTaskActionTargets", null], + ] as const)("routes %s to its semantic handler", async (action, method, argument) => { + const handler = jest.fn(); + const view = { [method]: handler }; + + await (TaskListView.prototype as any).executeTaskListAction.call(view, action); + + expect(handler).toHaveBeenCalledWith(...(argument ? [argument] : [])); + }); + + it("claims a recognized shortcut when the task card owns focus", () => { + const executeTaskListAction = jest.fn(); + const view = { + focusController: { + getFocusedPathForEvent: jest.fn(() => "focused.md"), + }, + executeTaskListAction, + }; + const event = new KeyboardEvent("keydown", { + key: "d", + cancelable: true, + }); + const stopPropagation = jest.spyOn(event, "stopPropagation"); + + (TaskListView.prototype as any).handleTaskListActionKeyDown.call(view, event); + + expect(event.defaultPrevented).toBe(true); + expect(stopPropagation).toHaveBeenCalled(); + expect(executeTaskListAction).toHaveBeenCalledWith("edit-due"); + }); + + it("does not claim shortcuts when an interactive control owns focus", () => { + const executeTaskListAction = jest.fn(); + const view = { + focusController: { + getFocusedPathForEvent: jest.fn(() => null), + }, + executeTaskListAction, + }; + const event = new KeyboardEvent("keydown", { + key: "d", + cancelable: true, + }); + + (TaskListView.prototype as any).handleTaskListActionKeyDown.call(view, event); + + expect(event.defaultPrevented).toBe(false); + expect(executeTaskListAction).not.toHaveBeenCalled(); + }); + + it("uses the first resolved target for the single-task edit modal", async () => { + const first = task("first.md"); + const second = task("second.md"); + const openTaskEditModal = jest.fn(); + const view = { + getTaskActionTargets: jest.fn(async () => [first, second]), + plugin: { openTaskEditModal }, + }; + + await (TaskListView.prototype as any).executeTaskListAction.call(view, "edit-task"); + + expect(openTaskEditModal).toHaveBeenCalledWith(first); + }); + + it("updates every resolved target through the current property API", async () => { + const tasks = [task("first.md"), task("second.md")]; + const updateTaskProperty = jest.fn(async () => undefined); + const view = { plugin: { updateTaskProperty } }; + + await (TaskListView.prototype as any).updateTaskActionTargets.call( + view, + tasks, + "priority", + "high" + ); + + expect(updateTaskProperty).toHaveBeenNthCalledWith(1, tasks[0], "priority", "high"); + expect(updateTaskProperty).toHaveBeenNthCalledWith(2, tasks[1], "priority", "high"); + }); + + it("does not delete when destructive confirmation is cancelled", async () => { + const tasks = [task("first.md"), task("second.md")]; + const deleteTask = jest.fn(); + mockedConfirmation.mockResolvedValue(false); + const view = { + getTaskActionTargets: jest.fn(async () => tasks), + plugin: { + app: {}, + i18n: { translate: jest.fn(() => "Cancel") }, + taskService: { deleteTask }, + taskSelectionService: { clearSelection: jest.fn() }, + }, + }; + + await (TaskListView.prototype as any).deleteTaskActionTargets.call(view); + + expect(mockedConfirmation).toHaveBeenCalled(); + expect(deleteTask).not.toHaveBeenCalled(); + }); + + it("deletes every target after one confirmation and clears selection", async () => { + const tasks = [task("first.md"), task("second.md")]; + const deleteTask = jest.fn(async () => undefined); + const clearSelection = jest.fn(); + mockedConfirmation.mockResolvedValue(true); + const view = { + getTaskActionTargets: jest.fn(async () => tasks), + plugin: { + app: {}, + i18n: { translate: jest.fn(() => "Cancel") }, + taskService: { deleteTask }, + taskSelectionService: { clearSelection }, + }, + }; + + await (TaskListView.prototype as any).deleteTaskActionTargets.call(view); + + expect(mockedConfirmation).toHaveBeenCalledTimes(1); + expect(deleteTask).toHaveBeenCalledTimes(2); + expect(clearSelection).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/bases/taskListKeyboardActions.test.ts b/tests/unit/bases/taskListKeyboardActions.test.ts new file mode 100644 index 000000000..cb3e3b06b --- /dev/null +++ b/tests/unit/bases/taskListKeyboardActions.test.ts @@ -0,0 +1,47 @@ +import { resolveDefaultTaskListKeyboardAction } from "../../../src/bases/taskListKeyboardActions"; + +function key( + value: string, + modifiers: Partial> = {} +) { + return { + key: value, + ctrlKey: false, + metaKey: false, + altKey: false, + shiftKey: false, + isComposing: false, + ...modifiers, + }; +} + +describe("resolveDefaultTaskListKeyboardAction", () => { + it.each([ + ["c", {}, "create-task"], + ["/", {}, "focus-search"], + ["Enter", {}, "edit-task"], + ["Enter", { shiftKey: true }, "open-task-notes"], + ["d", {}, "edit-due"], + ["s", { shiftKey: true }, "edit-scheduled"], + ["p", {}, "edit-priority"], + ["s", {}, "edit-status"], + ["r", {}, "edit-recurrence"], + ["#", { shiftKey: true }, "add-tags"], + ["@", { shiftKey: true }, "add-context"], + ["+", { shiftKey: true }, "add-project"], + ["Delete", { ctrlKey: true }, "delete-tasks"], + ["Delete", { metaKey: true }, "delete-tasks"], + ] as const)("maps %s to %s", (keyValue, modifiers, expected) => { + expect(resolveDefaultTaskListKeyboardAction(key(keyValue, modifiers))).toBe(expected); + }); + + it.each([ + key("d", { altKey: true }), + key("d", { ctrlKey: true }), + key("Process"), + key("d", { isComposing: true }), + key("x"), + ])("ignores unsupported or composition input", (event) => { + expect(resolveDefaultTaskListKeyboardAction(event)).toBeNull(); + }); +}); From 2a7a71589eef7e38f9d7851011da26f045f2f9de Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Tue, 28 Jul 2026 10:03:55 -0600 Subject: [PATCH 05/55] Fix shift key chords --- src/bases/TaskListFocusController.ts | 8 +++- src/bases/TaskListView.ts | 2 +- .../bases/TaskListFocusController.test.ts | 38 +++++++++++++++++++ .../TaskListView.keyboardActions.test.ts | 28 ++++++++++++++ 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/bases/TaskListFocusController.ts b/src/bases/TaskListFocusController.ts index 943ceb999..f684ac7fd 100644 --- a/src/bases/TaskListFocusController.ts +++ b/src/bases/TaskListFocusController.ts @@ -125,8 +125,12 @@ export class TaskListFocusController { return index >= 0 ? cards[index] : null; } - getFocusedPathForEvent(event: KeyboardEvent): string | null { - if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { + getFocusedPathForEvent(event: KeyboardEvent, allowModifiers = false): string | null { + if ( + event.defaultPrevented || + (!allowModifiers && + (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)) + ) { return null; } diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index 49a2e3e3a..96b5a48b8 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -2311,7 +2311,7 @@ export class TaskListView extends BasesViewBase { private handleTaskListActionKeyDown(event: KeyboardEvent): void { const action = resolveDefaultTaskListKeyboardAction(event); - if (!action || !this.focusController?.getFocusedPathForEvent(event)) return; + if (!action || !this.focusController?.getFocusedPathForEvent(event, true)) return; event.preventDefault(); event.stopPropagation(); diff --git a/tests/unit/bases/TaskListFocusController.test.ts b/tests/unit/bases/TaskListFocusController.test.ts index 5b96a8378..044ca2367 100644 --- a/tests/unit/bases/TaskListFocusController.test.ts +++ b/tests/unit/bases/TaskListFocusController.test.ts @@ -49,6 +49,27 @@ describe("TaskListFocusController", () => { expect(document.activeElement).toBe(cards[0]); }); + it("handles navigation when a Bases capture listener already prevented the default", () => { + const cards = [createCard("a.md"), createCard("b.md")]; + root.append(...cards); + controller.restoreAfterRender(); + cards[0].focus(); + const event = new KeyboardEvent("keydown", { + key: "ArrowDown", + bubbles: true, + cancelable: true, + }); + root.addEventListener("keydown", (capturedEvent) => capturedEvent.preventDefault(), { + capture: true, + once: true, + }); + + cards[0].dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(cards[1]); + }); + it("tracks repeated task paths by rendered occurrence", () => { const first = createCard("same.md"); const middle = createCard("other.md"); @@ -149,4 +170,21 @@ describe("TaskListFocusController", () => { expect(controller.getFocusedPathForEvent(event)).toBe("focused.md"); }); + + it("allows a shifted action chord when modifiers were already validated", () => { + const card = createCard("focused.md"); + root.appendChild(card); + controller.restoreAfterRender(); + card.focus(); + const event = new KeyboardEvent("keydown", { + key: "Enter", + shiftKey: true, + bubbles: true, + cancelable: true, + }); + Object.defineProperty(event, "target", { value: card }); + + expect(controller.getFocusedPathForEvent(event)).toBeNull(); + expect(controller.getFocusedPathForEvent(event, true)).toBe("focused.md"); + }); }); diff --git a/tests/unit/bases/TaskListView.keyboardActions.test.ts b/tests/unit/bases/TaskListView.keyboardActions.test.ts index 579d4efe0..27feaa00b 100644 --- a/tests/unit/bases/TaskListView.keyboardActions.test.ts +++ b/tests/unit/bases/TaskListView.keyboardActions.test.ts @@ -73,6 +73,34 @@ describe("TaskListView keyboard actions", () => { expect(executeTaskListAction).toHaveBeenCalledWith("edit-due"); }); + it.each([ + ["Enter", { shiftKey: true }, "open-task-notes"], + ["s", { shiftKey: true }, "edit-scheduled"], + ["#", { shiftKey: true }, "add-tags"], + ["@", { shiftKey: true }, "add-context"], + ["+", { shiftKey: true }, "add-project"], + ["Delete", { ctrlKey: true }, "delete-tasks"], + ["Delete", { metaKey: true }, "delete-tasks"], + ] as const)("allows the modifier chord %s after action recognition", (keyValue, modifiers, action) => { + const executeTaskListAction = jest.fn(); + const getFocusedPathForEvent = jest.fn(() => "focused.md"); + const view = { + focusController: { getFocusedPathForEvent }, + executeTaskListAction, + }; + const event = new KeyboardEvent("keydown", { + key: keyValue, + cancelable: true, + ...modifiers, + }); + + (TaskListView.prototype as any).handleTaskListActionKeyDown.call(view, event); + + expect(getFocusedPathForEvent).toHaveBeenCalledWith(event, true); + expect(event.defaultPrevented).toBe(true); + expect(executeTaskListAction).toHaveBeenCalledWith(action); + }); + it("does not claim shortcuts when an interactive control owns focus", () => { const executeTaskListAction = jest.fn(); const view = { From b52a994bc924f67faf22b907e66d4cc8479e2cd5 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Tue, 28 Jul 2026 10:23:55 -0600 Subject: [PATCH 06/55] Track modal focus so that escape doesn't discard context - Task-list shortcuts pause while an Obsidian menu or modal is open. - Menu/modal Escape and Backspace events remain owned by the overlay. - Focus returns to the originating task after an overlay closes. - Focus is not stolen if the user intentionally moves to another control. - Escape clears task selection without moving focus to the Bases root. - Escape dismisses search; Backspace dismisses an already-empty search and returns to the focused task. - IME composition and editable controls suppress task-list shortcuts. - All listeners and pending restoration timers are cleaned up with the view. --- docs/releases/unreleased.md | 3 +- src/bases/BasesViewBase.ts | 3 + src/bases/TaskListFocusController.ts | 11 +- src/bases/TaskListInputOwnershipController.ts | 106 ++++++++++++++++++ src/bases/TaskListView.ts | 51 ++++++++- src/bases/basesSearchUi.ts | 4 +- src/bases/components/SearchBox.ts | 11 +- tests/unit/SearchBox.test.ts | 18 +++ .../TaskListInputOwnershipController.test.ts | 104 +++++++++++++++++ .../TaskListView.keyboardSelection.test.ts | 21 ++++ 10 files changed, 324 insertions(+), 8 deletions(-) create mode 100644 src/bases/TaskListInputOwnershipController.ts create mode 100644 tests/unit/bases/TaskListInputOwnershipController.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index adc8f09d7..26e79e908 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -38,7 +38,8 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l support Arrow Up/Down and Home/End navigation, and toggle the focused task's existing batch-selection state with Space. Focused or selected tasks can now be created, opened, edited, organized, rescheduled, reprioritized, updated, - or deleted with task-list keyboard actions. + or deleted with task-list keyboard actions. Menus and modals temporarily own + their keyboard input and return focus to the originating task after closing. - Generated TaskNotes type contracts now include configured natural-language capture triggers, allowing compatible clients to offer the same field suggestions. diff --git a/src/bases/BasesViewBase.ts b/src/bases/BasesViewBase.ts index 037e91880..b92c8c8de 100644 --- a/src/bases/BasesViewBase.ts +++ b/src/bases/BasesViewBase.ts @@ -614,6 +614,7 @@ export abstract class BasesViewBase extends Component { visibleProperties, currentSearchTerm: this.currentSearchTerm, onSearch: (term) => this.handleSearch(term), + onDismiss: () => this.handleSearchDismissed(), }); this.searchFilter = searchControls.searchFilter; this.searchBox = searchControls.searchBox; @@ -623,6 +624,8 @@ export abstract class BasesViewBase extends Component { this.register(() => this.teardownSearch()); } + protected handleSearchDismissed(): void {} + /** * Remove the search UI and reset search state. * Called when enableSearch is toggled off. diff --git a/src/bases/TaskListFocusController.ts b/src/bases/TaskListFocusController.ts index f684ac7fd..9e082e91d 100644 --- a/src/bases/TaskListFocusController.ts +++ b/src/bases/TaskListFocusController.ts @@ -49,7 +49,7 @@ export class TaskListFocusController { } handleKeyDown(event: KeyboardEvent): boolean { - if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { return false; } @@ -125,6 +125,15 @@ export class TaskListFocusController { return index >= 0 ? cards[index] : null; } + restoreFocusedElement(): boolean { + const card = this.getFocusedElement(); + if (!card) return false; + + card.focus({ preventScroll: true }); + card.scrollIntoView({ block: "nearest" }); + return true; + } + getFocusedPathForEvent(event: KeyboardEvent, allowModifiers = false): string | null { if ( event.defaultPrevented || diff --git a/src/bases/TaskListInputOwnershipController.ts b/src/bases/TaskListInputOwnershipController.ts new file mode 100644 index 000000000..fea4bcb77 --- /dev/null +++ b/src/bases/TaskListInputOwnershipController.ts @@ -0,0 +1,106 @@ +import type { TaskListFocusController } from "./TaskListFocusController"; + +const OVERLAY_SELECTOR = ".menu, .modal-container:not(.modals-hidden)"; +const EDITABLE_SELECTOR = + 'input, textarea, select, [contenteditable="true"], [role="textbox"], .cm-content'; + +export class TaskListInputOwnershipController { + private suspendedForOverlay = false; + private restoreTimer: number | null = null; + private restoreAttempts = 0; + + constructor( + private readonly viewRoot: HTMLElement, + private readonly focusController: TaskListFocusController + ) {} + + canHandleListKeyDown(event: KeyboardEvent): boolean { + if (event.isComposing || event.key === "Process") return false; + + const target = event.target; + if (!(target instanceof Element) || target.closest(EDITABLE_SELECTOR)) return false; + if (this.getOverlayFromTarget(target) || this.hasOpenOverlay()) return false; + + return this.viewRoot.contains(target); + } + + noteOverlayOpening(): void { + const activeElement = this.viewRoot.ownerDocument.activeElement; + if (activeElement instanceof Element && this.viewRoot.contains(activeElement)) { + this.suspendedForOverlay = true; + } + } + + handleDocumentFocusIn(event: FocusEvent): void { + const target = event.target; + if (!(target instanceof Element)) return; + + if (this.getOverlayFromTarget(target)) { + this.suspendedForOverlay = true; + return; + } + + if (this.suspendedForOverlay) { + // Focus moved intentionally somewhere outside the closing overlay. + this.suspendedForOverlay = false; + } + } + + handleOverlayInteraction(event: Event): void { + const target = event.target; + if (!(target instanceof Element) || !this.getOverlayFromTarget(target)) return; + + this.suspendedForOverlay = true; + this.scheduleRestoreAfterOverlayClose(); + } + + scheduleRestoreAfterOverlayClose(): void { + if (!this.suspendedForOverlay || this.restoreTimer !== null) return; + + const win = this.viewRoot.ownerDocument.defaultView ?? window; + this.restoreAttempts = 0; + const check = () => { + this.restoreTimer = null; + if (!this.suspendedForOverlay || !this.viewRoot.isConnected) return; + + if (this.hasOpenOverlay() && this.restoreAttempts < 20) { + this.restoreAttempts++; + this.restoreTimer = win.setTimeout(check, 16); + return; + } + + if (this.hasOpenOverlay()) return; + + const activeElement = this.viewRoot.ownerDocument.activeElement; + const body = this.viewRoot.ownerDocument.body; + if ( + !activeElement || + activeElement === body || + !activeElement.isConnected || + activeElement === this.viewRoot + ) { + this.focusController.restoreFocusedElement(); + } + this.suspendedForOverlay = false; + }; + + this.restoreTimer = win.setTimeout(check, 0); + } + + destroy(): void { + if (this.restoreTimer !== null) { + const win = this.viewRoot.ownerDocument.defaultView ?? window; + win.clearTimeout(this.restoreTimer); + this.restoreTimer = null; + } + this.suspendedForOverlay = false; + } + + private hasOpenOverlay(): boolean { + return this.viewRoot.ownerDocument.querySelector(OVERLAY_SELECTOR) !== null; + } + + private getOverlayFromTarget(target: Element): Element | null { + return target.closest(OVERLAY_SELECTOR); + } +} diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index 96b5a48b8..70ad44900 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -77,6 +77,7 @@ import { import { addTagsToList, parseTaskTagInput } from "../utils/taskTagList"; import { addContextToList } from "../components/TaskContextMenu"; import { addTaskToProject } from "../services/taskRelationshipActions"; +import { TaskListInputOwnershipController } from "./TaskListInputOwnershipController"; const tasknotesLogger = createTaskNotesLogger({ tag: "Bases/TaskListView" }); @@ -153,6 +154,7 @@ export class TaskListView extends BasesViewBase { private currentTargetDate = createUTCDateFromLocalCalendarDate(new Date()); private containerListenersRegistered = false; private focusController: TaskListFocusController | null = null; + private inputOwnershipController: TaskListInputOwnershipController | null = null; private virtualScroller: VirtualScroller | null = null; // Can render TaskInfo or group headers private useVirtualScrolling = false; private collapsedGroups = new Set(); // Track collapsed group keys @@ -456,10 +458,12 @@ export class TaskListView extends BasesViewBase { protected setupContainer(): void { super.setupContainer(); + const rootElement = this.rootElement; + if (!rootElement) return; // Make rootElement fill its container and establish flex context - if (this.rootElement) { - this.rootElement.classList.remove( + { + rootElement.classList.remove( "tn-static-display-block-2a1b75c9", "tn-static-display-flex-75816cae", "tn-static-display-flex-8bb39979", @@ -475,7 +479,7 @@ export class TaskListView extends BasesViewBase { "tn-static-height-24px-29a11d37", "tn-static-min-height-800px-997b4c8c" ); - this.rootElement.classList.add("tn-static-display-flex-4d51fc62"); + rootElement.classList.add("tn-static-display-flex-4d51fc62"); } // Use correct document for pop-out window support @@ -506,9 +510,13 @@ export class TaskListView extends BasesViewBase { "tn-static-position-relative-d461c96d" ); itemsContainer.classList.add("tn-static-margin-top-12px-91e0f558"); - this.rootElement?.appendChild(itemsContainer); + rootElement.appendChild(itemsContainer); this.itemsContainer = itemsContainer; this.focusController = new TaskListFocusController(itemsContainer); + this.inputOwnershipController = new TaskListInputOwnershipController( + rootElement, + this.focusController + ); this.registerContainerListeners(); this.setupContainerDragHandlers(); } @@ -2137,6 +2145,8 @@ export class TaskListView extends BasesViewBase { // We just need to clean up view-specific state this.unregisterContainerListeners(); this.destroyVirtualScroller(); + this.inputOwnershipController?.destroy(); + this.inputOwnershipController = null; this.focusController?.clear(); this.focusController = null; @@ -2289,13 +2299,45 @@ export class TaskListView extends BasesViewBase { this.focusController?.handlePointerDown(event); }); this.registerDomEvent(this.itemsContainer, "keydown", (event: KeyboardEvent) => { + if (!this.inputOwnershipController?.canHandleListKeyDown(event)) return; + if (this.handleTaskListEscape(event)) return; if (this.focusController?.handleKeyDown(event)) return; if (this.handleTaskListSelectionKeyDown(event)) return; this.handleTaskListActionKeyDown(event); }); + const doc = this.itemsContainer.ownerDocument; + this.registerDomEvent(doc, "focusin", (event: FocusEvent) => { + this.inputOwnershipController?.handleDocumentFocusIn(event); + }); + this.registerDomEvent(doc, "pointerdown", (event: PointerEvent) => { + this.inputOwnershipController?.handleOverlayInteraction(event); + }); + this.registerDomEvent( + doc, + "keydown", + (event: KeyboardEvent) => { + if (event.key === "Escape" || event.key === "Backspace") { + this.inputOwnershipController?.handleOverlayInteraction(event); + } + }, + true + ); this.containerListenersRegistered = true; } + private handleTaskListEscape(event: KeyboardEvent): boolean { + if (event.key !== "Escape") return false; + + event.preventDefault(); + event.stopPropagation(); + this.plugin.taskSelectionService?.exitSelectionMode(true); + return true; + } + + protected handleSearchDismissed(): void { + this.focusController?.restoreFocusedElement(); + } + private handleTaskListSelectionKeyDown(event: KeyboardEvent): boolean { if (event.key !== " " && event.key !== "Spacebar") return false; @@ -2315,6 +2357,7 @@ export class TaskListView extends BasesViewBase { event.preventDefault(); event.stopPropagation(); + this.inputOwnershipController?.noteOverlayOpening(); void this.executeTaskListAction(action); } diff --git a/src/bases/basesSearchUi.ts b/src/bases/basesSearchUi.ts index 5741329e3..863c076f9 100644 --- a/src/bases/basesSearchUi.ts +++ b/src/bases/basesSearchUi.ts @@ -12,6 +12,7 @@ export type CreateBasesSearchControlsOptions = { visibleProperties: readonly string[]; currentSearchTerm: string; onSearch: (term: string) => void; + onDismiss?: () => void; debounceMs?: number; }; @@ -20,6 +21,7 @@ export function createBasesSearchControls({ visibleProperties, currentSearchTerm, onSearch, + onDismiss, debounceMs = 300, }: CreateBasesSearchControlsOptions): BasesSearchControls { const doc = container.ownerDocument; @@ -33,7 +35,7 @@ export function createBasesSearchControls({ } const searchFilter = new TaskSearchFilter([...visibleProperties]); - const searchBox = new SearchBox(searchContainer, onSearch, debounceMs); + const searchBox = new SearchBox(searchContainer, onSearch, debounceMs, onDismiss); searchBox.render(); if (currentSearchTerm) { diff --git a/src/bases/components/SearchBox.ts b/src/bases/components/SearchBox.ts index 549991e91..79cbc931a 100644 --- a/src/bases/components/SearchBox.ts +++ b/src/bases/components/SearchBox.ts @@ -11,6 +11,7 @@ export class SearchBox { private container: HTMLElement; private onSearch: (term: string) => void; private debounceMs: number; + private onDismiss?: () => void; private searchBoxEl: HTMLElement | null = null; private inputEl: HTMLInputElement | null = null; @@ -27,11 +28,13 @@ export class SearchBox { constructor( container: HTMLElement, onSearch: (term: string) => void, - debounceMs = 300 + debounceMs = 300, + onDismiss?: () => void ) { this.container = container; this.onSearch = onSearch; this.debounceMs = debounceMs; + this.onDismiss = onDismiss; // Create debounced search handler with destroyed check this.debouncedSearch = debounce( @@ -132,11 +135,16 @@ export class SearchBox { */ private handleKeydown = (e: KeyboardEvent): void => { if (e.key === 'Escape') { + e.preventDefault(); this.clear(); // Trigger search with empty term if (this.debouncedSearch) { this.debouncedSearch(''); } + this.onDismiss?.(); + } else if (e.key === 'Backspace' && this.inputEl?.value.length === 0) { + e.preventDefault(); + this.onDismiss?.(); } }; @@ -224,5 +232,6 @@ export class SearchBox { this.clearBtnEl = null; this.searchBoxEl = null; this.debouncedSearch = null; + this.onDismiss = undefined; } } diff --git a/tests/unit/SearchBox.test.ts b/tests/unit/SearchBox.test.ts index f03afd155..33b6fa53d 100644 --- a/tests/unit/SearchBox.test.ts +++ b/tests/unit/SearchBox.test.ts @@ -204,6 +204,24 @@ describe('SearchBox', () => { expect(clearBtn.classList.contains('is-visible')).toBe(false); }); + it('should dismiss an empty search with Backspace', () => { + const onDismiss = jest.fn(); + searchBox = new SearchBox(container, onSearchMock, 300, onDismiss); + searchBox.render(); + const input = container.querySelector('.tn-search-box__input') as HTMLInputElement; + input.focus(); + const event = new KeyboardEvent('keydown', { + key: 'Backspace', + bubbles: true, + cancelable: true, + }); + + input.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(onDismiss).toHaveBeenCalled(); + }); + it('should clear input when clear button clicked', () => { searchBox = new SearchBox(container, onSearchMock, 300); searchBox.render(); diff --git a/tests/unit/bases/TaskListInputOwnershipController.test.ts b/tests/unit/bases/TaskListInputOwnershipController.test.ts new file mode 100644 index 000000000..34d70115e --- /dev/null +++ b/tests/unit/bases/TaskListInputOwnershipController.test.ts @@ -0,0 +1,104 @@ +import { TaskListFocusController } from "../../../src/bases/TaskListFocusController"; +import { TaskListInputOwnershipController } from "../../../src/bases/TaskListInputOwnershipController"; + +function card(path: string): HTMLElement { + const element = document.createElement("div"); + element.className = "task-card"; + element.dataset.taskPath = path; + return element; +} + +describe("TaskListInputOwnershipController", () => { + let viewRoot: HTMLElement; + let items: HTMLElement; + let taskCard: HTMLElement; + let focusController: TaskListFocusController; + let controller: TaskListInputOwnershipController; + + beforeEach(() => { + jest.useFakeTimers(); + viewRoot = document.createElement("div"); + items = document.createElement("div"); + taskCard = card("focused.md"); + items.appendChild(taskCard); + viewRoot.appendChild(items); + document.body.appendChild(viewRoot); + focusController = new TaskListFocusController(items); + controller = new TaskListInputOwnershipController(viewRoot, focusController); + items.addEventListener("focusin", (event) => focusController.handleFocusIn(event)); + HTMLElement.prototype.scrollIntoView = jest.fn(); + focusController.restoreAfterRender(); + taskCard.focus(); + }); + + afterEach(() => { + controller.destroy(); + jest.useRealTimers(); + jest.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + it("allows list keys from a task card and suppresses editable targets", () => { + const cardEvent = new KeyboardEvent("keydown", { key: "d" }); + Object.defineProperty(cardEvent, "target", { value: taskCard }); + expect(controller.canHandleListKeyDown(cardEvent)).toBe(true); + + const input = document.createElement("input"); + viewRoot.appendChild(input); + const inputEvent = new KeyboardEvent("keydown", { key: "d" }); + Object.defineProperty(inputEvent, "target", { value: input }); + expect(controller.canHandleListKeyDown(inputEvent)).toBe(false); + }); + + it("suppresses list shortcuts while a menu is open", () => { + const menu = document.createElement("div"); + menu.className = "menu"; + document.body.appendChild(menu); + const event = new KeyboardEvent("keydown", { key: "d" }); + Object.defineProperty(event, "target", { value: taskCard }); + + expect(controller.canHandleListKeyDown(event)).toBe(false); + }); + + it("restores the originating task after an overlay closes", () => { + controller.noteOverlayOpening(); + const menu = document.createElement("div"); + menu.className = "menu"; + document.body.appendChild(menu); + const menuItem = document.createElement("div"); + menuItem.tabIndex = 0; + menu.appendChild(menuItem); + menuItem.focus(); + const event = new MouseEvent("pointerdown", { bubbles: true }); + Object.defineProperty(event, "target", { value: menuItem }); + controller.handleOverlayInteraction(event); + + menu.remove(); + document.body.focus(); + jest.runAllTimers(); + + expect(document.activeElement).toBe(taskCard); + }); + + it("does not steal focus when the user moved to another control", () => { + controller.noteOverlayOpening(); + const outsideInput = document.createElement("input"); + document.body.appendChild(outsideInput); + outsideInput.focus(); + + controller.scheduleRestoreAfterOverlayClose(); + jest.runAllTimers(); + + expect(document.activeElement).toBe(outsideInput); + }); + + it("suppresses IME composition", () => { + const event = new KeyboardEvent("keydown", { + key: "Process", + isComposing: true, + }); + Object.defineProperty(event, "target", { value: taskCard }); + + expect(controller.canHandleListKeyDown(event)).toBe(false); + }); +}); diff --git a/tests/unit/bases/TaskListView.keyboardSelection.test.ts b/tests/unit/bases/TaskListView.keyboardSelection.test.ts index 4ae4bbfdf..5be927166 100644 --- a/tests/unit/bases/TaskListView.keyboardSelection.test.ts +++ b/tests/unit/bases/TaskListView.keyboardSelection.test.ts @@ -52,4 +52,25 @@ describe("TaskListView keyboard selection", () => { expect(event.defaultPrevented).toBe(false); expect(toggleSelection).not.toHaveBeenCalled(); }); + + it("clears selection on Escape without handing focus to the Bases root", () => { + const exitSelectionMode = jest.fn(); + const view = { + plugin: { + taskSelectionService: { exitSelectionMode }, + }, + }; + const event = new KeyboardEvent("keydown", { + key: "Escape", + cancelable: true, + }); + const stopPropagation = jest.spyOn(event, "stopPropagation"); + + const handled = (TaskListView.prototype as any).handleTaskListEscape.call(view, event); + + expect(handled).toBe(true); + expect(event.defaultPrevented).toBe(true); + expect(stopPropagation).toHaveBeenCalled(); + expect(exitSelectionMode).toHaveBeenCalledWith(true); + }); }); From c1cfb9441e3e721bf31f8851cd1821e7cf9a368b Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Tue, 28 Jul 2026 18:57:51 -0600 Subject: [PATCH 07/55] Customizable keyboard shortcuts --- src/bases/TaskListView.ts | 7 +- src/bases/taskListKeyboardActions.ts | 219 +++++++++++++----- src/i18n/resources/en.ts | 30 +++ src/settings/TaskNotesSettingTab.ts | 11 + src/settings/defaults.ts | 2 + src/settings/settingsPersistence.ts | 31 ++- src/settings/tabs/keyboardShortcutsTab.ts | 131 +++++++++++ src/types/settings.ts | 3 + .../bases/taskListKeyboardActions.test.ts | 65 +++++- .../unit/settings/settingsPersistence.test.ts | 30 +++ 10 files changed, 471 insertions(+), 58 deletions(-) create mode 100644 src/settings/tabs/keyboardShortcutsTab.ts diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index 70ad44900..fe2f05767 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -71,7 +71,7 @@ import { createTaskNotesLogger } from "../utils/tasknotesLogger"; import { TaskListFocusController } from "./TaskListFocusController"; import { resolveTaskListTargetPaths } from "./taskListTargetResolver"; import { - resolveDefaultTaskListKeyboardAction, + resolveTaskListKeyboardAction, type TaskListKeyboardAction, } from "./taskListKeyboardActions"; import { addTagsToList, parseTaskTagInput } from "../utils/taskTagList"; @@ -2352,7 +2352,10 @@ export class TaskListView extends BasesViewBase { } private handleTaskListActionKeyDown(event: KeyboardEvent): void { - const action = resolveDefaultTaskListKeyboardAction(event); + const action = resolveTaskListKeyboardAction( + event, + this.plugin?.settings?.taskListShortcuts + ); if (!action || !this.focusController?.getFocusedPathForEvent(event, true)) return; event.preventDefault(); diff --git a/src/bases/taskListKeyboardActions.ts b/src/bases/taskListKeyboardActions.ts index d947342c8..4d3c5c612 100644 --- a/src/bases/taskListKeyboardActions.ts +++ b/src/bases/taskListKeyboardActions.ts @@ -1,65 +1,176 @@ -export type TaskListKeyboardAction = - | "create-task" - | "focus-search" - | "edit-task" - | "open-task-notes" - | "edit-due" - | "edit-scheduled" - | "edit-priority" - | "edit-status" - | "edit-recurrence" - | "add-tags" - | "add-context" - | "add-project" - | "delete-tasks"; - -export function resolveDefaultTaskListKeyboardAction( +export const TASK_LIST_KEYBOARD_ACTIONS = [ + "create-task", + "focus-search", + "edit-task", + "open-task-notes", + "edit-due", + "edit-scheduled", + "edit-priority", + "edit-status", + "edit-recurrence", + "add-tags", + "add-context", + "add-project", + "delete-tasks", +] as const; + +export type TaskListKeyboardAction = (typeof TASK_LIST_KEYBOARD_ACTIONS)[number]; +export type TaskListShortcutMap = Record; + +export const DEFAULT_TASK_LIST_SHORTCUTS: TaskListShortcutMap = { + "create-task": ["c"], + "focus-search": ["slash"], + "edit-task": ["enter"], + "open-task-notes": ["shift+enter"], + "edit-due": ["d"], + "edit-scheduled": ["shift+s"], + "edit-priority": ["p"], + "edit-status": ["s"], + "edit-recurrence": ["r"], + "add-tags": ["shift+#"], + "add-context": ["shift+@"], + "add-project": ["shift+plus"], + "delete-tasks": ["mod+delete"], +}; + +const MODIFIER_ALIASES: Record = { + mod: "mod", + ctrl: "mod", + control: "mod", + cmd: "mod", + command: "mod", + meta: "mod", + alt: "alt", + option: "alt", + shift: "shift", +}; + +const KEY_ALIASES: Record = { + "/": "slash", + "+": "plus", + " ": "space", + spacebar: "space", + esc: "escape", + del: "delete", + return: "enter", +}; + +const MODIFIER_ORDER = ["mod", "ctrl", "meta", "alt", "shift"] as const; + +function normalizeKey(rawKey: string): string { + const key = rawKey.trim().toLowerCase(); + return KEY_ALIASES[key] ?? key; +} + +export function normalizeTaskListShortcut(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + + const rawParts = trimmed === "+" ? ["+"] : trimmed.split("+"); + const modifiers = new Set<(typeof MODIFIER_ORDER)[number]>(); + let key = ""; + + for (const rawPart of rawParts) { + const part = rawPart.trim().toLowerCase(); + const modifier = MODIFIER_ALIASES[part]; + if (modifier) { + modifiers.add(modifier); + } else if (part || rawPart === "") { + key = normalizeKey(part || "+"); + } + } + + if (!key || KEY_ALIASES[key] === "") return null; + const orderedModifiers = MODIFIER_ORDER.filter((modifier) => modifiers.has(modifier)); + return [...orderedModifiers, key].join("+"); +} + +export function keyboardEventToTaskListShortcut( event: Pick< KeyboardEvent, "key" | "ctrlKey" | "metaKey" | "altKey" | "shiftKey" | "isComposing" > -): TaskListKeyboardAction | null { - if (event.isComposing || event.key === "Process" || event.altKey) return null; - - const commandModifier = event.ctrlKey || event.metaKey; - if (commandModifier) { - return event.key === "Delete" ? "delete-tasks" : null; +): string | null { + if ( + event.isComposing || + event.key === "Process" || + ["Shift", "Control", "Alt", "Meta"].includes(event.key) + ) { + return null; } - if (event.shiftKey) { - switch (event.key) { - case "Enter": - return "open-task-notes"; - case "S": - case "s": - return "edit-scheduled"; - case "#": - return "add-tags"; - case "@": - return "add-context"; - case "+": - return "add-project"; - default: - return null; + const modifiers: string[] = []; + if (event.ctrlKey || event.metaKey) modifiers.push("mod"); + if (event.altKey) modifiers.push("alt"); + if (event.shiftKey) modifiers.push("shift"); + return normalizeTaskListShortcut([...modifiers, normalizeKey(event.key)].join("+")); +} + +export function normalizeTaskListShortcutMap( + shortcuts: Partial> | null | undefined +): TaskListShortcutMap { + return Object.fromEntries( + TASK_LIST_KEYBOARD_ACTIONS.map((action) => { + const configured = shortcuts?.[action]; + const values = Array.isArray(configured) + ? configured + : DEFAULT_TASK_LIST_SHORTCUTS[action]; + const normalized = values + .map(normalizeTaskListShortcut) + .filter((shortcut): shortcut is string => Boolean(shortcut)); + return [action, [...new Set(normalized)]]; + }) + ) as TaskListShortcutMap; +} + +export function findTaskListShortcutConflicts( + shortcuts: TaskListShortcutMap +): Map { + const actionsByShortcut = new Map(); + for (const action of TASK_LIST_KEYBOARD_ACTIONS) { + for (const shortcut of shortcuts[action]) { + const actions = actionsByShortcut.get(shortcut) ?? []; + actions.push(action); + actionsByShortcut.set(shortcut, actions); } } + return new Map([...actionsByShortcut].filter(([, actions]) => actions.length > 1)); +} - switch (event.key.toLowerCase()) { - case "c": - return "create-task"; - case "/": - return "focus-search"; - case "enter": - return "edit-task"; - case "d": - return "edit-due"; - case "p": - return "edit-priority"; - case "s": - return "edit-status"; - case "r": - return "edit-recurrence"; - default: - return null; +export function resolveTaskListKeyboardAction( + event: Pick< + KeyboardEvent, + "key" | "ctrlKey" | "metaKey" | "altKey" | "shiftKey" | "isComposing" + >, + shortcuts: TaskListShortcutMap = DEFAULT_TASK_LIST_SHORTCUTS +): TaskListKeyboardAction | null { + const shortcut = keyboardEventToTaskListShortcut(event); + if (!shortcut) return null; + + for (const action of TASK_LIST_KEYBOARD_ACTIONS) { + if (shortcuts[action].includes(shortcut)) return action; } + return null; +} + +/** Kept as a compatibility alias for tests and callers from the earlier port slices. */ +export const resolveDefaultTaskListKeyboardAction = resolveTaskListKeyboardAction; + +export function formatTaskListShortcut(shortcut: string, isMacOS: boolean): string { + const symbols: Record = isMacOS + ? { mod: "⌘", ctrl: "⌃", meta: "⌘", alt: "⌥", shift: "⇧" } + : { mod: "Ctrl", ctrl: "Ctrl", meta: "Meta", alt: "Alt", shift: "Shift" }; + const keyLabels: Record = { + slash: "/", + plus: "+", + space: "Space", + enter: "Enter", + delete: "Delete", + escape: "Esc", + }; + const separator = isMacOS ? "" : "+"; + return shortcut + .split("+") + .map((part) => symbols[part] ?? keyLabels[part] ?? part.toUpperCase()) + .join(separator); } diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 0926250b6..368f515c3 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -510,8 +510,38 @@ export const en: TranslationTree = { defaults: "Defaults & templates", appearance: "Appearance & UI", features: "Features", + keyboardShortcuts: "Keyboard shortcuts", integrations: "Integrations", }, + keyboardShortcuts: { + header: "Task-list keyboard shortcuts", + description: + "Configure view-local shortcuts. They only run while a task card has keyboard focus.", + actionDescription: "Click a shortcut to remove it, or add another binding.", + add: "Add shortcut", + remove: "Remove shortcut", + captureHint: "Click, then press the desired key combination", + recording: "Press keys…", + resetAction: "Reset this action", + resetAll: "Reset all shortcuts", + resetAllDescription: "Restore every task-list shortcut to its default binding.", + conflict: "Conflict: {shortcuts} is also assigned to another task-list action.", + actions: { + "create-task": "Create task", + "focus-search": "Focus search", + "edit-task": "Edit task", + "open-task-notes": "Open task note", + "edit-due": "Edit due date", + "edit-scheduled": "Edit scheduled date", + "edit-priority": "Edit priority", + "edit-status": "Edit status", + "edit-recurrence": "Edit recurrence", + "add-tags": "Add tags", + "add-context": "Add context", + "add-project": "Add project", + "delete-tasks": "Delete tasks", + }, + }, features: { inlineTasks: { header: "Inline tasks", diff --git a/src/settings/TaskNotesSettingTab.ts b/src/settings/TaskNotesSettingTab.ts index 20cf5c5ac..884b7650a 100644 --- a/src/settings/TaskNotesSettingTab.ts +++ b/src/settings/TaskNotesSettingTab.ts @@ -7,6 +7,7 @@ import { renderModalFieldsTab } from "./tabs/modalFieldsTab"; import { renderAppearanceTab } from "./tabs/appearanceTab"; import { renderFeaturesTab } from "./tabs/featuresTab"; import { renderIntegrationsTab } from "./tabs/integrationsTab"; +import { renderKeyboardShortcutsTab } from "./tabs/keyboardShortcutsTab"; import type { TranslationKey } from "../i18n"; interface TabConfig { @@ -81,6 +82,11 @@ export class TaskNotesSettingTab extends PluginSettingTab { nameKey: "settings.tabs.features", renderFn: renderFeaturesTab, }, + { + id: "keyboard-shortcuts", + nameKey: "settings.tabs.keyboardShortcuts", + renderFn: renderKeyboardShortcutsTab, + }, { id: "integrations", nameKey: "settings.tabs.integrations", @@ -242,6 +248,11 @@ export class TaskNotesSettingTab extends PluginSettingTab { nameKey: "settings.tabs.features", renderFn: renderFeaturesTab, }, + { + id: "keyboard-shortcuts", + nameKey: "settings.tabs.keyboardShortcuts", + renderFn: renderKeyboardShortcutsTab, + }, { id: "integrations", nameKey: "settings.tabs.integrations", diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 106920c5d..710a7c882 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -9,6 +9,7 @@ import { GoogleCalendarExportSettings, } from "../types/settings"; import { DEFAULT_FIELD_MAPPING } from "../core/defaultFieldMapping"; +import { DEFAULT_TASK_LIST_SHORTCUTS } from "../bases/taskListKeyboardActions"; export { DEFAULT_FIELD_MAPPING } from "../core/defaultFieldMapping"; /** @@ -301,6 +302,7 @@ export const DEFAULT_SETTINGS: TaskNotesSettings = { singleClickAction: "edit", doubleClickAction: "openNote", + taskListShortcuts: DEFAULT_TASK_LIST_SHORTCUTS, // Autosuggest project card defaults projectAutosuggest: DEFAULT_PROJECT_AUTOSUGGEST, diff --git a/src/settings/settingsPersistence.ts b/src/settings/settingsPersistence.ts index a04d3c5bf..69be2f673 100644 --- a/src/settings/settingsPersistence.ts +++ b/src/settings/settingsPersistence.ts @@ -4,6 +4,7 @@ import { hasMissingMigratedSettings } from "./settingsMigration"; import type { TaskCreationDefaults, TaskNotesSettings } from "../types/settings"; import { initializeFieldConfig } from "../utils/fieldConfigDefaults"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; +import { normalizeTaskListShortcutMap } from "../bases/taskListKeyboardActions"; const tasknotesLogger = createTaskNotesLogger({ tag: "Settings/SettingsPersistence" }); @@ -11,6 +12,7 @@ export type LoadedSettingsData = Partial & Record & { statusSuggestionTrigger?: string; useNativeMetadataCache?: unknown; + keyboardShortcuts?: Record; }; export type SettingsDataHost = { @@ -109,6 +111,28 @@ function migrateLoadedSettingsData(data: LoadedSettingsData | null): LoadedSetti const migratedData: LoadedSettingsData = { ...data }; + // Migration from the v3 custom branch. Only actions that still exist in the + // current semantic action layer are carried forward. + if (!migratedData.taskListShortcuts && migratedData.keyboardShortcuts) { + const legacy = migratedData.keyboardShortcuts; + migratedData.taskListShortcuts = normalizeTaskListShortcutMap({ + "create-task": legacy.newTask, + "focus-search": legacy.focusFilter, + "edit-task": legacy.openEdit, + "open-task-notes": legacy.openInNewPane, + "edit-due": legacy.editDueDates, + "edit-scheduled": legacy.editScheduleDates, + "edit-priority": legacy.editPriorities, + "edit-status": legacy.editStatuses, + "edit-recurrence": legacy.editRecurrence, + "add-tags": legacy.editTags, + "add-context": legacy.editContexts, + "add-project": legacy.editProjects, + "delete-tasks": legacy.deleteTasks, + }); + delete migratedData.keyboardShortcuts; + } + // Migration: Remove old useNativeMetadataCache setting if it exists. delete migratedData.useNativeMetadataCache; @@ -194,6 +218,9 @@ function buildTaskCreationDefaults( } export function buildSettingsFromLoadedData(data: LoadedSettingsData | null): SettingsBuildResult { + const migratedLegacyKeyboardShortcuts = Boolean( + data?.keyboardShortcuts && !data.taskListShortcuts + ); const loadedData = migrateLoadedSettingsData(data); const migratedLegacyCustomFilenameTemplate = data?.taskFilenameFormat !== "custom" && @@ -218,6 +245,7 @@ export function buildSettingsFromLoadedData(data: LoadedSettingsData | null): Se ...DEFAULT_SETTINGS.commandFileMapping, ...(loadedData?.commandFileMapping || {}), }, + taskListShortcuts: normalizeTaskListShortcutMap(loadedData?.taskListShortcuts), icsIntegration: { ...DEFAULT_SETTINGS.icsIntegration, ...(loadedData?.icsIntegration || {}), @@ -241,7 +269,8 @@ export function buildSettingsFromLoadedData(data: LoadedSettingsData | null): Se shouldPersistMigratedSettings: hasMissingMigratedSettings(loadedData) || migratedLegacyCustomFilenameTemplate || - migratedParentNoteTaskCreationDefault, + migratedParentNoteTaskCreationDefault || + migratedLegacyKeyboardShortcuts, }; } diff --git a/src/settings/tabs/keyboardShortcutsTab.ts b/src/settings/tabs/keyboardShortcutsTab.ts new file mode 100644 index 000000000..6fdf74f49 --- /dev/null +++ b/src/settings/tabs/keyboardShortcutsTab.ts @@ -0,0 +1,131 @@ +import { Platform, Setting } from "obsidian"; +import type TaskNotesPlugin from "../../main"; +import { + DEFAULT_TASK_LIST_SHORTCUTS, + TASK_LIST_KEYBOARD_ACTIONS, + findTaskListShortcutConflicts, + formatTaskListShortcut, + keyboardEventToTaskListShortcut, + type TaskListKeyboardAction, +} from "../../bases/taskListKeyboardActions"; +import { createSettingGroup } from "../components/settingHelpers"; +import type { TranslationKey } from "../../i18n"; + +function actionKey(action: TaskListKeyboardAction): TranslationKey { + return `settings.keyboardShortcuts.actions.${action}`; +} + +export function renderKeyboardShortcutsTab( + container: HTMLElement, + plugin: TaskNotesPlugin, + save: () => void +): void { + container.empty(); + const translate = (key: TranslationKey, params?: Record) => + plugin.i18n.translate(key, params); + const shortcuts = plugin.settings.taskListShortcuts; + const conflicts = findTaskListShortcutConflicts(shortcuts); + + createSettingGroup( + container, + { + heading: translate("settings.keyboardShortcuts.header"), + description: translate("settings.keyboardShortcuts.description"), + }, + (group) => { + for (const action of TASK_LIST_KEYBOARD_ACTIONS) { + group.addSetting((setting) => { + setting.setName(translate(actionKey(action))); + const actionConflicts = shortcuts[action] + .filter((shortcut) => conflicts.has(shortcut)) + .map((shortcut) => formatTaskListShortcut(shortcut, Platform.isMacOS)); + setting.setDesc( + actionConflicts.length + ? translate("settings.keyboardShortcuts.conflict", { + shortcuts: actionConflicts.join(", "), + }) + : translate("settings.keyboardShortcuts.actionDescription") + ); + if (actionConflicts.length) setting.settingEl.addClass("has-conflict"); + + for (const shortcut of shortcuts[action]) { + setting.addButton((button) => { + button + .setButtonText(formatTaskListShortcut(shortcut, Platform.isMacOS)) + .setTooltip(translate("settings.keyboardShortcuts.remove")) + .onClick(() => { + plugin.settings.taskListShortcuts[action] = shortcuts[action].filter( + (value) => value !== shortcut + ); + save(); + renderKeyboardShortcutsTab(container, plugin, save); + }); + }); + } + + setting.addButton((button) => { + button + .setButtonText(translate("settings.keyboardShortcuts.add")) + .setTooltip(translate("settings.keyboardShortcuts.captureHint")) + .onClick(() => { + const buttonEl = button.buttonEl; + buttonEl.setText(translate("settings.keyboardShortcuts.recording")); + buttonEl.addClass("mod-cta"); + const capture = (event: KeyboardEvent) => { + event.preventDefault(); + event.stopPropagation(); + const shortcut = keyboardEventToTaskListShortcut(event); + if (!shortcut) return; + buttonEl.removeEventListener("keydown", capture); + if (!shortcuts[action].includes(shortcut)) { + plugin.settings.taskListShortcuts[action] = [ + ...shortcuts[action], + shortcut, + ]; + save(); + } + renderKeyboardShortcutsTab(container, plugin, save); + }; + buttonEl.addEventListener("keydown", capture); + buttonEl.focus(); + }); + }); + + setting.addExtraButton((button) => { + button + .setIcon("rotate-ccw") + .setTooltip(translate("settings.keyboardShortcuts.resetAction")) + .onClick(() => { + plugin.settings.taskListShortcuts[action] = [ + ...DEFAULT_TASK_LIST_SHORTCUTS[action], + ]; + save(); + renderKeyboardShortcutsTab(container, plugin, save); + }); + }); + }); + } + + group.addSetting((setting: Setting) => { + setting + .setName(translate("settings.keyboardShortcuts.resetAll")) + .setDesc(translate("settings.keyboardShortcuts.resetAllDescription")) + .addButton((button) => + button + .setButtonText(translate("settings.keyboardShortcuts.resetAll")) + .setWarning() + .onClick(() => { + plugin.settings.taskListShortcuts = Object.fromEntries( + TASK_LIST_KEYBOARD_ACTIONS.map((action) => [ + action, + [...DEFAULT_TASK_LIST_SHORTCUTS[action]], + ]) + ) as typeof plugin.settings.taskListShortcuts; + save(); + renderKeyboardShortcutsTab(container, plugin, save); + }) + ); + }); + } + ); +} diff --git a/src/types/settings.ts b/src/types/settings.ts index 960391ce5..4a23932a1 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -1,5 +1,6 @@ import { FieldMapping, StatusConfig, PriorityConfig, SavedView, WebhookConfig } from "../types"; import type { FileFilterConfig } from "../suggest/FileSuggestHelper"; +import type { TaskListShortcutMap } from "../bases/taskListKeyboardActions"; export interface UserFieldMapping { enabled: boolean; @@ -150,6 +151,8 @@ export interface TaskNotesSettings { singleClickAction: "edit" | "openNote"; doubleClickAction: "edit" | "openNote" | "none"; + // View-local task-list keyboard shortcuts + taskListShortcuts: TaskListShortcutMap; // Inline task conversion settings inlineTaskConvertFolder: string; // Folder for inline task conversion, supports {{currentNotePath}} and {{currentNoteTitle}} // Performance settings diff --git a/tests/unit/bases/taskListKeyboardActions.test.ts b/tests/unit/bases/taskListKeyboardActions.test.ts index cb3e3b06b..7b901f9f6 100644 --- a/tests/unit/bases/taskListKeyboardActions.test.ts +++ b/tests/unit/bases/taskListKeyboardActions.test.ts @@ -1,4 +1,12 @@ -import { resolveDefaultTaskListKeyboardAction } from "../../../src/bases/taskListKeyboardActions"; +import { + DEFAULT_TASK_LIST_SHORTCUTS, + findTaskListShortcutConflicts, + formatTaskListShortcut, + normalizeTaskListShortcut, + normalizeTaskListShortcutMap, + resolveDefaultTaskListKeyboardAction, + resolveTaskListKeyboardAction, +} from "../../../src/bases/taskListKeyboardActions"; function key( value: string, @@ -44,4 +52,59 @@ describe("resolveDefaultTaskListKeyboardAction", () => { ])("ignores unsupported or composition input", (event) => { expect(resolveDefaultTaskListKeyboardAction(event)).toBeNull(); }); + + it("resolves a customized map instead of the defaults", () => { + const shortcuts = normalizeTaskListShortcutMap({ + "edit-due": ["Ctrl+Shift+K"], + }); + + expect( + resolveTaskListKeyboardAction( + key("k", { ctrlKey: true, shiftKey: true }), + shortcuts + ) + ).toBe("edit-due"); + expect(resolveTaskListKeyboardAction(key("d"), shortcuts)).toBeNull(); + }); + + it.each([ + [" Control + Shift + K ", "mod+shift+k"], + ["Cmd+Return", "mod+enter"], + ["Option+/", "alt+slash"], + ["Shift++", "shift+plus"], + ])("normalizes %s", (raw, expected) => { + expect(normalizeTaskListShortcut(raw)).toBe(expected); + }); + + it("preserves an explicitly cleared action while defaulting missing actions", () => { + const shortcuts = normalizeTaskListShortcutMap({ "edit-due": [] }); + + expect(shortcuts["edit-due"]).toEqual([]); + expect(shortcuts["create-task"]).toEqual(DEFAULT_TASK_LIST_SHORTCUTS["create-task"]); + }); + + it("falls back safely when persisted shortcut data is malformed", () => { + const shortcuts = normalizeTaskListShortcutMap({ + "edit-due": "not-an-array", + } as unknown as Parameters[0]); + + expect(shortcuts["edit-due"]).toEqual(DEFAULT_TASK_LIST_SHORTCUTS["edit-due"]); + }); + + it("reports duplicate bindings across semantic actions", () => { + const shortcuts = normalizeTaskListShortcutMap({ + "edit-due": ["x"], + "edit-status": ["X"], + }); + + expect(findTaskListShortcutConflicts(shortcuts).get("x")).toEqual([ + "edit-due", + "edit-status", + ]); + }); + + it("formats portable modifiers for the current platform", () => { + expect(formatTaskListShortcut("mod+shift+enter", false)).toBe("Ctrl+Shift+Enter"); + expect(formatTaskListShortcut("mod+shift+enter", true)).toBe("⌘⇧Enter"); + }); }); diff --git a/tests/unit/settings/settingsPersistence.test.ts b/tests/unit/settings/settingsPersistence.test.ts index 0f3aab2f0..e1770f8ad 100644 --- a/tests/unit/settings/settingsPersistence.test.ts +++ b/tests/unit/settings/settingsPersistence.test.ts @@ -167,6 +167,36 @@ describe("settings persistence helpers", () => { expect(shouldPersistMigratedSettings).toBe(false); }); + it("deep-merges saved task-list shortcuts with current defaults", () => { + const { settings } = buildSettingsFromLoadedData({ + taskListShortcuts: { + "edit-due": ["Ctrl+K"], + } as TaskNotesSettings["taskListShortcuts"], + }); + + expect(settings.taskListShortcuts["edit-due"]).toEqual(["mod+k"]); + expect(settings.taskListShortcuts["create-task"]).toEqual(["c"]); + }); + + it("migrates supported legacy keyboard action names", () => { + const { settings, shouldPersistMigratedSettings } = buildSettingsFromLoadedData({ + fieldMapping: DEFAULT_SETTINGS.fieldMapping, + calendarViewSettings: DEFAULT_SETTINGS.calendarViewSettings, + commandFileMapping: DEFAULT_SETTINGS.commandFileMapping, + keyboardShortcuts: { + newTask: ["N"], + editDueDates: ["Control+Shift+D"], + deleteTasks: ["Cmd+Delete"], + toggleArchive: ["a"], + }, + }); + + expect(settings.taskListShortcuts["create-task"]).toEqual(["n"]); + expect(settings.taskListShortcuts["edit-due"]).toEqual(["mod+shift+d"]); + expect(settings.taskListShortcuts["delete-tasks"]).toEqual(["mod+delete"]); + expect(shouldPersistMigratedSettings).toBe(true); + }); + it("merges only known settings keys into saved data while preserving other persisted data", () => { const settings = { ...DEFAULT_SETTINGS, From 95bd84784fb76c3279071d20039ab1419a95fc36 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Wed, 29 Jul 2026 10:06:35 -0600 Subject: [PATCH 08/55] Customizable keyboard shortcuts --- src/bases/TaskListFocusController.ts | 27 ++++++++++++++----- src/bases/TaskListView.ts | 10 +++++++ src/bases/taskListKeyboardActions.ts | 8 ++++++ src/i18n/resources/en.ts | 2 ++ src/settings/settingsPersistence.ts | 2 ++ .../bases/TaskListFocusController.test.ts | 24 ++++++++++++++++- .../TaskListView.keyboardActions.test.ts | 23 ++++++++++++++++ .../bases/taskListKeyboardActions.test.ts | 2 ++ .../unit/settings/settingsPersistence.test.ts | 4 +++ 9 files changed, 95 insertions(+), 7 deletions(-) diff --git a/src/bases/TaskListFocusController.ts b/src/bases/TaskListFocusController.ts index 9e082e91d..b1df808f3 100644 --- a/src/bases/TaskListFocusController.ts +++ b/src/bases/TaskListFocusController.ts @@ -65,12 +65,6 @@ export class TaskListFocusController { let nextIndex: number; switch (event.key) { - case "ArrowDown": - nextIndex = Math.min(currentIndex + 1, cards.length - 1); - break; - case "ArrowUp": - nextIndex = Math.max(currentIndex - 1, 0); - break; case "Home": nextIndex = 0; break; @@ -87,6 +81,27 @@ export class TaskListFocusController { return true; } + moveFocus(event: KeyboardEvent, direction: "next" | "previous"): boolean { + const target = event.target; + if (!(target instanceof Element) || target.closest(INTERACTIVE_SELECTOR)) return false; + + const cards = this.getCards(); + if (cards.length === 0) return false; + + const activeCard = this.getCardFromTarget(target); + let currentIndex = activeCard ? cards.indexOf(activeCard) : this.findFocusedIndex(cards); + if (currentIndex < 0) currentIndex = 0; + const nextIndex = + direction === "next" + ? Math.min(currentIndex + 1, cards.length - 1) + : Math.max(currentIndex - 1, 0); + + event.preventDefault(); + event.stopPropagation(); + this.focusCard(cards[nextIndex], true); + return true; + } + prepareForRender(): void { const activeElement = this.root.ownerDocument.activeElement; this.restoreDomFocus = activeElement instanceof Element && this.root.contains(activeElement); diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index fe2f05767..de4f75c11 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -2357,6 +2357,13 @@ export class TaskListView extends BasesViewBase { this.plugin?.settings?.taskListShortcuts ); if (!action || !this.focusController?.getFocusedPathForEvent(event, true)) return; + if (action === "navigate-next" || action === "navigate-previous") { + this.focusController.moveFocus( + event, + action === "navigate-next" ? "next" : "previous" + ); + return; + } event.preventDefault(); event.stopPropagation(); @@ -2377,6 +2384,9 @@ export class TaskListView extends BasesViewBase { private async executeTaskListAction(action: TaskListKeyboardAction): Promise { switch (action) { + case "navigate-next": + case "navigate-previous": + return; case "create-task": await this.createFileForView(); return; diff --git a/src/bases/taskListKeyboardActions.ts b/src/bases/taskListKeyboardActions.ts index 4d3c5c612..62fbe1282 100644 --- a/src/bases/taskListKeyboardActions.ts +++ b/src/bases/taskListKeyboardActions.ts @@ -1,4 +1,6 @@ export const TASK_LIST_KEYBOARD_ACTIONS = [ + "navigate-next", + "navigate-previous", "create-task", "focus-search", "edit-task", @@ -18,6 +20,8 @@ export type TaskListKeyboardAction = (typeof TASK_LIST_KEYBOARD_ACTIONS)[number] export type TaskListShortcutMap = Record; export const DEFAULT_TASK_LIST_SHORTCUTS: TaskListShortcutMap = { + "navigate-next": ["arrowdown"], + "navigate-previous": ["arrowup"], "create-task": ["c"], "focus-search": ["slash"], "edit-task": ["enter"], @@ -53,6 +57,8 @@ const KEY_ALIASES: Record = { esc: "escape", del: "delete", return: "enter", + "arrow down": "arrowdown", + "arrow up": "arrowup", }; const MODIFIER_ORDER = ["mod", "ctrl", "meta", "alt", "shift"] as const; @@ -167,6 +173,8 @@ export function formatTaskListShortcut(shortcut: string, isMacOS: boolean): stri enter: "Enter", delete: "Delete", escape: "Esc", + arrowdown: "↓", + arrowup: "↑", }; const separator = isMacOS ? "" : "+"; return shortcut diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 368f515c3..2d5fad63c 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -527,6 +527,8 @@ export const en: TranslationTree = { resetAllDescription: "Restore every task-list shortcut to its default binding.", conflict: "Conflict: {shortcuts} is also assigned to another task-list action.", actions: { + "navigate-next": "Focus next task", + "navigate-previous": "Focus previous task", "create-task": "Create task", "focus-search": "Focus search", "edit-task": "Edit task", diff --git a/src/settings/settingsPersistence.ts b/src/settings/settingsPersistence.ts index 69be2f673..7073880ea 100644 --- a/src/settings/settingsPersistence.ts +++ b/src/settings/settingsPersistence.ts @@ -116,6 +116,8 @@ function migrateLoadedSettingsData(data: LoadedSettingsData | null): LoadedSetti if (!migratedData.taskListShortcuts && migratedData.keyboardShortcuts) { const legacy = migratedData.keyboardShortcuts; migratedData.taskListShortcuts = normalizeTaskListShortcutMap({ + "navigate-next": legacy.navigateDown, + "navigate-previous": legacy.navigateUp, "create-task": legacy.newTask, "focus-search": legacy.focusFilter, "edit-task": legacy.openEdit, diff --git a/tests/unit/bases/TaskListFocusController.test.ts b/tests/unit/bases/TaskListFocusController.test.ts index 044ca2367..5a7daa3df 100644 --- a/tests/unit/bases/TaskListFocusController.test.ts +++ b/tests/unit/bases/TaskListFocusController.test.ts @@ -22,7 +22,11 @@ describe("TaskListFocusController", () => { document.body.appendChild(root); controller = new TaskListFocusController(root); root.addEventListener("focusin", (event) => controller.handleFocusIn(event)); - root.addEventListener("keydown", (event) => controller.handleKeyDown(event)); + root.addEventListener("keydown", (event) => { + if (event.key === "ArrowDown") controller.moveFocus(event, "next"); + else if (event.key === "ArrowUp") controller.moveFocus(event, "previous"); + else controller.handleKeyDown(event); + }); HTMLElement.prototype.scrollIntoView = jest.fn(); }); @@ -49,6 +53,24 @@ describe("TaskListFocusController", () => { expect(document.activeElement).toBe(cards[0]); }); + it("moves focus through the same path for configurable navigation keys", () => { + const cards = [createCard("a.md"), createCard("b.md")]; + root.append(...cards); + controller.restoreAfterRender(); + cards[0].focus(); + const event = new KeyboardEvent("keydown", { + key: "j", + bubbles: true, + cancelable: true, + }); + Object.defineProperty(event, "target", { value: cards[0] }); + + controller.moveFocus(event, "next"); + + expect(event.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(cards[1]); + }); + it("handles navigation when a Bases capture listener already prevented the default", () => { const cards = [createCard("a.md"), createCard("b.md")]; root.append(...cards); diff --git a/tests/unit/bases/TaskListView.keyboardActions.test.ts b/tests/unit/bases/TaskListView.keyboardActions.test.ts index 27feaa00b..4182799f0 100644 --- a/tests/unit/bases/TaskListView.keyboardActions.test.ts +++ b/tests/unit/bases/TaskListView.keyboardActions.test.ts @@ -73,6 +73,29 @@ describe("TaskListView keyboard actions", () => { expect(executeTaskListAction).toHaveBeenCalledWith("edit-due"); }); + it("routes configured Gmail-style navigation through the focus controller", () => { + const moveFocus = jest.fn(); + const event = new KeyboardEvent("keydown", { key: "j", cancelable: true }); + const view = { + plugin: { + settings: { + taskListShortcuts: { + "navigate-next": ["j"], + "navigate-previous": ["k"], + }, + }, + }, + focusController: { + getFocusedPathForEvent: jest.fn(() => "focused.md"), + moveFocus, + }, + }; + + (TaskListView.prototype as any).handleTaskListActionKeyDown.call(view, event); + + expect(moveFocus).toHaveBeenCalledWith(event, "next"); + }); + it.each([ ["Enter", { shiftKey: true }, "open-task-notes"], ["s", { shiftKey: true }, "edit-scheduled"], diff --git a/tests/unit/bases/taskListKeyboardActions.test.ts b/tests/unit/bases/taskListKeyboardActions.test.ts index 7b901f9f6..9aa36824b 100644 --- a/tests/unit/bases/taskListKeyboardActions.test.ts +++ b/tests/unit/bases/taskListKeyboardActions.test.ts @@ -25,6 +25,8 @@ function key( describe("resolveDefaultTaskListKeyboardAction", () => { it.each([ + ["ArrowDown", {}, "navigate-next"], + ["ArrowUp", {}, "navigate-previous"], ["c", {}, "create-task"], ["/", {}, "focus-search"], ["Enter", {}, "edit-task"], diff --git a/tests/unit/settings/settingsPersistence.test.ts b/tests/unit/settings/settingsPersistence.test.ts index e1770f8ad..58ef4758f 100644 --- a/tests/unit/settings/settingsPersistence.test.ts +++ b/tests/unit/settings/settingsPersistence.test.ts @@ -184,6 +184,8 @@ describe("settings persistence helpers", () => { calendarViewSettings: DEFAULT_SETTINGS.calendarViewSettings, commandFileMapping: DEFAULT_SETTINGS.commandFileMapping, keyboardShortcuts: { + navigateDown: ["J", "Arrow Down"], + navigateUp: ["K", "Arrow Up"], newTask: ["N"], editDueDates: ["Control+Shift+D"], deleteTasks: ["Cmd+Delete"], @@ -192,6 +194,8 @@ describe("settings persistence helpers", () => { }); expect(settings.taskListShortcuts["create-task"]).toEqual(["n"]); + expect(settings.taskListShortcuts["navigate-next"]).toEqual(["j", "arrowdown"]); + expect(settings.taskListShortcuts["navigate-previous"]).toEqual(["k", "arrowup"]); expect(settings.taskListShortcuts["edit-due"]).toEqual(["mod+shift+d"]); expect(settings.taskListShortcuts["delete-tasks"]).toEqual(["mod+delete"]); expect(shouldPersistMigratedSettings).toBe(true); From 944997a117f63d4b6521e0f70f9acc8bc2e56013 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Wed, 29 Jul 2026 10:16:42 -0600 Subject: [PATCH 09/55] Check hotkey dupes, new hotkey for jumping to first/last task --- src/bases/TaskListFocusController.ts | 45 +++++----------- src/bases/TaskListView.ts | 13 +++-- src/bases/taskListKeyboardActions.ts | 33 +++++++++++- src/i18n/resources/en.ts | 6 +++ src/settings/tabs/keyboardShortcutsTab.ts | 52 ++++++++++++++++--- .../bases/TaskListFocusController.test.ts | 19 ++++++- .../TaskListView.keyboardActions.test.ts | 25 +++++++++ .../bases/taskListKeyboardActions.test.ts | 21 ++++++++ 8 files changed, 171 insertions(+), 43 deletions(-) diff --git a/src/bases/TaskListFocusController.ts b/src/bases/TaskListFocusController.ts index b1df808f3..62fd46aa2 100644 --- a/src/bases/TaskListFocusController.ts +++ b/src/bases/TaskListFocusController.ts @@ -48,11 +48,10 @@ export class TaskListFocusController { if (card) this.focusCard(card, false); } - handleKeyDown(event: KeyboardEvent): boolean { - if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { - return false; - } - + moveFocus( + event: KeyboardEvent, + direction: "next" | "previous" | "first" | "last" + ): boolean { const target = event.target; if (!(target instanceof Element) || target.closest(INTERACTIVE_SELECTOR)) return false; @@ -62,17 +61,20 @@ export class TaskListFocusController { const activeCard = this.getCardFromTarget(target); let currentIndex = activeCard ? cards.indexOf(activeCard) : this.findFocusedIndex(cards); if (currentIndex < 0) currentIndex = 0; - let nextIndex: number; - switch (event.key) { - case "Home": + switch (direction) { + case "next": + nextIndex = Math.min(currentIndex + 1, cards.length - 1); + break; + case "previous": + nextIndex = Math.max(currentIndex - 1, 0); + break; + case "first": nextIndex = 0; break; - case "End": + case "last": nextIndex = cards.length - 1; break; - default: - return false; } event.preventDefault(); @@ -81,27 +83,6 @@ export class TaskListFocusController { return true; } - moveFocus(event: KeyboardEvent, direction: "next" | "previous"): boolean { - const target = event.target; - if (!(target instanceof Element) || target.closest(INTERACTIVE_SELECTOR)) return false; - - const cards = this.getCards(); - if (cards.length === 0) return false; - - const activeCard = this.getCardFromTarget(target); - let currentIndex = activeCard ? cards.indexOf(activeCard) : this.findFocusedIndex(cards); - if (currentIndex < 0) currentIndex = 0; - const nextIndex = - direction === "next" - ? Math.min(currentIndex + 1, cards.length - 1) - : Math.max(currentIndex - 1, 0); - - event.preventDefault(); - event.stopPropagation(); - this.focusCard(cards[nextIndex], true); - return true; - } - prepareForRender(): void { const activeElement = this.root.ownerDocument.activeElement; this.restoreDomFocus = activeElement instanceof Element && this.root.contains(activeElement); diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index de4f75c11..55f04b41b 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -2301,7 +2301,6 @@ export class TaskListView extends BasesViewBase { this.registerDomEvent(this.itemsContainer, "keydown", (event: KeyboardEvent) => { if (!this.inputOwnershipController?.canHandleListKeyDown(event)) return; if (this.handleTaskListEscape(event)) return; - if (this.focusController?.handleKeyDown(event)) return; if (this.handleTaskListSelectionKeyDown(event)) return; this.handleTaskListActionKeyDown(event); }); @@ -2357,10 +2356,16 @@ export class TaskListView extends BasesViewBase { this.plugin?.settings?.taskListShortcuts ); if (!action || !this.focusController?.getFocusedPathForEvent(event, true)) return; - if (action === "navigate-next" || action === "navigate-previous") { + const navigationDirections = { + "navigate-next": "next", + "navigate-previous": "previous", + "jump-first": "first", + "jump-last": "last", + } as const; + if (action in navigationDirections) { this.focusController.moveFocus( event, - action === "navigate-next" ? "next" : "previous" + navigationDirections[action as keyof typeof navigationDirections] ); return; } @@ -2386,6 +2391,8 @@ export class TaskListView extends BasesViewBase { switch (action) { case "navigate-next": case "navigate-previous": + case "jump-first": + case "jump-last": return; case "create-task": await this.createFileForView(); diff --git a/src/bases/taskListKeyboardActions.ts b/src/bases/taskListKeyboardActions.ts index 62fbe1282..b2c2ecd3a 100644 --- a/src/bases/taskListKeyboardActions.ts +++ b/src/bases/taskListKeyboardActions.ts @@ -1,6 +1,8 @@ export const TASK_LIST_KEYBOARD_ACTIONS = [ "navigate-next", "navigate-previous", + "jump-first", + "jump-last", "create-task", "focus-search", "edit-task", @@ -22,6 +24,8 @@ export type TaskListShortcutMap = Record; export const DEFAULT_TASK_LIST_SHORTCUTS: TaskListShortcutMap = { "navigate-next": ["arrowdown"], "navigate-previous": ["arrowup"], + "jump-first": ["home"], + "jump-last": ["end"], "create-task": ["c"], "focus-search": ["slash"], "edit-task": ["enter"], @@ -143,6 +147,31 @@ export function findTaskListShortcutConflicts( return new Map([...actionsByShortcut].filter(([, actions]) => actions.length > 1)); } +export function findTaskListShortcutOwners( + shortcuts: TaskListShortcutMap, + shortcut: string, + excludeAction?: TaskListKeyboardAction +): TaskListKeyboardAction[] { + return TASK_LIST_KEYBOARD_ACTIONS.filter( + (action) => action !== excludeAction && shortcuts[action].includes(shortcut) + ); +} + +export function replaceTaskListShortcut( + shortcuts: TaskListShortcutMap, + action: TaskListKeyboardAction, + shortcut: string +): TaskListShortcutMap { + return Object.fromEntries( + TASK_LIST_KEYBOARD_ACTIONS.map((candidate) => [ + candidate, + candidate === action + ? [...new Set([...shortcuts[candidate], shortcut])] + : shortcuts[candidate].filter((value) => value !== shortcut), + ]) + ) as TaskListShortcutMap; +} + export function resolveTaskListKeyboardAction( event: Pick< KeyboardEvent, @@ -154,7 +183,7 @@ export function resolveTaskListKeyboardAction( if (!shortcut) return null; for (const action of TASK_LIST_KEYBOARD_ACTIONS) { - if (shortcuts[action].includes(shortcut)) return action; + if (shortcuts[action]?.includes(shortcut)) return action; } return null; } @@ -173,6 +202,8 @@ export function formatTaskListShortcut(shortcut: string, isMacOS: boolean): stri enter: "Enter", delete: "Delete", escape: "Esc", + home: "Home", + end: "End", arrowdown: "↓", arrowup: "↑", }; diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 2d5fad63c..9e8f139f3 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -526,9 +526,15 @@ export const en: TranslationTree = { resetAll: "Reset all shortcuts", resetAllDescription: "Restore every task-list shortcut to its default binding.", conflict: "Conflict: {shortcuts} is also assigned to another task-list action.", + duplicateTitle: "Shortcut already assigned", + duplicateMessage: + "{shortcut} is assigned to {actions}. Replace the existing assignment?", + replace: "Replace", actions: { "navigate-next": "Focus next task", "navigate-previous": "Focus previous task", + "jump-first": "Jump to first task", + "jump-last": "Jump to last task", "create-task": "Create task", "focus-search": "Focus search", "edit-task": "Edit task", diff --git a/src/settings/tabs/keyboardShortcutsTab.ts b/src/settings/tabs/keyboardShortcutsTab.ts index 6fdf74f49..6bbb2d754 100644 --- a/src/settings/tabs/keyboardShortcutsTab.ts +++ b/src/settings/tabs/keyboardShortcutsTab.ts @@ -4,12 +4,15 @@ import { DEFAULT_TASK_LIST_SHORTCUTS, TASK_LIST_KEYBOARD_ACTIONS, findTaskListShortcutConflicts, + findTaskListShortcutOwners, formatTaskListShortcut, keyboardEventToTaskListShortcut, + replaceTaskListShortcut, type TaskListKeyboardAction, } from "../../bases/taskListKeyboardActions"; import { createSettingGroup } from "../components/settingHelpers"; import type { TranslationKey } from "../../i18n"; +import { showConfirmationModal } from "../../modals/ConfirmationModal"; function actionKey(action: TaskListKeyboardAction): TranslationKey { return `settings.keyboardShortcuts.actions.${action}`; @@ -71,22 +74,59 @@ export function renderKeyboardShortcutsTab( const buttonEl = button.buttonEl; buttonEl.setText(translate("settings.keyboardShortcuts.recording")); buttonEl.addClass("mod-cta"); - const capture = (event: KeyboardEvent) => { + const capture = async (event: KeyboardEvent) => { event.preventDefault(); event.stopPropagation(); const shortcut = keyboardEventToTaskListShortcut(event); if (!shortcut) return; - buttonEl.removeEventListener("keydown", capture); + buttonEl.removeEventListener("keydown", captureListener); if (!shortcuts[action].includes(shortcut)) { - plugin.settings.taskListShortcuts[action] = [ - ...shortcuts[action], + const owners = findTaskListShortcutOwners( + shortcuts, shortcut, - ]; + action + ); + if (owners.length > 0) { + const replace = await showConfirmationModal(plugin.app, { + title: translate( + "settings.keyboardShortcuts.duplicateTitle" + ), + message: translate( + "settings.keyboardShortcuts.duplicateMessage", + { + shortcut: formatTaskListShortcut( + shortcut, + Platform.isMacOS + ), + actions: owners + .map((owner) => translate(actionKey(owner))) + .join(", "), + } + ), + confirmText: translate( + "settings.keyboardShortcuts.replace" + ), + cancelText: translate("common.cancel"), + isDestructive: true, + }); + if (!replace) { + renderKeyboardShortcutsTab(container, plugin, save); + return; + } + plugin.settings.taskListShortcuts = + replaceTaskListShortcut(shortcuts, action, shortcut); + } else { + plugin.settings.taskListShortcuts[action] = [ + ...shortcuts[action], + shortcut, + ]; + } save(); } renderKeyboardShortcutsTab(container, plugin, save); }; - buttonEl.addEventListener("keydown", capture); + const captureListener = (event: KeyboardEvent) => void capture(event); + buttonEl.addEventListener("keydown", captureListener); buttonEl.focus(); }); }); diff --git a/tests/unit/bases/TaskListFocusController.test.ts b/tests/unit/bases/TaskListFocusController.test.ts index 5a7daa3df..715e2f8a6 100644 --- a/tests/unit/bases/TaskListFocusController.test.ts +++ b/tests/unit/bases/TaskListFocusController.test.ts @@ -25,7 +25,8 @@ describe("TaskListFocusController", () => { root.addEventListener("keydown", (event) => { if (event.key === "ArrowDown") controller.moveFocus(event, "next"); else if (event.key === "ArrowUp") controller.moveFocus(event, "previous"); - else controller.handleKeyDown(event); + else if (event.key === "Home") controller.moveFocus(event, "first"); + else if (event.key === "End") controller.moveFocus(event, "last"); }); HTMLElement.prototype.scrollIntoView = jest.fn(); }); @@ -71,6 +72,22 @@ describe("TaskListFocusController", () => { expect(document.activeElement).toBe(cards[1]); }); + it("supports configurable first and last navigation through the same path", () => { + const cards = [createCard("a.md"), createCard("b.md"), createCard("c.md")]; + root.append(...cards); + controller.restoreAfterRender(); + cards[1].focus(); + const firstEvent = new KeyboardEvent("keydown", { key: "g", cancelable: true }); + Object.defineProperty(firstEvent, "target", { value: cards[1] }); + controller.moveFocus(firstEvent, "first"); + expect(document.activeElement).toBe(cards[0]); + + const lastEvent = new KeyboardEvent("keydown", { key: "G", cancelable: true }); + Object.defineProperty(lastEvent, "target", { value: cards[0] }); + controller.moveFocus(lastEvent, "last"); + expect(document.activeElement).toBe(cards[2]); + }); + it("handles navigation when a Bases capture listener already prevented the default", () => { const cards = [createCard("a.md"), createCard("b.md")]; root.append(...cards); diff --git a/tests/unit/bases/TaskListView.keyboardActions.test.ts b/tests/unit/bases/TaskListView.keyboardActions.test.ts index 4182799f0..ef242fb40 100644 --- a/tests/unit/bases/TaskListView.keyboardActions.test.ts +++ b/tests/unit/bases/TaskListView.keyboardActions.test.ts @@ -96,6 +96,31 @@ describe("TaskListView keyboard actions", () => { expect(moveFocus).toHaveBeenCalledWith(event, "next"); }); + it.each([ + ["g", "jump-first", "first"], + ["G", "jump-last", "last"], + ] as const)("routes configured boundary key %s through the focus controller", (key, action, direction) => { + const moveFocus = jest.fn(); + const event = new KeyboardEvent("keydown", { key, cancelable: true }); + const view = { + plugin: { + settings: { + taskListShortcuts: { + [action]: [key.toLowerCase()], + }, + }, + }, + focusController: { + getFocusedPathForEvent: jest.fn(() => "focused.md"), + moveFocus, + }, + }; + + (TaskListView.prototype as any).handleTaskListActionKeyDown.call(view, event); + + expect(moveFocus).toHaveBeenCalledWith(event, direction); + }); + it.each([ ["Enter", { shiftKey: true }, "open-task-notes"], ["s", { shiftKey: true }, "edit-scheduled"], diff --git a/tests/unit/bases/taskListKeyboardActions.test.ts b/tests/unit/bases/taskListKeyboardActions.test.ts index 9aa36824b..f53d7c0a2 100644 --- a/tests/unit/bases/taskListKeyboardActions.test.ts +++ b/tests/unit/bases/taskListKeyboardActions.test.ts @@ -1,11 +1,13 @@ import { DEFAULT_TASK_LIST_SHORTCUTS, findTaskListShortcutConflicts, + findTaskListShortcutOwners, formatTaskListShortcut, normalizeTaskListShortcut, normalizeTaskListShortcutMap, resolveDefaultTaskListKeyboardAction, resolveTaskListKeyboardAction, + replaceTaskListShortcut, } from "../../../src/bases/taskListKeyboardActions"; function key( @@ -27,6 +29,8 @@ describe("resolveDefaultTaskListKeyboardAction", () => { it.each([ ["ArrowDown", {}, "navigate-next"], ["ArrowUp", {}, "navigate-previous"], + ["Home", {}, "jump-first"], + ["End", {}, "jump-last"], ["c", {}, "create-task"], ["/", {}, "focus-search"], ["Enter", {}, "edit-task"], @@ -105,6 +109,23 @@ describe("resolveDefaultTaskListKeyboardAction", () => { ]); }); + it("finds duplicate owners and replaces their binding atomically", () => { + const shortcuts = normalizeTaskListShortcutMap({ + "edit-due": ["x"], + "edit-status": ["x"], + "jump-first": ["home"], + }); + + expect(findTaskListShortcutOwners(shortcuts, "x", "jump-first")).toEqual([ + "edit-due", + "edit-status", + ]); + const replaced = replaceTaskListShortcut(shortcuts, "jump-first", "x"); + expect(replaced["edit-due"]).toEqual([]); + expect(replaced["edit-status"]).toEqual([]); + expect(replaced["jump-first"]).toEqual(["home", "x"]); + }); + it("formats portable modifiers for the current platform", () => { expect(formatTaskListShortcut("mod+shift+enter", false)).toBe("Ctrl+Shift+Enter"); expect(formatTaskListShortcut("mod+shift+enter", true)).toBe("⌘⇧Enter"); From 7f01a1e3e5a8114f6cfdf5e9d40f1524fb1015cf Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Wed, 29 Jul 2026 10:46:47 -0600 Subject: [PATCH 10/55] Initial upgrades to focus and selection management - Selection/focus visuals now have deterministic precedence: - Purple selection background overrides gray focus background. - Gray focus border overrides the primary-selection border. - Every selected task gets a subdued purple border. - The primary selected task retains the brighter purple border when not focused. - The first rendered task receives keyboard focus when the task view initially loads, so shortcuts work immediately without clicking a task first. --- src/bases/BasesViewBase.ts | 9 +++++ src/bases/TaskListFocusController.ts | 14 ++++++- src/bases/TaskListView.ts | 6 ++- styles/task-card-bem.css | 21 ++++++++-- .../bases/TaskListFocusController.test.ts | 19 +++++++++ .../TaskListView.keyboardSelection.test.ts | 40 +++++++++++++++++++ 6 files changed, 103 insertions(+), 6 deletions(-) diff --git a/src/bases/BasesViewBase.ts b/src/bases/BasesViewBase.ts index b92c8c8de..bee7d6a90 100644 --- a/src/bases/BasesViewBase.ts +++ b/src/bases/BasesViewBase.ts @@ -805,6 +805,7 @@ export abstract class BasesViewBase extends Component { // Keyboard event handler for selection mode const handleKeyDown = (e: KeyboardEvent) => { + if (!this.canHandleSelectionKeyDown(e)) return; handleBasesSelectionKeyDown({ event: e, selectionService, @@ -835,6 +836,14 @@ export abstract class BasesViewBase extends Component { }); } + /** + * Lets specialized Bases views defer selection keys while another UI surface + * (for example a menu or modal) owns keyboard input. + */ + protected canHandleSelectionKeyDown(_event: KeyboardEvent): boolean { + return true; + } + /** * Update UI to reflect selection mode state. */ diff --git a/src/bases/TaskListFocusController.ts b/src/bases/TaskListFocusController.ts index 62fd46aa2..b73a096ba 100644 --- a/src/bases/TaskListFocusController.ts +++ b/src/bases/TaskListFocusController.ts @@ -29,8 +29,14 @@ function identitiesEqual( export class TaskListFocusController { private focusedIdentity: TaskListFocusIdentity | null = null; private restoreDomFocus = false; + private initialFocusPending: boolean; - constructor(private readonly root: HTMLElement) {} + constructor( + private readonly root: HTMLElement, + autoFocusInitial = false + ) { + this.initialFocusPending = autoFocusInitial; + } handleFocusIn(event: FocusEvent): void { const card = this.getCardFromTarget(event.target); @@ -99,7 +105,10 @@ export class TaskListFocusController { } this.syncRovingTabIndex(cards); - if (this.restoreDomFocus) { + if (this.initialFocusPending) { + this.initialFocusPending = false; + card.focus({ preventScroll: true }); + } else if (this.restoreDomFocus) { card.focus({ preventScroll: true }); card.scrollIntoView({ block: "nearest" }); } @@ -109,6 +118,7 @@ export class TaskListFocusController { clear(): void { this.focusedIdentity = null; this.restoreDomFocus = false; + this.initialFocusPending = false; } getFocusedIdentity(): TaskListFocusIdentity | null { diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index 55f04b41b..49183eef7 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -512,7 +512,7 @@ export class TaskListView extends BasesViewBase { itemsContainer.classList.add("tn-static-margin-top-12px-91e0f558"); rootElement.appendChild(itemsContainer); this.itemsContainer = itemsContainer; - this.focusController = new TaskListFocusController(itemsContainer); + this.focusController = new TaskListFocusController(itemsContainer, true); this.inputOwnershipController = new TaskListInputOwnershipController( rootElement, this.focusController @@ -2324,6 +2324,10 @@ export class TaskListView extends BasesViewBase { this.containerListenersRegistered = true; } + protected canHandleSelectionKeyDown(event: KeyboardEvent): boolean { + return this.inputOwnershipController?.canHandleListKeyDown(event) ?? false; + } + private handleTaskListEscape(event: KeyboardEvent): boolean { if (event.key !== "Escape") return false; diff --git a/styles/task-card-bem.css b/styles/task-card-bem.css index ac609a8ce..0961995ef 100644 --- a/styles/task-card-bem.css +++ b/styles/task-card-bem.css @@ -1971,16 +1971,26 @@ body.is-mobile .tasknotes-plugin .task-card--layout-inline .task-card__context-m /* Selected task card styling */ .tasknotes-plugin .task-card--keyboard-focused { - outline: 2px solid var(--interactive-accent); - outline-offset: 2px; + background-color: var(--background-modifier-hover); + outline: 2px solid var(--background-modifier-border-focus); + outline-offset: -2px; } .tasknotes-plugin .task-card--selected { background-color: color-mix(in srgb, var(--interactive-accent) 15%, transparent); border-radius: var(--tn-radius-sm); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--interactive-accent) 55%, transparent); } -.tasknotes-plugin .task-card--selected:hover { +/* Selection background always wins when focus and selection overlap. */ +.tasknotes-plugin .task-card--selected:focus, +.tasknotes-plugin .task-card--selected.task-card--keyboard-focused { + background-color: color-mix(in srgb, var(--interactive-accent) 15%, transparent); +} + +.tasknotes-plugin .task-card--selected:hover, +.tasknotes-plugin .task-card--selected:focus:hover, +.tasknotes-plugin .task-card--selected.task-card--keyboard-focused:hover { background-color: color-mix(in srgb, var(--interactive-accent) 20%, transparent); } @@ -1989,6 +1999,11 @@ body.is-mobile .tasknotes-plugin .task-card--layout-inline .task-card__context-m box-shadow: inset 0 0 0 2px var(--interactive-accent); } +/* Keyboard focus border wins over both selected border strengths. */ +.tasknotes-plugin .task-card--selected.task-card--keyboard-focused, +.tasknotes-plugin .task-card--selected:focus-visible { + box-shadow: inset 0 0 0 2px var(--background-modifier-border-focus); +} /* Selection indicator floating badge */ .tasknotes-plugin .tn-selection-indicator { diff --git a/tests/unit/bases/TaskListFocusController.test.ts b/tests/unit/bases/TaskListFocusController.test.ts index 715e2f8a6..6331d9899 100644 --- a/tests/unit/bases/TaskListFocusController.test.ts +++ b/tests/unit/bases/TaskListFocusController.test.ts @@ -54,6 +54,25 @@ describe("TaskListFocusController", () => { expect(document.activeElement).toBe(cards[0]); }); + it("can focus the first rendered task immediately on initial view load", () => { + const initialRoot = document.createElement("div"); + const cards = [createCard("first.md"), createCard("second.md")]; + initialRoot.append(...cards); + document.body.appendChild(initialRoot); + const initialController = new TaskListFocusController(initialRoot, true); + initialRoot.addEventListener("focusin", (event) => + initialController.handleFocusIn(event) + ); + + initialController.restoreAfterRender(); + + expect(document.activeElement).toBe(cards[0]); + expect(initialController.getFocusedIdentity()).toEqual({ + path: "first.md", + occurrence: 0, + }); + }); + it("moves focus through the same path for configurable navigation keys", () => { const cards = [createCard("a.md"), createCard("b.md")]; root.append(...cards); diff --git a/tests/unit/bases/TaskListView.keyboardSelection.test.ts b/tests/unit/bases/TaskListView.keyboardSelection.test.ts index 5be927166..4b4d00997 100644 --- a/tests/unit/bases/TaskListView.keyboardSelection.test.ts +++ b/tests/unit/bases/TaskListView.keyboardSelection.test.ts @@ -73,4 +73,44 @@ describe("TaskListView keyboard selection", () => { expect(stopPropagation).toHaveBeenCalled(); expect(exitSelectionMode).toHaveBeenCalledWith(true); }); + + it("does not let the inherited selection handler clear selection while a popup owns Escape", () => { + const exitSelectionMode = jest.fn(); + const rootElement = document.createElement("div"); + const card = document.createElement("div"); + rootElement.appendChild(card); + document.body.appendChild(rootElement); + const view = { + rootElement, + plugin: { + taskSelectionService: { + isSelectionModeActive: jest.fn(() => true), + exitSelectionMode, + onSelectionChange: jest.fn(() => jest.fn()), + onSelectionModeChange: jest.fn(() => jest.fn()), + }, + }, + inputOwnershipController: { + canHandleListKeyDown: jest.fn(() => false), + }, + canHandleSelectionKeyDown: (TaskListView.prototype as any) + .canHandleSelectionKeyDown, + getVisibleTaskPaths: jest.fn(() => ["focused.md"]), + updateSelectionModeUI: jest.fn(), + updateSelectionVisuals: jest.fn(), + updateSelectionIndicator: jest.fn(), + register: jest.fn(), + }; + (TaskListView.prototype as any).setupSelectionHandling.call(view); + const event = new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }); + + card.dispatchEvent(event); + + expect(view.inputOwnershipController.canHandleListKeyDown).toHaveBeenCalledWith(event); + expect(exitSelectionMode).not.toHaveBeenCalled(); + }); }); From 7beacbdf8d60aec25840d50cafafc76c0cac89c4 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Wed, 29 Jul 2026 11:32:40 -0600 Subject: [PATCH 11/55] Trying to preserve focus and selection after edits and tab changes - Selection state is rehydrated whenever Bases rebuilds the view after an edit. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Batch property, tag, and archive edits no longer explicitly clear selection. Delete and “Clear selection” still do. - Task-list views listen for workspace leaf activation and restore the previously focused card when their tab returns to the foreground. --- src/bases/BasesViewBase.ts | 7 +++ src/bases/TaskListInputOwnershipController.ts | 9 +++- src/bases/TaskListView.ts | 51 ++++++++++++++++++- src/components/BatchContextMenu.ts | 11 ---- .../TaskListInputOwnershipController.test.ts | 16 ++++++ .../TaskListView.keyboardSelection.test.ts | 50 ++++++++++++++++++ .../BatchContextMenu.selection.test.ts | 40 +++++++++++++++ 7 files changed, 171 insertions(+), 13 deletions(-) create mode 100644 tests/unit/components/BatchContextMenu.selection.test.ts diff --git a/src/bases/BasesViewBase.ts b/src/bases/BasesViewBase.ts index bee7d6a90..9dc6f70b1 100644 --- a/src/bases/BasesViewBase.ts +++ b/src/bases/BasesViewBase.ts @@ -828,6 +828,13 @@ export abstract class BasesViewBase extends Component { this.updateSelectionModeUI(active); }); + // A Bases refresh can recreate the view root without changing the shared + // selection service. Hydrate the new DOM immediately instead of waiting + // for another selection event. + this.updateSelectionModeUI(selectionService.isSelectionModeActive()); + this.updateSelectionVisuals(); + this.updateSelectionIndicator(selectionService.getSelectionCount()); + // Register cleanup this.register(() => { this.rootElement?.removeEventListener("keydown", handleKeyDown); diff --git a/src/bases/TaskListInputOwnershipController.ts b/src/bases/TaskListInputOwnershipController.ts index fea4bcb77..dce246639 100644 --- a/src/bases/TaskListInputOwnershipController.ts +++ b/src/bases/TaskListInputOwnershipController.ts @@ -16,6 +16,7 @@ export class TaskListInputOwnershipController { canHandleListKeyDown(event: KeyboardEvent): boolean { if (event.isComposing || event.key === "Process") return false; + if (this.suspendedForOverlay) return false; const target = event.target; if (!(target instanceof Element) || target.closest(EDITABLE_SELECTOR)) return false; @@ -48,7 +49,13 @@ export class TaskListInputOwnershipController { handleOverlayInteraction(event: Event): void { const target = event.target; - if (!(target instanceof Element) || !this.getOverlayFromTarget(target)) return; + const overlayTarget = + target instanceof Element && Boolean(this.getOverlayFromTarget(target)); + const overlayCloseKey = + event instanceof KeyboardEvent && + (event.key === "Escape" || event.key === "Backspace") && + (this.suspendedForOverlay || this.hasOpenOverlay()); + if (!overlayTarget && !overlayCloseKey) return; this.suspendedForOverlay = true; this.scheduleRestoreAfterOverlayClose(); diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index 49183eef7..92ba1870e 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -224,6 +224,44 @@ export class TaskListView extends BasesViewBase { // Call parent onload which sets up container and listeners super.onload(); this.registerGroupContextMenuListeners(); + this.registerEvent( + this.plugin.app.workspace.on("active-leaf-change", (leaf) => { + this.restoreFocusForActivatedLeaf(leaf); + }) + ); + this.registerDomEvent( + this.containerEl.ownerDocument, + "click", + (event: MouseEvent) => { + const target = event.target; + if ( + target instanceof Element && + target.closest(".workspace-tab-header") + ) { + const win = this.containerEl.ownerDocument.defaultView ?? window; + win.setTimeout(() => { + this.restoreFocusForActivatedLeaf( + this.plugin.app.workspace.getMostRecentLeaf() + ); + }, 0); + } + }, + true + ); + } + + private restoreFocusForActivatedLeaf( + leaf: { view?: { containerEl?: HTMLElement } } | null + ): void { + const leafContainer = leaf?.view?.containerEl; + if (!leafContainer?.contains(this.containerEl)) return; + + const win = this.containerEl.ownerDocument.defaultView ?? window; + win.setTimeout(() => { + if (this.rootElement?.isConnected) { + this.focusController?.restoreFocusedElement(); + } + }, 0); } /** @@ -604,10 +642,21 @@ export class TaskListView extends BasesViewBase { this.sortScopeCandidateTaskPaths.clear(); this.renderError(error instanceof Error ? error : new Error(String(error))); } finally { - this.focusController?.restoreAfterRender(); + this.restoreInteractionStateAfterRender(); } } + private restoreInteractionStateAfterRender(): void { + this.focusController?.restoreAfterRender(); + // Rendering replaces card elements, so restore visual state from the + // shared selection service after every render—not only when selection + // itself changes. + this.updateSelectionVisuals(); + this.updateSelectionIndicator( + this.plugin.taskSelectionService?.getSelectionCount() ?? 0 + ); + } + // ── Drag-to-reorder ──────────────────────────────────────────────── private getGroupByPropertyId(): string | null { diff --git a/src/components/BatchContextMenu.ts b/src/components/BatchContextMenu.ts index 7a4084c20..54be09993 100644 --- a/src/components/BatchContextMenu.ts +++ b/src/components/BatchContextMenu.ts @@ -408,9 +408,6 @@ export class BatchContextMenu { new Notice(`Updated tags on ${successCount} tasks, ${failCount} failed`); } - plugin.taskSelectionService?.clearSelection(); - plugin.taskSelectionService?.exitSelectionMode(); - onUpdate?.(); } catch (error) { tasknotesLogger.error("[BatchContextMenu] Batch tag update failed:", { @@ -457,10 +454,6 @@ export class BatchContextMenu { new Notice(`Updated ${successCount} tasks, ${failCount} failed`); } - // Clear selection after successful batch operation - plugin.taskSelectionService?.clearSelection(); - plugin.taskSelectionService?.exitSelectionMode(); - onUpdate?.(); } catch (error) { tasknotesLogger.error("[BatchContextMenu] Batch update failed:", { @@ -512,10 +505,6 @@ export class BatchContextMenu { ); } - // Clear selection after successful batch operation - plugin.taskSelectionService?.clearSelection(); - plugin.taskSelectionService?.exitSelectionMode(); - onUpdate?.(); } catch (error) { tasknotesLogger.error("[BatchContextMenu] Batch archive failed:", { diff --git a/tests/unit/bases/TaskListInputOwnershipController.test.ts b/tests/unit/bases/TaskListInputOwnershipController.test.ts index 34d70115e..57b22cb2d 100644 --- a/tests/unit/bases/TaskListInputOwnershipController.test.ts +++ b/tests/unit/bases/TaskListInputOwnershipController.test.ts @@ -80,6 +80,22 @@ describe("TaskListInputOwnershipController", () => { expect(document.activeElement).toBe(taskCard); }); + it("keeps Escape overlay-owned after the menu is synchronously removed", () => { + controller.noteOverlayOpening(); + const menu = document.createElement("div"); + menu.className = "menu"; + document.body.appendChild(menu); + const event = new KeyboardEvent("keydown", { key: "Escape" }); + Object.defineProperty(event, "target", { value: taskCard }); + + controller.handleOverlayInteraction(event); + menu.remove(); + + expect(controller.canHandleListKeyDown(event)).toBe(false); + jest.runAllTimers(); + expect(controller.canHandleListKeyDown(event)).toBe(true); + }); + it("does not steal focus when the user moved to another control", () => { controller.noteOverlayOpening(); const outsideInput = document.createElement("input"); diff --git a/tests/unit/bases/TaskListView.keyboardSelection.test.ts b/tests/unit/bases/TaskListView.keyboardSelection.test.ts index 4b4d00997..bc7ac164f 100644 --- a/tests/unit/bases/TaskListView.keyboardSelection.test.ts +++ b/tests/unit/bases/TaskListView.keyboardSelection.test.ts @@ -85,6 +85,7 @@ describe("TaskListView keyboard selection", () => { plugin: { taskSelectionService: { isSelectionModeActive: jest.fn(() => true), + getSelectionCount: jest.fn(() => 2), exitSelectionMode, onSelectionChange: jest.fn(() => jest.fn()), onSelectionModeChange: jest.fn(() => jest.fn()), @@ -102,6 +103,9 @@ describe("TaskListView keyboard selection", () => { register: jest.fn(), }; (TaskListView.prototype as any).setupSelectionHandling.call(view); + expect(view.updateSelectionModeUI).toHaveBeenCalledWith(true); + expect(view.updateSelectionVisuals).toHaveBeenCalled(); + expect(view.updateSelectionIndicator).toHaveBeenCalledWith(2); const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, @@ -113,4 +117,50 @@ describe("TaskListView keyboard selection", () => { expect(view.inputOwnershipController.canHandleListKeyDown).toHaveBeenCalledWith(event); expect(exitSelectionMode).not.toHaveBeenCalled(); }); + + it("restores remembered card focus when its workspace leaf is activated", () => { + jest.useFakeTimers(); + const leafContainer = document.createElement("div"); + const containerEl = document.createElement("div"); + const rootElement = document.createElement("div"); + leafContainer.append(containerEl); + containerEl.append(rootElement); + document.body.appendChild(leafContainer); + const restoreFocusedElement = jest.fn(); + const view = { + containerEl, + rootElement, + focusController: { restoreFocusedElement }, + }; + + (TaskListView.prototype as any).restoreFocusForActivatedLeaf.call(view, { + view: { containerEl: leafContainer }, + }); + jest.runAllTimers(); + + expect(restoreFocusedElement).toHaveBeenCalled(); + jest.useRealTimers(); + }); + + it("rehydrates selection visuals after every card render", () => { + const restoreAfterRender = jest.fn(); + const updateSelectionVisuals = jest.fn(); + const updateSelectionIndicator = jest.fn(); + const view = { + focusController: { restoreAfterRender }, + plugin: { + taskSelectionService: { + getSelectionCount: jest.fn(() => 3), + }, + }, + updateSelectionVisuals, + updateSelectionIndicator, + }; + + (TaskListView.prototype as any).restoreInteractionStateAfterRender.call(view); + + expect(restoreAfterRender).toHaveBeenCalled(); + expect(updateSelectionVisuals).toHaveBeenCalled(); + expect(updateSelectionIndicator).toHaveBeenCalledWith(3); + }); }); diff --git a/tests/unit/components/BatchContextMenu.selection.test.ts b/tests/unit/components/BatchContextMenu.selection.test.ts new file mode 100644 index 000000000..90018d4e8 --- /dev/null +++ b/tests/unit/components/BatchContextMenu.selection.test.ts @@ -0,0 +1,40 @@ +import { BatchContextMenu } from "../../../src/components/BatchContextMenu"; +import type { TaskInfo } from "../../../src/types"; + +describe("BatchContextMenu selection persistence", () => { + it("keeps selection after applying a property edit", async () => { + const task = { + path: "selected.md", + title: "Selected", + status: "open", + } as TaskInfo; + const clearSelection = jest.fn(); + const exitSelectionMode = jest.fn(); + const updateProperty = jest.fn(async () => undefined); + const onUpdate = jest.fn(); + const menu = { + options: { + plugin: { + cacheManager: { + getTaskInfo: jest.fn(async () => task), + }, + taskService: { updateProperty }, + taskSelectionService: { clearSelection, exitSelectionMode }, + }, + selectedPaths: [task.path], + onUpdate, + }, + }; + + await (BatchContextMenu.prototype as any).batchUpdateProperty.call( + menu, + "status", + "done" + ); + + expect(updateProperty).toHaveBeenCalledWith(task, "status", "done"); + expect(clearSelection).not.toHaveBeenCalled(); + expect(exitSelectionMode).not.toHaveBeenCalled(); + expect(onUpdate).toHaveBeenCalled(); + }); +}); From a46a799cb5014a961aae6b7bc968c28d77a18b1e Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Wed, 29 Jul 2026 11:52:32 -0600 Subject: [PATCH 12/55] Keep hotkeys active, search hotkey works - Keyboard shortcuts now remain available when focus is on a non- interactive part of the foreground task view. Actions use the remembered focused card as their target. - Cancelling search restores card focus, and subsequent rerenders continue restoring it. - / now creates and opens the search controls when search was disabled for the Bases view, then focuses the input. - Existing search controls are simply focused. --- src/bases/TaskListFocusController.ts | 15 ++++- src/bases/TaskListView.ts | 65 ++++++++++++++++--- .../bases/TaskListFocusController.test.ts | 15 +++++ .../TaskListView.keyboardActions.test.ts | 42 +++++++++++- 4 files changed, 124 insertions(+), 13 deletions(-) diff --git a/src/bases/TaskListFocusController.ts b/src/bases/TaskListFocusController.ts index b73a096ba..4c507815e 100644 --- a/src/bases/TaskListFocusController.ts +++ b/src/bases/TaskListFocusController.ts @@ -140,7 +140,11 @@ export class TaskListFocusController { return true; } - getFocusedPathForEvent(event: KeyboardEvent, allowModifiers = false): string | null { + getFocusedPathForEvent( + event: KeyboardEvent, + allowModifiers = false, + allowRememberedFallback = false + ): string | null { if ( event.defaultPrevented || (!allowModifiers && @@ -153,7 +157,14 @@ export class TaskListFocusController { if (!(target instanceof Element) || target.closest(INTERACTIVE_SELECTOR)) return null; const card = this.getCardFromTarget(target); - return card?.dataset.taskPath ?? null; + if (card?.dataset.taskPath) return card.dataset.taskPath; + if ( + allowRememberedFallback && + (this.root.contains(target) || target.contains(this.root)) + ) { + return this.focusedIdentity?.path ?? null; + } + return null; } private getCards(): HTMLElement[] { diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index 92ba1870e..e98515246 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -205,6 +205,7 @@ export class TaskListView extends BasesViewBase { private readonly CARD_NO_DRAG_SELECTOR = '[data-tn-no-drag="true"], a, button, input, select, textarea, [contenteditable="true"]'; private readonly CARD_DRAG_HANDLE_SELECTOR = '[data-tn-drag-handle="true"]'; + private searchOpenedByShortcut = false; constructor(controller: unknown, containerEl: HTMLElement, plugin: TaskNotesPlugin) { super(controller, containerEl, plugin); @@ -361,7 +362,8 @@ export class TaskListView extends BasesViewBase { this.subGroupPropertyId = this.config.getAsPropertyId("subGroup"); // Read enableSearch toggle (default: false for backward compatibility) const enableSearchValue = this.config.get("enableSearch"); - this.enableSearch = (enableSearchValue as boolean) ?? false; + this.enableSearch = + ((enableSearchValue as boolean) ?? false) || this.searchOpenedByShortcut; const defaultCollapsedStateValue = this.config.get("defaultCollapsedState"); this.defaultCollapsedState = @@ -2348,11 +2350,16 @@ export class TaskListView extends BasesViewBase { this.focusController?.handlePointerDown(event); }); this.registerDomEvent(this.itemsContainer, "keydown", (event: KeyboardEvent) => { - if (!this.inputOwnershipController?.canHandleListKeyDown(event)) return; - if (this.handleTaskListEscape(event)) return; - if (this.handleTaskListSelectionKeyDown(event)) return; - this.handleTaskListActionKeyDown(event); + this.handleTaskListKeyDown(event); }); + if (this.rootElement) { + this.registerDomEvent(this.rootElement, "keydown", (event: KeyboardEvent) => { + if (event.defaultPrevented || this.itemsContainer?.contains(event.target as Node)) { + return; + } + this.handleTaskListKeyDown(event, true); + }); + } const doc = this.itemsContainer.ownerDocument; this.registerDomEvent(doc, "focusin", (event: FocusEvent) => { this.inputOwnershipController?.handleDocumentFocusIn(event); @@ -2373,6 +2380,16 @@ export class TaskListView extends BasesViewBase { this.containerListenersRegistered = true; } + private handleTaskListKeyDown( + event: KeyboardEvent, + allowRememberedFocus = false + ): void { + if (!this.inputOwnershipController?.canHandleListKeyDown(event)) return; + if (this.handleTaskListEscape(event)) return; + if (this.handleTaskListSelectionKeyDown(event, allowRememberedFocus)) return; + this.handleTaskListActionKeyDown(event, allowRememberedFocus); + } + protected canHandleSelectionKeyDown(event: KeyboardEvent): boolean { return this.inputOwnershipController?.canHandleListKeyDown(event) ?? false; } @@ -2390,10 +2407,17 @@ export class TaskListView extends BasesViewBase { this.focusController?.restoreFocusedElement(); } - private handleTaskListSelectionKeyDown(event: KeyboardEvent): boolean { + private handleTaskListSelectionKeyDown( + event: KeyboardEvent, + allowRememberedFocus = false + ): boolean { if (event.key !== " " && event.key !== "Spacebar") return false; - const taskPath = this.focusController?.getFocusedPathForEvent(event); + const taskPath = this.focusController?.getFocusedPathForEvent( + event, + false, + allowRememberedFocus + ); const selectionService = this.plugin.taskSelectionService; if (!taskPath || !selectionService) return false; @@ -2403,12 +2427,23 @@ export class TaskListView extends BasesViewBase { return true; } - private handleTaskListActionKeyDown(event: KeyboardEvent): void { + private handleTaskListActionKeyDown( + event: KeyboardEvent, + allowRememberedFocus = false + ): void { const action = resolveTaskListKeyboardAction( event, this.plugin?.settings?.taskListShortcuts ); - if (!action || !this.focusController?.getFocusedPathForEvent(event, true)) return; + if ( + !action || + !this.focusController?.getFocusedPathForEvent( + event, + true, + allowRememberedFocus + ) + ) + return; const navigationDirections = { "navigate-next": "next", "navigate-previous": "previous", @@ -2451,7 +2486,7 @@ export class TaskListView extends BasesViewBase { await this.createFileForView(); return; case "focus-search": - this.searchBox?.focus(); + this.focusTaskListSearch(); return; case "edit-task": { const task = (await this.getTaskActionTargets())[0]; @@ -2490,6 +2525,16 @@ export class TaskListView extends BasesViewBase { } } + private focusTaskListSearch(): void { + if (!this.rootElement) return; + if (!this.searchBox) { + this.searchOpenedByShortcut = true; + this.enableSearch = true; + this.setupSearch(this.rootElement); + } + this.searchBox?.focus(); + } + private async getTaskActionTargets(): Promise { const tasks: TaskInfo[] = []; for (const path of this.getTaskActionTargetPaths()) { diff --git a/tests/unit/bases/TaskListFocusController.test.ts b/tests/unit/bases/TaskListFocusController.test.ts index 6331d9899..ee7bda042 100644 --- a/tests/unit/bases/TaskListFocusController.test.ts +++ b/tests/unit/bases/TaskListFocusController.test.ts @@ -245,4 +245,19 @@ describe("TaskListFocusController", () => { expect(controller.getFocusedPathForEvent(event)).toBeNull(); expect(controller.getFocusedPathForEvent(event, true)).toBe("focused.md"); }); + + it("uses remembered focus for non-interactive events from the view shell", () => { + const card = createCard("remembered.md"); + root.appendChild(card); + controller.restoreAfterRender(); + card.focus(); + const shell = document.createElement("div"); + shell.appendChild(root); + document.body.appendChild(shell); + const event = new KeyboardEvent("keydown", { key: "j" }); + Object.defineProperty(event, "target", { value: shell }); + + expect(controller.getFocusedPathForEvent(event, false, false)).toBeNull(); + expect(controller.getFocusedPathForEvent(event, false, true)).toBe("remembered.md"); + }); }); diff --git a/tests/unit/bases/TaskListView.keyboardActions.test.ts b/tests/unit/bases/TaskListView.keyboardActions.test.ts index ef242fb40..eeb954e23 100644 --- a/tests/unit/bases/TaskListView.keyboardActions.test.ts +++ b/tests/unit/bases/TaskListView.keyboardActions.test.ts @@ -144,7 +144,7 @@ describe("TaskListView keyboard actions", () => { (TaskListView.prototype as any).handleTaskListActionKeyDown.call(view, event); - expect(getFocusedPathForEvent).toHaveBeenCalledWith(event, true); + expect(getFocusedPathForEvent).toHaveBeenCalledWith(event, true, false); expect(event.defaultPrevented).toBe(true); expect(executeTaskListAction).toHaveBeenCalledWith(action); }); @@ -168,6 +168,46 @@ describe("TaskListView keyboard actions", () => { expect(executeTaskListAction).not.toHaveBeenCalled(); }); + it("routes a shortcut from the active view shell through remembered task focus", () => { + const executeTaskListAction = jest.fn(); + const getFocusedPathForEvent = jest.fn(() => "remembered.md"); + const event = new KeyboardEvent("keydown", { key: "d", cancelable: true }); + const view = { + focusController: { getFocusedPathForEvent }, + executeTaskListAction, + }; + + (TaskListView.prototype as any).handleTaskListActionKeyDown.call( + view, + event, + true + ); + + expect(getFocusedPathForEvent).toHaveBeenCalledWith(event, true, true); + expect(executeTaskListAction).toHaveBeenCalledWith("edit-due"); + }); + + it("creates search controls on demand before focusing them", () => { + const rootElement = document.createElement("div"); + const focus = jest.fn(); + const view = { + rootElement, + searchBox: null, + searchOpenedByShortcut: false, + enableSearch: false, + setupSearch: jest.fn(function (this: { searchBox: { focus: () => void } | null }) { + this.searchBox = { focus }; + }), + }; + + (TaskListView.prototype as any).focusTaskListSearch.call(view); + + expect(view.searchOpenedByShortcut).toBe(true); + expect(view.enableSearch).toBe(true); + expect(view.setupSearch).toHaveBeenCalledWith(rootElement); + expect(focus).toHaveBeenCalled(); + }); + it("uses the first resolved target for the single-task edit modal", async () => { const first = task("first.md"); const second = task("second.md"); From 09b101fd21c7fce72ca8b4cf8ed4a0db5d3c5c1f Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Wed, 29 Jul 2026 13:49:34 -0600 Subject: [PATCH 13/55] Don't apply edits to tasks not visible. Keyboard multi-edit actions now: - Target only selected tasks present in the current filtered view. - Preserve hidden tasks in the selection state. - Do nothing if every selected task is filtered out. - Continue using the focused task when there is no active selection. --- src/bases/TaskListView.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index e98515246..a3e26d77b 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -2472,7 +2472,7 @@ export class TaskListView extends BasesViewBase { return resolveTaskListTargetPaths( this.plugin.taskSelectionService, this.focusController?.getFocusedIdentity()?.path - ); + ).filter((path) => this.currentVisibleTaskPaths.has(path)); } private async executeTaskListAction(action: TaskListKeyboardAction): Promise { From 4b63276896dc7da744184b651a7a7611368bb5d9 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Wed, 29 Jul 2026 14:22:28 -0600 Subject: [PATCH 14/55] New hotkey customization and minor commands - Enter in search preserves the query and returns focus to the remembered task. - Added customizable actions: - Clear focus/selection: Escape, Backspace - Toggle selection: Space - Copy selected/focused visible task titles: Ctrl/Cmd+C - Select all visible tasks: Ctrl/Cmd+A - Toggle archive: Y - Legacy shortcut settings migrate to the new action names. - Mixed archived/unarchived selections show a warning instead of toggling unpredictably. - Shortcut conflict detection and replace/cancel behavior automatically covers the new actions. - Fixed literal Space key normalization. --- src/bases/TaskListFocusController.ts | 1 + src/bases/TaskListView.ts | 150 +++++++++++++----- src/bases/taskListKeyboardActions.ts | 11 ++ src/i18n/resources/en.ts | 5 + src/settings/settingsPersistence.ts | 5 + .../bases/TaskListFocusController.test.ts | 13 ++ .../TaskListView.keyboardActions.test.ts | 70 ++++++++ .../TaskListView.keyboardSelection.test.ts | 50 +++--- .../bases/taskListKeyboardActions.test.ts | 8 + .../unit/settings/settingsPersistence.test.ts | 9 ++ 10 files changed, 252 insertions(+), 70 deletions(-) diff --git a/src/bases/TaskListFocusController.ts b/src/bases/TaskListFocusController.ts index 4c507815e..9d217b59a 100644 --- a/src/bases/TaskListFocusController.ts +++ b/src/bases/TaskListFocusController.ts @@ -119,6 +119,7 @@ export class TaskListFocusController { this.focusedIdentity = null; this.restoreDomFocus = false; this.initialFocusPending = false; + this.syncRovingTabIndex(); } getFocusedIdentity(): TaskListFocusIdentity | null { diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index a3e26d77b..cfb7e4ff8 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -25,6 +25,7 @@ import { createUTCDateFromLocalCalendarDate, } from "../utils/dateUtils"; import { stringifyUnknown } from "../utils/stringUtils"; +import { formatTasksForClipboard } from "../utils/taskClipboard"; import { VirtualScroller } from "../utils/VirtualScroller"; import { isSortOrderInSortConfig, @@ -2385,48 +2386,21 @@ export class TaskListView extends BasesViewBase { allowRememberedFocus = false ): void { if (!this.inputOwnershipController?.canHandleListKeyDown(event)) return; - if (this.handleTaskListEscape(event)) return; - if (this.handleTaskListSelectionKeyDown(event, allowRememberedFocus)) return; this.handleTaskListActionKeyDown(event, allowRememberedFocus); } protected canHandleSelectionKeyDown(event: KeyboardEvent): boolean { - return this.inputOwnershipController?.canHandleListKeyDown(event) ?? false; - } - - private handleTaskListEscape(event: KeyboardEvent): boolean { - if (event.key !== "Escape") return false; - - event.preventDefault(); - event.stopPropagation(); - this.plugin.taskSelectionService?.exitSelectionMode(true); - return true; + return ( + (this.inputOwnershipController?.canHandleListKeyDown(event) ?? false) && + event.shiftKey && + ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key) + ); } protected handleSearchDismissed(): void { this.focusController?.restoreFocusedElement(); } - private handleTaskListSelectionKeyDown( - event: KeyboardEvent, - allowRememberedFocus = false - ): boolean { - if (event.key !== " " && event.key !== "Spacebar") return false; - - const taskPath = this.focusController?.getFocusedPathForEvent( - event, - false, - allowRememberedFocus - ); - const selectionService = this.plugin.taskSelectionService; - if (!taskPath || !selectionService) return false; - - event.preventDefault(); - event.stopPropagation(); - selectionService.toggleSelection(taskPath); - return true; - } - private handleTaskListActionKeyDown( event: KeyboardEvent, allowRememberedFocus = false @@ -2435,15 +2409,19 @@ export class TaskListView extends BasesViewBase { event, this.plugin?.settings?.taskListShortcuts ); + if (!action) return; + const focusedPath = this.focusController?.getFocusedPathForEvent( + event, + true, + allowRememberedFocus + ); if ( - !action || - !this.focusController?.getFocusedPathForEvent( - event, - true, - allowRememberedFocus - ) - ) + !focusedPath && + action !== "clear-focus-and-selection" && + action !== "select-all" + ) { return; + } const navigationDirections = { "navigate-next": "next", "navigate-previous": "previous", @@ -2451,7 +2429,7 @@ export class TaskListView extends BasesViewBase { "jump-last": "last", } as const; if (action in navigationDirections) { - this.focusController.moveFocus( + this.focusController?.moveFocus( event, navigationDirections[action as keyof typeof navigationDirections] ); @@ -2460,7 +2438,22 @@ export class TaskListView extends BasesViewBase { event.preventDefault(); event.stopPropagation(); - this.inputOwnershipController?.noteOverlayOpening(); + if ( + [ + "edit-task", + "edit-due", + "edit-scheduled", + "edit-priority", + "edit-status", + "edit-recurrence", + "add-tags", + "add-context", + "add-project", + "delete-tasks", + ].includes(action) + ) { + this.inputOwnershipController?.noteOverlayOpening(); + } void this.executeTaskListAction(action); } @@ -2482,6 +2475,21 @@ export class TaskListView extends BasesViewBase { case "jump-first": case "jump-last": return; + case "clear-focus-and-selection": + this.clearTaskListFocusAndSelection(); + return; + case "toggle-select": + this.toggleFocusedTaskSelection(); + return; + case "select-all": + this.selectAllVisibleTasks(); + return; + case "copy-task-titles": + await this.copyTaskActionTargetTitles(); + return; + case "toggle-archive": + await this.toggleTaskActionTargetsArchive(); + return; case "create-task": await this.createFileForView(); return; @@ -2544,6 +2552,66 @@ export class TaskListView extends BasesViewBase { return tasks; } + private clearTaskListFocusAndSelection(): void { + const selectionService = this.plugin.taskSelectionService; + selectionService?.clearSelection(); + selectionService?.exitSelectionMode(); + this.focusController?.clear(); + this.rootElement?.focus({ preventScroll: true }); + } + + private toggleFocusedTaskSelection(): void { + const path = this.focusController?.getFocusedIdentity()?.path; + if (!path || !this.currentVisibleTaskPaths.has(path)) return; + this.plugin.taskSelectionService?.toggleSelection(path); + } + + private selectAllVisibleTasks(): void { + const selectionService = this.plugin.taskSelectionService; + if (!selectionService) return; + selectionService.selectAll([...this.currentVisibleTaskPaths]); + if (this.currentVisibleTaskPaths.size > 0) { + selectionService.enterSelectionMode(); + } + } + + private async copyTaskActionTargetTitles(): Promise { + const tasks = await this.getTaskActionTargets(); + if (tasks.length === 0) return; + + try { + await navigator.clipboard.writeText(formatTasksForClipboard(tasks, "titles")); + new Notice(`Copied ${tasks.length} task title${tasks.length === 1 ? "" : "s"}`); + } catch (error) { + tasknotesLogger.error("[TaskNotes][TaskListView] Failed to copy task titles", { + category: "provider", + operation: "copy-task-titles", + error, + }); + new Notice("Failed to copy task titles"); + } + } + + private async toggleTaskActionTargetsArchive(): Promise { + const tasks = await this.getTaskActionTargets(); + if (tasks.length === 0) return; + + const archived = tasks[0].archived === true; + if (!tasks.every((task) => (task.archived === true) === archived)) { + new Notice("Select tasks with the same archive state"); + return; + } + + for (const task of tasks) { + await this.plugin.taskService.toggleArchive(task); + } + new Notice( + `${archived ? "Unarchived" : "Archived"} ${tasks.length} task${ + tasks.length === 1 ? "" : "s" + }` + ); + } + private getTaskActionAnchor(): HTMLElement | null { return this.focusController?.getFocusedElement() ?? this.itemsContainer; } diff --git a/src/bases/taskListKeyboardActions.ts b/src/bases/taskListKeyboardActions.ts index b2c2ecd3a..da493688e 100644 --- a/src/bases/taskListKeyboardActions.ts +++ b/src/bases/taskListKeyboardActions.ts @@ -3,6 +3,11 @@ export const TASK_LIST_KEYBOARD_ACTIONS = [ "navigate-previous", "jump-first", "jump-last", + "clear-focus-and-selection", + "toggle-select", + "select-all", + "copy-task-titles", + "toggle-archive", "create-task", "focus-search", "edit-task", @@ -26,6 +31,11 @@ export const DEFAULT_TASK_LIST_SHORTCUTS: TaskListShortcutMap = { "navigate-previous": ["arrowup"], "jump-first": ["home"], "jump-last": ["end"], + "clear-focus-and-selection": ["escape", "backspace"], + "toggle-select": ["space"], + "select-all": ["mod+a"], + "copy-task-titles": ["mod+c"], + "toggle-archive": ["y"], "create-task": ["c"], "focus-search": ["slash"], "edit-task": ["enter"], @@ -68,6 +78,7 @@ const KEY_ALIASES: Record = { const MODIFIER_ORDER = ["mod", "ctrl", "meta", "alt", "shift"] as const; function normalizeKey(rawKey: string): string { + if (rawKey === " ") return "space"; const key = rawKey.trim().toLowerCase(); return KEY_ALIASES[key] ?? key; } diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 9e8f139f3..4d8b0ca70 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -535,6 +535,11 @@ export const en: TranslationTree = { "navigate-previous": "Focus previous task", "jump-first": "Jump to first task", "jump-last": "Jump to last task", + "clear-focus-and-selection": "Clear focus and selection", + "toggle-select": "Toggle focused task selection", + "select-all": "Select all visible tasks", + "copy-task-titles": "Copy task titles", + "toggle-archive": "Toggle archive", "create-task": "Create task", "focus-search": "Focus search", "edit-task": "Edit task", diff --git a/src/settings/settingsPersistence.ts b/src/settings/settingsPersistence.ts index 7073880ea..ef7cc930a 100644 --- a/src/settings/settingsPersistence.ts +++ b/src/settings/settingsPersistence.ts @@ -118,6 +118,11 @@ function migrateLoadedSettingsData(data: LoadedSettingsData | null): LoadedSetti migratedData.taskListShortcuts = normalizeTaskListShortcutMap({ "navigate-next": legacy.navigateDown, "navigate-previous": legacy.navigateUp, + "clear-focus-and-selection": legacy.clearFocusAndSelection, + "toggle-select": legacy.toggleSelect, + "select-all": legacy.selectAll, + "copy-task-titles": legacy.copyTaskTitles, + "toggle-archive": legacy.toggleArchive, "create-task": legacy.newTask, "focus-search": legacy.focusFilter, "edit-task": legacy.openEdit, diff --git a/tests/unit/bases/TaskListFocusController.test.ts b/tests/unit/bases/TaskListFocusController.test.ts index ee7bda042..faef327e2 100644 --- a/tests/unit/bases/TaskListFocusController.test.ts +++ b/tests/unit/bases/TaskListFocusController.test.ts @@ -260,4 +260,17 @@ describe("TaskListFocusController", () => { expect(controller.getFocusedPathForEvent(event, false, false)).toBeNull(); expect(controller.getFocusedPathForEvent(event, false, true)).toBe("remembered.md"); }); + + it("clears the remembered identity and keyboard-focus styling", () => { + const cards = [createCard("first.md"), createCard("second.md")]; + root.append(...cards); + controller.restoreAfterRender(); + cards[1].focus(); + + controller.clear(); + + expect(controller.getFocusedIdentity()).toBeNull(); + expect(cards[1].classList.contains("task-card--keyboard-focused")).toBe(false); + expect(cards.map((card) => card.tabIndex)).toEqual([0, -1]); + }); }); diff --git a/tests/unit/bases/TaskListView.keyboardActions.test.ts b/tests/unit/bases/TaskListView.keyboardActions.test.ts index eeb954e23..77ece7790 100644 --- a/tests/unit/bases/TaskListView.keyboardActions.test.ts +++ b/tests/unit/bases/TaskListView.keyboardActions.test.ts @@ -33,6 +33,11 @@ describe("TaskListView keyboard actions", () => { }); it.each([ + ["clear-focus-and-selection", "clearTaskListFocusAndSelection", null], + ["toggle-select", "toggleFocusedTaskSelection", null], + ["select-all", "selectAllVisibleTasks", null], + ["copy-task-titles", "copyTaskActionTargetTitles", null], + ["toggle-archive", "toggleTaskActionTargetsArchive", null], ["open-task-notes", "openTaskActionTargets", null], ["edit-due", "showTaskActionDateMenu", "due"], ["edit-scheduled", "showTaskActionDateMenu", "scheduled"], @@ -238,6 +243,71 @@ describe("TaskListView keyboard actions", () => { expect(updateTaskProperty).toHaveBeenNthCalledWith(2, tasks[1], "priority", "high"); }); + it("selects only tasks visible in the current filtered view", () => { + const selectAll = jest.fn(); + const enterSelectionMode = jest.fn(); + const view = { + currentVisibleTaskPaths: new Set(["visible-a.md", "visible-b.md"]), + plugin: { + taskSelectionService: { selectAll, enterSelectionMode }, + }, + }; + + (TaskListView.prototype as any).selectAllVisibleTasks.call(view); + + expect(selectAll).toHaveBeenCalledWith(["visible-a.md", "visible-b.md"]); + expect(enterSelectionMode).toHaveBeenCalled(); + }); + + it("copies resolved visible target titles as newline-delimited text", async () => { + const tasks = [ + { ...task("first.md"), title: "First title" }, + { ...task("second.md"), title: "Second title" }, + ]; + const writeText = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + const view = { + getTaskActionTargets: jest.fn(async () => tasks), + }; + + await (TaskListView.prototype as any).copyTaskActionTargetTitles.call(view); + + expect(writeText).toHaveBeenCalledWith("First title\nSecond title"); + }); + + it("toggles archive only when all resolved targets share the same state", async () => { + const tasks = [task("first.md"), task("second.md")]; + const toggleArchive = jest.fn().mockResolvedValue(undefined); + const view = { + getTaskActionTargets: jest.fn(async () => tasks), + plugin: { taskService: { toggleArchive } }, + }; + + await (TaskListView.prototype as any).toggleTaskActionTargetsArchive.call(view); + + expect(toggleArchive).toHaveBeenNthCalledWith(1, tasks[0]); + expect(toggleArchive).toHaveBeenNthCalledWith(2, tasks[1]); + }); + + it("does not toggle a mixed archive selection", async () => { + const tasks = [ + task("open.md"), + { ...task("archived.md"), archived: true }, + ]; + const toggleArchive = jest.fn(); + const view = { + getTaskActionTargets: jest.fn(async () => tasks), + plugin: { taskService: { toggleArchive } }, + }; + + await (TaskListView.prototype as any).toggleTaskActionTargetsArchive.call(view); + + expect(toggleArchive).not.toHaveBeenCalled(); + }); + it("does not delete when destructive confirmation is cancelled", async () => { const tasks = [task("first.md"), task("second.md")]; const deleteTask = jest.fn(); diff --git a/tests/unit/bases/TaskListView.keyboardSelection.test.ts b/tests/unit/bases/TaskListView.keyboardSelection.test.ts index bc7ac164f..7c4f83e6f 100644 --- a/tests/unit/bases/TaskListView.keyboardSelection.test.ts +++ b/tests/unit/bases/TaskListView.keyboardSelection.test.ts @@ -13,65 +13,57 @@ describe("TaskListView keyboard selection", () => { const toggleSelection = jest.fn(); const view = { focusController: { - getFocusedPathForEvent: jest.fn(() => "focused.md"), + getFocusedIdentity: jest.fn(() => ({ path: "focused.md", occurrence: 0 })), }, + currentVisibleTaskPaths: new Set(["focused.md"]), plugin: { taskSelectionService: { toggleSelection }, }, }; - const event = new KeyboardEvent("keydown", { - key: " ", - cancelable: true, - }); - const stopPropagation = jest.spyOn(event, "stopPropagation"); - (TaskListView.prototype as any).handleTaskListSelectionKeyDown.call(view, event); + (TaskListView.prototype as any).toggleFocusedTaskSelection.call(view); - expect(event.defaultPrevented).toBe(true); - expect(stopPropagation).toHaveBeenCalled(); expect(toggleSelection).toHaveBeenCalledWith("focused.md"); }); - it("leaves Space alone when focus is in an excluded control", () => { + it("does not toggle a remembered task that is filtered out", () => { const toggleSelection = jest.fn(); const view = { focusController: { - getFocusedPathForEvent: jest.fn(() => null), + getFocusedIdentity: jest.fn(() => ({ path: "hidden.md", occurrence: 0 })), }, + currentVisibleTaskPaths: new Set(["visible.md"]), plugin: { taskSelectionService: { toggleSelection }, }, }; - const event = new KeyboardEvent("keydown", { - key: " ", - cancelable: true, - }); - (TaskListView.prototype as any).handleTaskListSelectionKeyDown.call(view, event); + (TaskListView.prototype as any).toggleFocusedTaskSelection.call(view); - expect(event.defaultPrevented).toBe(false); expect(toggleSelection).not.toHaveBeenCalled(); }); - it("clears selection on Escape without handing focus to the Bases root", () => { + it("clears selection and focus through the configurable action", () => { + const clearSelection = jest.fn(); const exitSelectionMode = jest.fn(); + const clearFocus = jest.fn(); + const rootElement = document.createElement("div"); + rootElement.tabIndex = -1; + document.body.appendChild(rootElement); const view = { + rootElement, + focusController: { clear: clearFocus }, plugin: { - taskSelectionService: { exitSelectionMode }, + taskSelectionService: { clearSelection, exitSelectionMode }, }, }; - const event = new KeyboardEvent("keydown", { - key: "Escape", - cancelable: true, - }); - const stopPropagation = jest.spyOn(event, "stopPropagation"); - const handled = (TaskListView.prototype as any).handleTaskListEscape.call(view, event); + (TaskListView.prototype as any).clearTaskListFocusAndSelection.call(view); - expect(handled).toBe(true); - expect(event.defaultPrevented).toBe(true); - expect(stopPropagation).toHaveBeenCalled(); - expect(exitSelectionMode).toHaveBeenCalledWith(true); + expect(clearSelection).toHaveBeenCalled(); + expect(exitSelectionMode).toHaveBeenCalled(); + expect(clearFocus).toHaveBeenCalled(); + expect(document.activeElement).toBe(rootElement); }); it("does not let the inherited selection handler clear selection while a popup owns Escape", () => { diff --git a/tests/unit/bases/taskListKeyboardActions.test.ts b/tests/unit/bases/taskListKeyboardActions.test.ts index f53d7c0a2..b13376d68 100644 --- a/tests/unit/bases/taskListKeyboardActions.test.ts +++ b/tests/unit/bases/taskListKeyboardActions.test.ts @@ -31,6 +31,14 @@ describe("resolveDefaultTaskListKeyboardAction", () => { ["ArrowUp", {}, "navigate-previous"], ["Home", {}, "jump-first"], ["End", {}, "jump-last"], + ["Escape", {}, "clear-focus-and-selection"], + ["Backspace", {}, "clear-focus-and-selection"], + [" ", {}, "toggle-select"], + ["a", { ctrlKey: true }, "select-all"], + ["a", { metaKey: true }, "select-all"], + ["c", { ctrlKey: true }, "copy-task-titles"], + ["c", { metaKey: true }, "copy-task-titles"], + ["y", {}, "toggle-archive"], ["c", {}, "create-task"], ["/", {}, "focus-search"], ["Enter", {}, "edit-task"], diff --git a/tests/unit/settings/settingsPersistence.test.ts b/tests/unit/settings/settingsPersistence.test.ts index 58ef4758f..2bd31498a 100644 --- a/tests/unit/settings/settingsPersistence.test.ts +++ b/tests/unit/settings/settingsPersistence.test.ts @@ -186,6 +186,10 @@ describe("settings persistence helpers", () => { keyboardShortcuts: { navigateDown: ["J", "Arrow Down"], navigateUp: ["K", "Arrow Up"], + copyTaskTitles: ["Control+C"], + toggleSelect: ["Space"], + selectAll: ["Meta+A"], + clearFocusAndSelection: ["Backspace"], newTask: ["N"], editDueDates: ["Control+Shift+D"], deleteTasks: ["Cmd+Delete"], @@ -196,6 +200,11 @@ describe("settings persistence helpers", () => { expect(settings.taskListShortcuts["create-task"]).toEqual(["n"]); expect(settings.taskListShortcuts["navigate-next"]).toEqual(["j", "arrowdown"]); expect(settings.taskListShortcuts["navigate-previous"]).toEqual(["k", "arrowup"]); + expect(settings.taskListShortcuts["copy-task-titles"]).toEqual(["mod+c"]); + expect(settings.taskListShortcuts["toggle-select"]).toEqual(["space"]); + expect(settings.taskListShortcuts["select-all"]).toEqual(["mod+a"]); + expect(settings.taskListShortcuts["clear-focus-and-selection"]).toEqual(["backspace"]); + expect(settings.taskListShortcuts["toggle-archive"]).toEqual(["a"]); expect(settings.taskListShortcuts["edit-due"]).toEqual(["mod+shift+d"]); expect(settings.taskListShortcuts["delete-tasks"]).toEqual(["mod+delete"]); expect(shouldPersistMigratedSettings).toBe(true); From 34e54496d7e8b3430e3cb9d340984c9e0d6aad68 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Wed, 29 Jul 2026 14:22:50 -0600 Subject: [PATCH 15/55] Pressing enter in the search box returns focus to the task list --- src/bases/components/SearchBox.ts | 6 +++++- tests/unit/SearchBox.test.ts | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/bases/components/SearchBox.ts b/src/bases/components/SearchBox.ts index 79cbc931a..dd2150c32 100644 --- a/src/bases/components/SearchBox.ts +++ b/src/bases/components/SearchBox.ts @@ -134,7 +134,11 @@ export class SearchBox { * Handle keydown event */ private handleKeydown = (e: KeyboardEvent): void => { - if (e.key === 'Escape') { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + this.onDismiss?.(); + } else if (e.key === 'Escape') { e.preventDefault(); this.clear(); // Trigger search with empty term diff --git a/tests/unit/SearchBox.test.ts b/tests/unit/SearchBox.test.ts index 33b6fa53d..cd4b4f092 100644 --- a/tests/unit/SearchBox.test.ts +++ b/tests/unit/SearchBox.test.ts @@ -222,6 +222,27 @@ describe('SearchBox', () => { expect(onDismiss).toHaveBeenCalled(); }); + it('should return focus ownership without clearing the query when Enter is pressed', () => { + const onDismiss = jest.fn(); + searchBox = new SearchBox(container, onSearchMock, 300, onDismiss); + searchBox.render(); + const input = container.querySelector('.tn-search-box__input') as HTMLInputElement; + input.value = 'visible tasks'; + const event = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + }); + const stopPropagation = jest.spyOn(event, 'stopPropagation'); + + input.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(stopPropagation).toHaveBeenCalled(); + expect(input.value).toBe('visible tasks'); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + it('should clear input when clear button clicked', () => { searchBox = new SearchBox(container, onSearchMock, 300); searchBox.render(); From 669690b3575a35259fcbb3edebad98875d791008 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Wed, 29 Jul 2026 15:18:53 -0600 Subject: [PATCH 16/55] Allow task view hotkeys to override Obsidian globals The active Task List now pushes a child keyboard scope containing its configured shortcuts. This gives bindings such as Ctrl+B and Ctrl+D precedence over global Bold/Delete Paragraph commands while: - The Task List leaf is active. - Keyboard focus belongs to a non-editable part of that view. - No menu, modal, search input, or editor owns input. The scope is popped when the leaf deactivates, focus enters an editable control, or the view unloads. --- src/bases/TaskListFocusController.ts | 5 +- src/bases/TaskListInputOwnershipController.ts | 7 +- src/bases/TaskListView.ts | 119 +++++++++--- src/bases/taskListKeyboardActions.ts | 49 +++++ .../bases/TaskListFocusController.test.ts | 17 ++ .../TaskListView.keyboardActions.test.ts | 177 ++++++++++++++++++ .../TaskListView.keyboardSelection.test.ts | 1 + .../bases/taskListKeyboardActions.test.ts | 11 ++ 8 files changed, 358 insertions(+), 28 deletions(-) diff --git a/src/bases/TaskListFocusController.ts b/src/bases/TaskListFocusController.ts index 9d217b59a..e05e6d3b7 100644 --- a/src/bases/TaskListFocusController.ts +++ b/src/bases/TaskListFocusController.ts @@ -147,9 +147,8 @@ export class TaskListFocusController { allowRememberedFallback = false ): string | null { if ( - event.defaultPrevented || - (!allowModifiers && - (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)) + !allowModifiers && + (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) ) { return null; } diff --git a/src/bases/TaskListInputOwnershipController.ts b/src/bases/TaskListInputOwnershipController.ts index dce246639..cbf53118c 100644 --- a/src/bases/TaskListInputOwnershipController.ts +++ b/src/bases/TaskListInputOwnershipController.ts @@ -18,10 +18,13 @@ export class TaskListInputOwnershipController { if (event.isComposing || event.key === "Process") return false; if (this.suspendedForOverlay) return false; - const target = event.target; + return this.canOwnKeyboardTarget(event.target); + } + + canOwnKeyboardTarget(target: EventTarget | null): boolean { + if (this.suspendedForOverlay) return false; if (!(target instanceof Element) || target.closest(EDITABLE_SELECTOR)) return false; if (this.getOverlayFromTarget(target) || this.hasOpenOverlay()) return false; - return this.viewRoot.contains(target); } diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index cfb7e4ff8..d61a0eb42 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion -- Legacy Bases view rendering narrows DOM references through lifecycle checks. */ -import { Menu, Notice, TFile, setIcon } from "obsidian"; +import { Menu, Notice, Scope, TFile, setIcon } from "obsidian"; import type { BasesView, BasesViewFactory } from "obsidian"; import TaskNotesPlugin from "../main"; import { BasesViewBase } from "./BasesViewBase"; @@ -73,6 +73,8 @@ import { TaskListFocusController } from "./TaskListFocusController"; import { resolveTaskListTargetPaths } from "./taskListTargetResolver"; import { resolveTaskListKeyboardAction, + taskListShortcutToScopeBinding, + TASK_LIST_KEYBOARD_ACTIONS, type TaskListKeyboardAction, } from "./taskListKeyboardActions"; import { addTagsToList, parseTaskTagInput } from "../utils/taskTagList"; @@ -156,6 +158,8 @@ export class TaskListView extends BasesViewBase { private containerListenersRegistered = false; private focusController: TaskListFocusController | null = null; private inputOwnershipController: TaskListInputOwnershipController | null = null; + private taskListShortcutScope: Scope | null = null; + private taskListLeafActive = false; private virtualScroller: VirtualScroller | null = null; // Can render TaskInfo or group headers private useVirtualScrolling = false; private collapsedGroups = new Set(); // Track collapsed group keys @@ -228,6 +232,7 @@ export class TaskListView extends BasesViewBase { this.registerGroupContextMenuListeners(); this.registerEvent( this.plugin.app.workspace.on("active-leaf-change", (leaf) => { + this.syncTaskListShortcutScopeForLeaf(leaf); this.restoreFocusForActivatedLeaf(leaf); }) ); @@ -242,21 +247,29 @@ export class TaskListView extends BasesViewBase { ) { const win = this.containerEl.ownerDocument.defaultView ?? window; win.setTimeout(() => { - this.restoreFocusForActivatedLeaf( - this.plugin.app.workspace.getMostRecentLeaf() - ); + const leaf = this.plugin.app.workspace.getMostRecentLeaf(); + this.syncTaskListShortcutScopeForLeaf(leaf); + this.restoreFocusForActivatedLeaf(leaf); }, 0); } }, true ); + this.syncTaskListShortcutScopeForLeaf( + this.plugin.app.workspace.getMostRecentLeaf() + ); + } + + private isTaskListLeaf( + leaf: { view?: { containerEl?: HTMLElement } } | null + ): boolean { + return Boolean(leaf?.view?.containerEl?.contains(this.containerEl)); } private restoreFocusForActivatedLeaf( leaf: { view?: { containerEl?: HTMLElement } } | null ): void { - const leafContainer = leaf?.view?.containerEl; - if (!leafContainer?.contains(this.containerEl)) return; + if (!this.isTaskListLeaf(leaf)) return; const win = this.containerEl.ownerDocument.defaultView ?? window; win.setTimeout(() => { @@ -266,6 +279,53 @@ export class TaskListView extends BasesViewBase { }, 0); } + private syncTaskListShortcutScopeForLeaf( + leaf: { view?: { containerEl?: HTMLElement } } | null + ): void { + this.taskListLeafActive = this.isTaskListLeaf(leaf); + this.syncTaskListShortcutScopeForFocusTarget( + this.containerEl.ownerDocument.activeElement + ); + } + + private syncTaskListShortcutScopeForFocusTarget(target: EventTarget | null): void { + if ( + this.taskListLeafActive && + this.inputOwnershipController?.canOwnKeyboardTarget(target) + ) { + this.activateTaskListShortcutScope(); + return; + } + this.deactivateTaskListShortcutScope(); + } + + private activateTaskListShortcutScope(): void { + if (this.taskListShortcutScope) return; + + const scope = new Scope(this.plugin.app.scope); + const shortcuts = this.plugin.settings.taskListShortcuts; + for (const action of TASK_LIST_KEYBOARD_ACTIONS) { + for (const shortcut of shortcuts[action]) { + const binding = taskListShortcutToScopeBinding(shortcut); + if (!binding) continue; + scope.register(binding.modifiers, binding.key, (event) => { + if (!this.taskListLeafActive) return; + if (!this.handleTaskListKeyDown(event, true)) return; + return false; + }); + } + } + + this.taskListShortcutScope = scope; + this.plugin.app.keymap.pushScope(scope); + } + + private deactivateTaskListShortcutScope(): void { + if (!this.taskListShortcutScope) return; + this.plugin.app.keymap.popScope(this.taskListShortcutScope); + this.taskListShortcutScope = null; + } + /** * Register contextmenu listeners for group collapse actions. * - Right-click on a primary group header → expand/collapse branch @@ -2195,6 +2255,8 @@ export class TaskListView extends BasesViewBase { onunload(): void { // Component.register() calls will be automatically cleaned up (including search cleanup) // We just need to clean up view-specific state + this.taskListLeafActive = false; + this.deactivateTaskListShortcutScope(); this.unregisterContainerListeners(); this.destroyVirtualScroller(); this.inputOwnershipController?.destroy(); @@ -2350,20 +2412,23 @@ export class TaskListView extends BasesViewBase { this.registerDomEvent(this.itemsContainer, "pointerdown", (event: PointerEvent) => { this.focusController?.handlePointerDown(event); }); - this.registerDomEvent(this.itemsContainer, "keydown", (event: KeyboardEvent) => { - this.handleTaskListKeyDown(event); - }); if (this.rootElement) { - this.registerDomEvent(this.rootElement, "keydown", (event: KeyboardEvent) => { - if (event.defaultPrevented || this.itemsContainer?.contains(event.target as Node)) { - return; - } - this.handleTaskListKeyDown(event, true); - }); + // Resolve view-local shortcuts during capture so Obsidian commands such + // as Ctrl+B/Ctrl+D cannot stop propagation before the task list sees a + // user-configured chord. + this.registerDomEvent( + this.rootElement, + "keydown", + (event: KeyboardEvent) => { + this.handleTaskListRootKeyDown(event); + }, + true + ); } const doc = this.itemsContainer.ownerDocument; this.registerDomEvent(doc, "focusin", (event: FocusEvent) => { this.inputOwnershipController?.handleDocumentFocusIn(event); + this.syncTaskListShortcutScopeForFocusTarget(event.target); }); this.registerDomEvent(doc, "pointerdown", (event: PointerEvent) => { this.inputOwnershipController?.handleOverlayInteraction(event); @@ -2381,12 +2446,18 @@ export class TaskListView extends BasesViewBase { this.containerListenersRegistered = true; } + private handleTaskListRootKeyDown(event: KeyboardEvent): void { + const eventStartedInTaskItems = + this.itemsContainer?.contains(event.target as Node) ?? false; + this.handleTaskListKeyDown(event, !eventStartedInTaskItems); + } + private handleTaskListKeyDown( event: KeyboardEvent, allowRememberedFocus = false - ): void { - if (!this.inputOwnershipController?.canHandleListKeyDown(event)) return; - this.handleTaskListActionKeyDown(event, allowRememberedFocus); + ): boolean { + if (!this.inputOwnershipController?.canHandleListKeyDown(event)) return false; + return this.handleTaskListActionKeyDown(event, allowRememberedFocus); } protected canHandleSelectionKeyDown(event: KeyboardEvent): boolean { @@ -2404,12 +2475,12 @@ export class TaskListView extends BasesViewBase { private handleTaskListActionKeyDown( event: KeyboardEvent, allowRememberedFocus = false - ): void { + ): boolean { const action = resolveTaskListKeyboardAction( event, this.plugin?.settings?.taskListShortcuts ); - if (!action) return; + if (!action) return false; const focusedPath = this.focusController?.getFocusedPathForEvent( event, true, @@ -2420,7 +2491,7 @@ export class TaskListView extends BasesViewBase { action !== "clear-focus-and-selection" && action !== "select-all" ) { - return; + return false; } const navigationDirections = { "navigate-next": "next", @@ -2429,11 +2500,12 @@ export class TaskListView extends BasesViewBase { "jump-last": "last", } as const; if (action in navigationDirections) { - this.focusController?.moveFocus( + return ( + this.focusController?.moveFocus( event, navigationDirections[action as keyof typeof navigationDirections] + ) ?? false ); - return; } event.preventDefault(); @@ -2455,6 +2527,7 @@ export class TaskListView extends BasesViewBase { this.inputOwnershipController?.noteOverlayOpening(); } void this.executeTaskListAction(action); + return true; } /** diff --git a/src/bases/taskListKeyboardActions.ts b/src/bases/taskListKeyboardActions.ts index da493688e..cff0e44f4 100644 --- a/src/bases/taskListKeyboardActions.ts +++ b/src/bases/taskListKeyboardActions.ts @@ -1,3 +1,5 @@ +import type { Modifier } from "obsidian"; + export const TASK_LIST_KEYBOARD_ACTIONS = [ "navigate-next", "navigate-previous", @@ -26,6 +28,11 @@ export const TASK_LIST_KEYBOARD_ACTIONS = [ export type TaskListKeyboardAction = (typeof TASK_LIST_KEYBOARD_ACTIONS)[number]; export type TaskListShortcutMap = Record; +export type TaskListScopeBinding = { + modifiers: Modifier[]; + key: string; +}; + export const DEFAULT_TASK_LIST_SHORTCUTS: TaskListShortcutMap = { "navigate-next": ["arrowdown"], "navigate-previous": ["arrowup"], @@ -199,6 +206,48 @@ export function resolveTaskListKeyboardAction( return null; } +export function taskListShortcutToScopeBinding( + shortcut: string +): TaskListScopeBinding | null { + const normalized = normalizeTaskListShortcut(shortcut); + if (!normalized) return null; + + const parts = normalized.split("+"); + const keyPart = parts.pop(); + if (!keyPart) return null; + + const modifierNames: Record = { + mod: "Mod", + ctrl: "Ctrl", + meta: "Meta", + alt: "Alt", + shift: "Shift", + }; + const modifiers: Modifier[] = []; + for (const part of parts) { + const modifier = modifierNames[part]; + if (!modifier) return null; + modifiers.push(modifier); + } + + const keyNames: Record = { + slash: "/", + plus: "+", + space: " ", + enter: "Enter", + delete: "Delete", + escape: "Escape", + home: "Home", + end: "End", + arrowdown: "ArrowDown", + arrowup: "ArrowUp", + }; + return { + modifiers, + key: keyNames[keyPart] ?? keyPart, + }; +} + /** Kept as a compatibility alias for tests and callers from the earlier port slices. */ export const resolveDefaultTaskListKeyboardAction = resolveTaskListKeyboardAction; diff --git a/tests/unit/bases/TaskListFocusController.test.ts b/tests/unit/bases/TaskListFocusController.test.ts index faef327e2..1bb217224 100644 --- a/tests/unit/bases/TaskListFocusController.test.ts +++ b/tests/unit/bases/TaskListFocusController.test.ts @@ -246,6 +246,23 @@ describe("TaskListFocusController", () => { expect(controller.getFocusedPathForEvent(event, true)).toBe("focused.md"); }); + it("resolves a modified chord after an upstream handler prevented its browser default", () => { + const card = createCard("focused.md"); + root.appendChild(card); + controller.restoreAfterRender(); + card.focus(); + const event = new KeyboardEvent("keydown", { + key: "k", + ctrlKey: true, + cancelable: true, + }); + Object.defineProperty(event, "target", { value: card }); + event.preventDefault(); + + expect(event.defaultPrevented).toBe(true); + expect(controller.getFocusedPathForEvent(event, true)).toBe("focused.md"); + }); + it("uses remembered focus for non-interactive events from the view shell", () => { const card = createCard("remembered.md"); root.appendChild(card); diff --git a/tests/unit/bases/TaskListView.keyboardActions.test.ts b/tests/unit/bases/TaskListView.keyboardActions.test.ts index 77ece7790..758870f2a 100644 --- a/tests/unit/bases/TaskListView.keyboardActions.test.ts +++ b/tests/unit/bases/TaskListView.keyboardActions.test.ts @@ -1,6 +1,8 @@ import { showConfirmationModal } from "../../../src/modals/ConfirmationModal"; import { TaskListView } from "../../../src/bases/TaskListView"; import type { TaskInfo } from "../../../src/types"; +import { normalizeTaskListShortcutMap } from "../../../src/bases/taskListKeyboardActions"; +import { Scope } from "obsidian"; jest.mock( "tasknotes-nlp-core", @@ -192,6 +194,181 @@ describe("TaskListView keyboard actions", () => { expect(executeTaskListAction).toHaveBeenCalledWith("edit-due"); }); + it("routes a prevented modifier chord from the active view shell", () => { + const executeTaskListAction = jest.fn(); + const event = new KeyboardEvent("keydown", { + key: "a", + ctrlKey: true, + cancelable: true, + }); + event.preventDefault(); + const view = { + plugin: { + settings: { + taskListShortcuts: { + "select-all": ["mod+a"], + }, + }, + }, + focusController: { + getFocusedPathForEvent: jest.fn(() => null), + }, + executeTaskListAction, + }; + + (TaskListView.prototype as any).handleTaskListActionKeyDown.call( + view, + event, + true + ); + + expect(executeTaskListAction).toHaveBeenCalledWith("select-all"); + }); + + it("does not discard a prevented chord before shell shortcut routing", () => { + const root = document.createElement("div"); + const itemsContainer = document.createElement("div"); + root.appendChild(itemsContainer); + const event = new KeyboardEvent("keydown", { + key: "c", + metaKey: true, + cancelable: true, + }); + Object.defineProperty(event, "target", { value: root }); + event.preventDefault(); + const handleTaskListKeyDown = jest.fn(); + const view = { itemsContainer, handleTaskListKeyDown }; + + (TaskListView.prototype as any).handleTaskListRootKeyDown.call(view, event); + + expect(handleTaskListKeyDown).toHaveBeenCalledWith(event, true); + }); + + it("routes card chords from the root without using remembered-focus fallback", () => { + const root = document.createElement("div"); + const itemsContainer = document.createElement("div"); + const card = document.createElement("div"); + itemsContainer.appendChild(card); + root.appendChild(itemsContainer); + const event = new KeyboardEvent("keydown", { + key: "b", + ctrlKey: true, + }); + Object.defineProperty(event, "target", { value: card }); + const handleTaskListKeyDown = jest.fn(); + const view = { itemsContainer, handleTaskListKeyDown }; + + (TaskListView.prototype as any).handleTaskListRootKeyDown.call(view, event); + + expect(handleTaskListKeyDown).toHaveBeenCalledWith(event, false); + }); + + it("registers task-list shortcut routing in the capture phase", () => { + const rootElement = document.createElement("div"); + const itemsContainer = document.createElement("div"); + rootElement.appendChild(itemsContainer); + const registerDomEvent = jest.fn(); + const view = { + rootElement, + itemsContainer, + containerListenersRegistered: false, + handleItemClick: jest.fn(), + focusController: null, + inputOwnershipController: null, + registerDomEvent, + }; + + (TaskListView.prototype as any).registerContainerListeners.call(view); + + expect(registerDomEvent).toHaveBeenCalledWith( + rootElement, + "keydown", + expect.any(Function), + true + ); + }); + + it("pushes an Obsidian child scope for configured view-local chords", () => { + const registerSpy = jest.spyOn(Scope.prototype, "register"); + const pushScope = jest.fn(); + const popScope = jest.fn(); + const handleTaskListKeyDown = jest.fn(() => true); + const view = { + taskListShortcutScope: null, + taskListLeafActive: true, + plugin: { + settings: { + taskListShortcuts: normalizeTaskListShortcutMap({ + "select-all": ["Ctrl+B"], + "copy-task-titles": ["Ctrl+D"], + }), + }, + app: { + scope: {}, + keymap: { pushScope, popScope }, + }, + }, + handleTaskListKeyDown, + }; + + (TaskListView.prototype as any).activateTaskListShortcutScope.call(view); + + expect(pushScope).toHaveBeenCalledTimes(1); + const scope = view.taskListShortcutScope; + expect(registerSpy).toHaveBeenCalledWith( + ["Mod"], + "b", + expect.any(Function) + ); + expect(registerSpy).toHaveBeenCalledWith( + ["Mod"], + "d", + expect.any(Function) + ); + + const ctrlBHandler = registerSpy.mock.calls.find( + ([modifiers, key]) => modifiers[0] === "Mod" && key === "b" + )?.[2] as (event: KeyboardEvent) => unknown; + const event = new KeyboardEvent("keydown", { key: "b", ctrlKey: true }); + expect(ctrlBHandler(event)).toBe(false); + expect(handleTaskListKeyDown).toHaveBeenCalledWith(event, true); + + (TaskListView.prototype as any).deactivateTaskListShortcutScope.call(view); + expect(popScope).toHaveBeenCalledTimes(1); + expect(popScope.mock.calls[0][0]).toBe(scope); + expect(view.taskListShortcutScope).toBeNull(); + registerSpy.mockRestore(); + }); + + it("does not claim a scoped chord after the task-list leaf is deactivated", () => { + const registerSpy = jest.spyOn(Scope.prototype, "register"); + const view = { + taskListShortcutScope: null, + taskListLeafActive: true, + plugin: { + settings: { + taskListShortcuts: normalizeTaskListShortcutMap({ + "select-all": ["Ctrl+B"], + }), + }, + app: { + scope: {}, + keymap: { pushScope: jest.fn(), popScope: jest.fn() }, + }, + }, + handleTaskListKeyDown: jest.fn(() => true), + }; + (TaskListView.prototype as any).activateTaskListShortcutScope.call(view); + const handler = registerSpy.mock.calls.find( + ([modifiers, key]) => modifiers[0] === "Mod" && key === "b" + )?.[2] as (event: KeyboardEvent) => unknown; + view.taskListLeafActive = false; + + expect(handler(new KeyboardEvent("keydown", { key: "b", ctrlKey: true }))).toBeUndefined(); + expect(view.handleTaskListKeyDown).not.toHaveBeenCalled(); + registerSpy.mockRestore(); + }); + it("creates search controls on demand before focusing them", () => { const rootElement = document.createElement("div"); const focus = jest.fn(); diff --git a/tests/unit/bases/TaskListView.keyboardSelection.test.ts b/tests/unit/bases/TaskListView.keyboardSelection.test.ts index 7c4f83e6f..666c8dc03 100644 --- a/tests/unit/bases/TaskListView.keyboardSelection.test.ts +++ b/tests/unit/bases/TaskListView.keyboardSelection.test.ts @@ -123,6 +123,7 @@ describe("TaskListView keyboard selection", () => { containerEl, rootElement, focusController: { restoreFocusedElement }, + isTaskListLeaf: (TaskListView.prototype as any).isTaskListLeaf, }; (TaskListView.prototype as any).restoreFocusForActivatedLeaf.call(view, { diff --git a/tests/unit/bases/taskListKeyboardActions.test.ts b/tests/unit/bases/taskListKeyboardActions.test.ts index b13376d68..55925bfd4 100644 --- a/tests/unit/bases/taskListKeyboardActions.test.ts +++ b/tests/unit/bases/taskListKeyboardActions.test.ts @@ -8,6 +8,7 @@ import { resolveDefaultTaskListKeyboardAction, resolveTaskListKeyboardAction, replaceTaskListShortcut, + taskListShortcutToScopeBinding, } from "../../../src/bases/taskListKeyboardActions"; function key( @@ -138,4 +139,14 @@ describe("resolveDefaultTaskListKeyboardAction", () => { expect(formatTaskListShortcut("mod+shift+enter", false)).toBe("Ctrl+Shift+Enter"); expect(formatTaskListShortcut("mod+shift+enter", true)).toBe("⌘⇧Enter"); }); + + it.each([ + ["mod+b", { modifiers: ["Mod"], key: "b" }], + ["mod+shift+d", { modifiers: ["Mod", "Shift"], key: "d" }], + ["space", { modifiers: [], key: " " }], + ["shift+plus", { modifiers: ["Shift"], key: "+" }], + ["arrowdown", { modifiers: [], key: "ArrowDown" }], + ])("converts %s to an Obsidian scope binding", (shortcut, expected) => { + expect(taskListShortcutToScopeBinding(shortcut)).toEqual(expected); + }); }); From f44bae428cfb8e85304220bcb056321e8be11b73 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Wed, 29 Jul 2026 15:28:17 -0600 Subject: [PATCH 17/55] Clearing tasklist selection does not disable hotkeys --- src/bases/TaskListView.ts | 5 ++-- .../TaskListView.keyboardSelection.test.ts | 28 ++++++++++++++++--- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index d61a0eb42..c8276aa85 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -2629,8 +2629,9 @@ export class TaskListView extends BasesViewBase { const selectionService = this.plugin.taskSelectionService; selectionService?.clearSelection(); selectionService?.exitSelectionMode(); - this.focusController?.clear(); - this.rootElement?.focus({ preventScroll: true }); + if (!this.focusController?.restoreFocusedElement()) { + this.rootElement?.focus({ preventScroll: true }); + } } private toggleFocusedTaskSelection(): void { diff --git a/tests/unit/bases/TaskListView.keyboardSelection.test.ts b/tests/unit/bases/TaskListView.keyboardSelection.test.ts index 666c8dc03..e34889304 100644 --- a/tests/unit/bases/TaskListView.keyboardSelection.test.ts +++ b/tests/unit/bases/TaskListView.keyboardSelection.test.ts @@ -43,16 +43,16 @@ describe("TaskListView keyboard selection", () => { expect(toggleSelection).not.toHaveBeenCalled(); }); - it("clears selection and focus through the configurable action", () => { + it("clears selection while preserving task focus for subsequent shortcuts", () => { const clearSelection = jest.fn(); const exitSelectionMode = jest.fn(); - const clearFocus = jest.fn(); + const restoreFocusedElement = jest.fn(() => true); const rootElement = document.createElement("div"); rootElement.tabIndex = -1; document.body.appendChild(rootElement); const view = { rootElement, - focusController: { clear: clearFocus }, + focusController: { restoreFocusedElement }, plugin: { taskSelectionService: { clearSelection, exitSelectionMode }, }, @@ -62,7 +62,27 @@ describe("TaskListView keyboard selection", () => { expect(clearSelection).toHaveBeenCalled(); expect(exitSelectionMode).toHaveBeenCalled(); - expect(clearFocus).toHaveBeenCalled(); + expect(restoreFocusedElement).toHaveBeenCalled(); + expect(document.activeElement).not.toBe(rootElement); + }); + + it("focuses the task-list root when no task focus can be restored after clearing selection", () => { + const rootElement = document.createElement("div"); + rootElement.tabIndex = -1; + document.body.appendChild(rootElement); + const view = { + rootElement, + focusController: { restoreFocusedElement: jest.fn(() => false) }, + plugin: { + taskSelectionService: { + clearSelection: jest.fn(), + exitSelectionMode: jest.fn(), + }, + }, + }; + + (TaskListView.prototype as any).clearTaskListFocusAndSelection.call(view); + expect(document.activeElement).toBe(rootElement); }); From a64971e68f2c05e35f4c46c90124003270cf70c4 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Wed, 29 Jul 2026 15:29:45 -0600 Subject: [PATCH 18/55] Handle 'Esc' key with scope during hotkey capture - Hotkey recording now pushes a temporary Obsidian Scope that consumes Escape, cancels recording, and prevents the underlying Settings dialog from closing. Cleanup is idempotent and also runs during rerenders. --- src/settings/tabs/keyboardShortcutsTab.ts | 51 ++++++++++++++++++- .../settings/keyboardShortcutsTab.test.ts | 36 +++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 tests/unit/settings/keyboardShortcutsTab.test.ts diff --git a/src/settings/tabs/keyboardShortcutsTab.ts b/src/settings/tabs/keyboardShortcutsTab.ts index 6bbb2d754..69fb8ac05 100644 --- a/src/settings/tabs/keyboardShortcutsTab.ts +++ b/src/settings/tabs/keyboardShortcutsTab.ts @@ -1,4 +1,4 @@ -import { Platform, Setting } from "obsidian"; +import { Platform, Scope, Setting } from "obsidian"; import type TaskNotesPlugin from "../../main"; import { DEFAULT_TASK_LIST_SHORTCUTS, @@ -14,6 +14,30 @@ import { createSettingGroup } from "../components/settingHelpers"; import type { TranslationKey } from "../../i18n"; import { showConfirmationModal } from "../../modals/ConfirmationModal"; +const activeCaptureCleanup = new WeakMap void>(); + +export function pushKeyboardShortcutCaptureScope( + plugin: TaskNotesPlugin, + onEscape: () => void +): () => void { + const captureScope = new Scope(plugin.app.scope); + let active = true; + const stop = () => { + if (!active) return; + active = false; + plugin.app.keymap.popScope(captureScope); + }; + captureScope.register([], "Escape", (event) => { + event.preventDefault(); + event.stopPropagation(); + stop(); + onEscape(); + return false; + }); + plugin.app.keymap.pushScope(captureScope); + return stop; +} + function actionKey(action: TaskListKeyboardAction): TranslationKey { return `settings.keyboardShortcuts.actions.${action}`; } @@ -23,6 +47,7 @@ export function renderKeyboardShortcutsTab( plugin: TaskNotesPlugin, save: () => void ): void { + activeCaptureCleanup.get(container)?.(); container.empty(); const translate = (key: TranslationKey, params?: Record) => plugin.i18n.translate(key, params); @@ -71,15 +96,32 @@ export function renderKeyboardShortcutsTab( .setButtonText(translate("settings.keyboardShortcuts.add")) .setTooltip(translate("settings.keyboardShortcuts.captureHint")) .onClick(() => { + activeCaptureCleanup.get(container)?.(); const buttonEl = button.buttonEl; buttonEl.setText(translate("settings.keyboardShortcuts.recording")); buttonEl.addClass("mod-cta"); + let stopped = false; + let popCaptureScope = () => {}; + const stopCapture = () => { + if (stopped) return; + stopped = true; + buttonEl.removeEventListener("keydown", captureListener); + popCaptureScope(); + if (activeCaptureCleanup.get(container) === stopCapture) { + activeCaptureCleanup.delete(container); + } + }; const capture = async (event: KeyboardEvent) => { event.preventDefault(); event.stopPropagation(); + if (event.key === "Escape") { + stopCapture(); + renderKeyboardShortcutsTab(container, plugin, save); + return; + } const shortcut = keyboardEventToTaskListShortcut(event); if (!shortcut) return; - buttonEl.removeEventListener("keydown", captureListener); + stopCapture(); if (!shortcuts[action].includes(shortcut)) { const owners = findTaskListShortcutOwners( shortcuts, @@ -127,6 +169,11 @@ export function renderKeyboardShortcutsTab( }; const captureListener = (event: KeyboardEvent) => void capture(event); buttonEl.addEventListener("keydown", captureListener); + popCaptureScope = pushKeyboardShortcutCaptureScope(plugin, () => { + stopCapture(); + renderKeyboardShortcutsTab(container, plugin, save); + }); + activeCaptureCleanup.set(container, stopCapture); buttonEl.focus(); }); }); diff --git a/tests/unit/settings/keyboardShortcutsTab.test.ts b/tests/unit/settings/keyboardShortcutsTab.test.ts new file mode 100644 index 000000000..3a3580779 --- /dev/null +++ b/tests/unit/settings/keyboardShortcutsTab.test.ts @@ -0,0 +1,36 @@ +import { Scope } from "obsidian"; +import { pushKeyboardShortcutCaptureScope } from "../../../src/settings/tabs/keyboardShortcutsTab"; + +describe("keyboard shortcut capture scope", () => { + it("swallows Escape, cancels capture, and pops itself", () => { + let escapeHandler: (event: KeyboardEvent) => boolean; + jest.spyOn(Scope.prototype, "register").mockImplementation( + (_modifiers, _key, handler) => { + escapeHandler = handler as (event: KeyboardEvent) => boolean; + } + ); + const app = { + scope: new Scope(), + keymap: { + pushScope: jest.fn(), + popScope: jest.fn(), + }, + }; + const onEscape = jest.fn(); + const stop = pushKeyboardShortcutCaptureScope({ app } as any, onEscape); + const scope = app.keymap.pushScope.mock.calls[0][0] as Scope; + const event = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + } as unknown as KeyboardEvent; + + expect(escapeHandler!(event)).toBe(false); + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + expect(onEscape).toHaveBeenCalledTimes(1); + expect(app.keymap.popScope.mock.calls[0][0] === scope).toBe(true); + + stop(); + expect(app.keymap.popScope).toHaveBeenCalledTimes(1); + }); +}); From acded60020b19e6a17b413e9e0b416687d5bdb48 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Wed, 29 Jul 2026 15:54:31 -0600 Subject: [PATCH 19/55] Task hotkey editor polish, handles "Esc" as a task hotkey - Escape is now captured as a normal shortcut and forwarded to the confirmation flow without closing Settings. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New shortcuts require confirmation before being saved. - “Add shortcut” is now a circle-plus icon. - Reset appears before Add. - Existing bindings display a circle-x icon beside the value; it turns red on hover. - Added scoped styling and updated regression coverage. --- src/i18n/resources/en.ts | 3 + src/settings/tabs/keyboardShortcutsTab.ts | 107 ++++++++++++------ styles/settings-view.css | 31 +++++ .../settings/keyboardShortcutsTab.test.ts | 2 +- 4 files changed, 105 insertions(+), 38 deletions(-) diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 4d8b0ca70..2a6f02170 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -522,6 +522,9 @@ export const en: TranslationTree = { remove: "Remove shortcut", captureHint: "Click, then press the desired key combination", recording: "Press keys…", + confirmTitle: "Add shortcut?", + confirmMessage: "Add {shortcut} to {action}?", + confirm: "Add", resetAction: "Reset this action", resetAll: "Reset all shortcuts", resetAllDescription: "Restore every task-list shortcut to its default binding.", diff --git a/src/settings/tabs/keyboardShortcutsTab.ts b/src/settings/tabs/keyboardShortcutsTab.ts index 69fb8ac05..11f6a40ab 100644 --- a/src/settings/tabs/keyboardShortcutsTab.ts +++ b/src/settings/tabs/keyboardShortcutsTab.ts @@ -1,4 +1,4 @@ -import { Platform, Scope, Setting } from "obsidian"; +import { Platform, Scope, Setting, setIcon } from "obsidian"; import type TaskNotesPlugin from "../../main"; import { DEFAULT_TASK_LIST_SHORTCUTS, @@ -18,7 +18,7 @@ const activeCaptureCleanup = new WeakMap void>(); export function pushKeyboardShortcutCaptureScope( plugin: TaskNotesPlugin, - onEscape: () => void + onEscape: (event: KeyboardEvent) => void ): () => void { const captureScope = new Scope(plugin.app.scope); let active = true; @@ -31,7 +31,7 @@ export function pushKeyboardShortcutCaptureScope( event.preventDefault(); event.stopPropagation(); stop(); - onEscape(); + onEscape(event); return false; }); plugin.app.keymap.pushScope(captureScope); @@ -64,6 +64,7 @@ export function renderKeyboardShortcutsTab( for (const action of TASK_LIST_KEYBOARD_ACTIONS) { group.addSetting((setting) => { setting.setName(translate(actionKey(action))); + setting.settingEl.addClass("tasknotes-settings__shortcut-setting"); const actionConflicts = shortcuts[action] .filter((shortcut) => conflicts.has(shortcut)) .map((shortcut) => formatTaskListShortcut(shortcut, Platform.isMacOS)); @@ -77,23 +78,50 @@ export function renderKeyboardShortcutsTab( if (actionConflicts.length) setting.settingEl.addClass("has-conflict"); for (const shortcut of shortcuts[action]) { - setting.addButton((button) => { - button - .setButtonText(formatTaskListShortcut(shortcut, Platform.isMacOS)) - .setTooltip(translate("settings.keyboardShortcuts.remove")) - .onClick(() => { - plugin.settings.taskListShortcuts[action] = shortcuts[action].filter( - (value) => value !== shortcut - ); - save(); - renderKeyboardShortcutsTab(container, plugin, save); - }); + const shortcutButton = setting.controlEl.createEl("button", { + cls: "tasknotes-settings__shortcut-binding setting-hotkey", + attr: { + type: "button", + "aria-label": translate("settings.keyboardShortcuts.remove"), + }, + }); + shortcutButton.createSpan({ + cls: "tasknotes-settings__shortcut-value", + text: formatTaskListShortcut(shortcut, Platform.isMacOS), + }); + const removeIcon = shortcutButton.createSpan({ + cls: "tasknotes-settings__shortcut-remove-icon", + }); + setIcon(removeIcon, "circle-x"); + shortcutButton.addEventListener("click", () => { + plugin.settings.taskListShortcuts[action] = shortcuts[action].filter( + (value) => value !== shortcut + ); + save(); + renderKeyboardShortcutsTab(container, plugin, save); }); } + setting.addExtraButton((button) => { + button + .setIcon("rotate-ccw") + .setTooltip(translate("settings.keyboardShortcuts.resetAction")) + .onClick(() => { + plugin.settings.taskListShortcuts[action] = [ + ...DEFAULT_TASK_LIST_SHORTCUTS[action], + ]; + save(); + renderKeyboardShortcutsTab(container, plugin, save); + }); + }); + setting.addButton((button) => { + button.buttonEl.addClass( + "tasknotes-settings__shortcut-add", + "clickable-icon" + ); + setIcon(button.buttonEl, "circle-plus"); button - .setButtonText(translate("settings.keyboardShortcuts.add")) .setTooltip(translate("settings.keyboardShortcuts.captureHint")) .onClick(() => { activeCaptureCleanup.get(container)?.(); @@ -114,11 +142,6 @@ export function renderKeyboardShortcutsTab( const capture = async (event: KeyboardEvent) => { event.preventDefault(); event.stopPropagation(); - if (event.key === "Escape") { - stopCapture(); - renderKeyboardShortcutsTab(container, plugin, save); - return; - } const shortcut = keyboardEventToTaskListShortcut(event); if (!shortcut) return; stopCapture(); @@ -158,6 +181,29 @@ export function renderKeyboardShortcutsTab( plugin.settings.taskListShortcuts = replaceTaskListShortcut(shortcuts, action, shortcut); } else { + const confirmed = await showConfirmationModal(plugin.app, { + title: translate( + "settings.keyboardShortcuts.confirmTitle" + ), + message: translate( + "settings.keyboardShortcuts.confirmMessage", + { + shortcut: formatTaskListShortcut( + shortcut, + Platform.isMacOS + ), + action: translate(actionKey(action)), + } + ), + confirmText: translate( + "settings.keyboardShortcuts.confirm" + ), + cancelText: translate("common.cancel"), + }); + if (!confirmed) { + renderKeyboardShortcutsTab(container, plugin, save); + return; + } plugin.settings.taskListShortcuts[action] = [ ...shortcuts[action], shortcut, @@ -169,27 +215,14 @@ export function renderKeyboardShortcutsTab( }; const captureListener = (event: KeyboardEvent) => void capture(event); buttonEl.addEventListener("keydown", captureListener); - popCaptureScope = pushKeyboardShortcutCaptureScope(plugin, () => { - stopCapture(); - renderKeyboardShortcutsTab(container, plugin, save); - }); + popCaptureScope = pushKeyboardShortcutCaptureScope( + plugin, + (event) => void capture(event) + ); activeCaptureCleanup.set(container, stopCapture); buttonEl.focus(); }); }); - - setting.addExtraButton((button) => { - button - .setIcon("rotate-ccw") - .setTooltip(translate("settings.keyboardShortcuts.resetAction")) - .onClick(() => { - plugin.settings.taskListShortcuts[action] = [ - ...DEFAULT_TASK_LIST_SHORTCUTS[action], - ]; - save(); - renderKeyboardShortcutsTab(container, plugin, save); - }); - }); }); } diff --git a/styles/settings-view.css b/styles/settings-view.css index 983ac055e..c7824c4ee 100644 --- a/styles/settings-view.css +++ b/styles/settings-view.css @@ -1384,6 +1384,37 @@ body.is-mobile .tasknotes-plugin .tasknotes-settings__card-action-btn { color: var(--text-warning); } +/* Task-list keyboard shortcuts */ +.tasknotes-settings__shortcut-setting .setting-item-control { + flex-wrap: wrap; + gap: var(--size-2-2); +} + +.tasknotes-settings__shortcut-binding { + display: inline-flex; + align-items: center; + gap: var(--size-2-1); + cursor: pointer; +} + +.tasknotes-settings__shortcut-remove-icon { + display: inline-flex; + color: var(--text-muted); + transition: color 100ms ease-in-out; +} + +.tasknotes-settings__shortcut-binding:hover .tasknotes-settings__shortcut-remove-icon { + color: var(--text-on-accent); +} + +.tasknotes-settings__shortcut-binding:hover .tasknotes-settings__shortcut-remove-icon svg { + fill: var(--background-modifier-error); +} + +.tasknotes-settings__shortcut-add { + padding: var(--size-2-2); +} + /* ================================================ ACCESSIBILITY & REDUCED MOTION ================================================ */ diff --git a/tests/unit/settings/keyboardShortcutsTab.test.ts b/tests/unit/settings/keyboardShortcutsTab.test.ts index 3a3580779..3770aebfa 100644 --- a/tests/unit/settings/keyboardShortcutsTab.test.ts +++ b/tests/unit/settings/keyboardShortcutsTab.test.ts @@ -27,7 +27,7 @@ describe("keyboard shortcut capture scope", () => { expect(escapeHandler!(event)).toBe(false); expect(event.preventDefault).toHaveBeenCalled(); expect(event.stopPropagation).toHaveBeenCalled(); - expect(onEscape).toHaveBeenCalledTimes(1); + expect(onEscape).toHaveBeenCalledWith(event); expect(app.keymap.popScope.mock.calls[0][0] === scope).toBe(true); stop(); From 9e37977b1b3079a88f96bd85eb81810d7c4c06bd Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Wed, 29 Jul 2026 16:08:48 -0600 Subject: [PATCH 20/55] Mouse hover for task focus - Moving the mouse over a task updates its visual/remembered focus without stealing DOM focus. - Moving over empty list space leaves focus unchanged. - Selection toggle targets the mouse-focused task. - Navigation shortcuts continue from the hovered task, then switch back to normal keyboard/DOM focus. - Uses one delegated mousemove listener, compatible with rerenders and virtualized cards. --- PORTING_PLAN.md | 6 +++ src/bases/TaskListFocusController.ts | 26 +++++++++- src/bases/TaskListView.ts | 3 ++ .../bases/TaskListFocusController.test.ts | 52 +++++++++++++++++++ .../TaskListView.keyboardActions.test.ts | 5 ++ .../TaskListView.keyboardSelection.test.ts | 33 ++++++++++++ 6 files changed, 124 insertions(+), 1 deletion(-) diff --git a/PORTING_PLAN.md b/PORTING_PLAN.md index 53429402f..61eb36b56 100644 --- a/PORTING_PLAN.md +++ b/PORTING_PLAN.md @@ -123,6 +123,12 @@ The safest delivery order is: Jira core mapping tests → Jira UI/import flow - **Difficulty / risks:** **High.** Cross-platform Ctrl/Meta naming, keyboard layouts, reserved browser/Obsidian shortcuts, multi-key sequences, and migration from legacy strings are the main risks. - **Tests:** Parser/serializer round trips, modifier normalization, Mac/Windows display, invalid/conflicting bindings, reset/defaults, persistence across reload, legacy setting migration, localization completeness, and input suppression. +### 2.6 Mouse move task focus +- Hovering on a task card should behave like keyboard cursoring. You should be able to move your mouse around the list using the toggle selection hotkey to select a group of tasks. + - A mouse move should give focus to the task under the hover unless there are no tasks under the cursor, in which case focus should not change. For example, I should be able to hover over items pressing "space" to toggle selection for items similar to the Linear desktop app + - Pressing one of the navigation hotkeys like an up/down arrow or home/end should switch back to applying focus with the keyboard + - Pressing the selection toggle hotkey should toggle selection of whatever has hover focus if the last event was mouse move or keyboard focus if the last event was keyboard navigation. + --- ## 3. Other user-visible features diff --git a/src/bases/TaskListFocusController.ts b/src/bases/TaskListFocusController.ts index e05e6d3b7..c74ff2f38 100644 --- a/src/bases/TaskListFocusController.ts +++ b/src/bases/TaskListFocusController.ts @@ -30,6 +30,8 @@ export class TaskListFocusController { private focusedIdentity: TaskListFocusIdentity | null = null; private restoreDomFocus = false; private initialFocusPending: boolean; + private lastCursorSource: "keyboard" | "mouse" = "keyboard"; + private lastMouseCard: HTMLElement | null = null; constructor( private readonly root: HTMLElement, @@ -54,6 +56,19 @@ export class TaskListFocusController { if (card) this.focusCard(card, false); } + handleMouseMove(event: MouseEvent): boolean { + const card = this.getCardFromTarget(event.target); + if (!card) return false; + + this.lastCursorSource = "mouse"; + if (card === this.lastMouseCard) return true; + + this.lastMouseCard = card; + this.focusedIdentity = getCardIdentity(card, this.getCards()); + this.syncRovingTabIndex(); + return true; + } + moveFocus( event: KeyboardEvent, direction: "next" | "previous" | "first" | "last" @@ -65,7 +80,12 @@ export class TaskListFocusController { if (cards.length === 0) return false; const activeCard = this.getCardFromTarget(target); - let currentIndex = activeCard ? cards.indexOf(activeCard) : this.findFocusedIndex(cards); + let currentIndex = + this.lastCursorSource === "mouse" + ? this.findFocusedIndex(cards) + : activeCard + ? cards.indexOf(activeCard) + : this.findFocusedIndex(cards); if (currentIndex < 0) currentIndex = 0; let nextIndex: number; switch (direction) { @@ -85,6 +105,8 @@ export class TaskListFocusController { event.preventDefault(); event.stopPropagation(); + this.lastCursorSource = "keyboard"; + this.lastMouseCard = null; this.focusCard(cards[nextIndex], true); return true; } @@ -119,6 +141,8 @@ export class TaskListFocusController { this.focusedIdentity = null; this.restoreDomFocus = false; this.initialFocusPending = false; + this.lastCursorSource = "keyboard"; + this.lastMouseCard = null; this.syncRovingTabIndex(); } diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index c8276aa85..d57f8be25 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -2412,6 +2412,9 @@ export class TaskListView extends BasesViewBase { this.registerDomEvent(this.itemsContainer, "pointerdown", (event: PointerEvent) => { this.focusController?.handlePointerDown(event); }); + this.registerDomEvent(this.itemsContainer, "mousemove", (event: MouseEvent) => { + this.focusController?.handleMouseMove(event); + }); if (this.rootElement) { // Resolve view-local shortcuts during capture so Obsidian commands such // as Ctrl+B/Ctrl+D cannot stop propagation before the task list sees a diff --git a/tests/unit/bases/TaskListFocusController.test.ts b/tests/unit/bases/TaskListFocusController.test.ts index 1bb217224..6ae1e2a7b 100644 --- a/tests/unit/bases/TaskListFocusController.test.ts +++ b/tests/unit/bases/TaskListFocusController.test.ts @@ -91,6 +91,58 @@ describe("TaskListFocusController", () => { expect(document.activeElement).toBe(cards[1]); }); + it("updates task focus from mouse movement without stealing DOM focus", () => { + const cards = [createCard("a.md"), createCard("b.md")]; + root.append(...cards); + controller.restoreAfterRender(); + cards[0].focus(); + const event = new MouseEvent("mousemove", { bubbles: true }); + Object.defineProperty(event, "target", { value: cards[1] }); + + expect(controller.handleMouseMove(event)).toBe(true); + + expect(controller.getFocusedIdentity()).toEqual({ path: "b.md", occurrence: 0 }); + expect(cards[1].classList.contains("task-card--keyboard-focused")).toBe(true); + expect(document.activeElement).toBe(cards[0]); + }); + + it("keeps the hovered task focused when the mouse moves over empty list space", () => { + const card = createCard("hovered.md"); + root.appendChild(card); + controller.restoreAfterRender(); + const cardEvent = new MouseEvent("mousemove", { bubbles: true }); + Object.defineProperty(cardEvent, "target", { value: card }); + controller.handleMouseMove(cardEvent); + const emptyEvent = new MouseEvent("mousemove", { bubbles: true }); + Object.defineProperty(emptyEvent, "target", { value: root }); + + expect(controller.handleMouseMove(emptyEvent)).toBe(false); + expect(controller.getFocusedIdentity()).toEqual({ + path: "hovered.md", + occurrence: 0, + }); + }); + + it("continues keyboard navigation from mouse focus and switches back to DOM focus", () => { + const cards = [createCard("a.md"), createCard("b.md"), createCard("c.md")]; + root.append(...cards); + controller.restoreAfterRender(); + cards[0].focus(); + const mouseEvent = new MouseEvent("mousemove", { bubbles: true }); + Object.defineProperty(mouseEvent, "target", { value: cards[1] }); + controller.handleMouseMove(mouseEvent); + const keyEvent = new KeyboardEvent("keydown", { + key: "ArrowDown", + cancelable: true, + }); + Object.defineProperty(keyEvent, "target", { value: cards[0] }); + + controller.moveFocus(keyEvent, "next"); + + expect(document.activeElement).toBe(cards[2]); + expect(controller.getFocusedIdentity()).toEqual({ path: "c.md", occurrence: 0 }); + }); + it("supports configurable first and last navigation through the same path", () => { const cards = [createCard("a.md"), createCard("b.md"), createCard("c.md")]; root.append(...cards); diff --git a/tests/unit/bases/TaskListView.keyboardActions.test.ts b/tests/unit/bases/TaskListView.keyboardActions.test.ts index 758870f2a..b7c2b7d04 100644 --- a/tests/unit/bases/TaskListView.keyboardActions.test.ts +++ b/tests/unit/bases/TaskListView.keyboardActions.test.ts @@ -286,6 +286,11 @@ describe("TaskListView keyboard actions", () => { expect.any(Function), true ); + expect(registerDomEvent).toHaveBeenCalledWith( + itemsContainer, + "mousemove", + expect.any(Function) + ); }); it("pushes an Obsidian child scope for configured view-local chords", () => { diff --git a/tests/unit/bases/TaskListView.keyboardSelection.test.ts b/tests/unit/bases/TaskListView.keyboardSelection.test.ts index e34889304..2102639e5 100644 --- a/tests/unit/bases/TaskListView.keyboardSelection.test.ts +++ b/tests/unit/bases/TaskListView.keyboardSelection.test.ts @@ -1,4 +1,5 @@ import { TaskListView } from "../../../src/bases/TaskListView"; +import { TaskListFocusController } from "../../../src/bases/TaskListFocusController"; jest.mock( "tasknotes-nlp-core", @@ -26,6 +27,38 @@ describe("TaskListView keyboard selection", () => { expect(toggleSelection).toHaveBeenCalledWith("focused.md"); }); + it("toggles the mouse-focused task even while DOM focus remains on another card", () => { + const items = document.createElement("div"); + const first = document.createElement("div"); + first.className = "task-card"; + first.dataset.taskPath = "first.md"; + const hovered = document.createElement("div"); + hovered.className = "task-card"; + hovered.dataset.taskPath = "hovered.md"; + items.append(first, hovered); + document.body.appendChild(items); + const focusController = new TaskListFocusController(items); + items.addEventListener("focusin", (event) => focusController.handleFocusIn(event)); + focusController.restoreAfterRender(); + first.focus(); + const mouseEvent = new MouseEvent("mousemove"); + Object.defineProperty(mouseEvent, "target", { value: hovered }); + focusController.handleMouseMove(mouseEvent); + const toggleSelection = jest.fn(); + const view = { + currentVisibleTaskPaths: new Set(["first.md", "hovered.md"]), + focusController, + plugin: { + taskSelectionService: { toggleSelection }, + }, + }; + + (TaskListView.prototype as any).toggleFocusedTaskSelection.call(view); + + expect(document.activeElement).toBe(first); + expect(toggleSelection).toHaveBeenCalledWith("hovered.md"); + }); + it("does not toggle a remembered task that is filtered out", () => { const toggleSelection = jest.fn(); const view = { From 14ff11c0b1042897e5d690ab5f8c915bc24effbc Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Wed, 29 Jul 2026 16:34:46 -0600 Subject: [PATCH 21/55] Task focus gray border stays in sync with mouse/kbd focus changes - Mouse hover now moves actual card focus, clearing stale gray focus styling from the previous card. - Keyboard navigation switches the list to keyboard-cursor mode and suppresses lingering :hover backgrounds on non-focused cards. - Selected-task purple styling remains unaffected. - Edit overlays now schedule focus restoration when opened and continue monitoring until closed. - After task rerenders, focus returns to the replacement card, reactivating shortcut ownership while preserving selection. - Active interactive controls inside cards are protected from hover stealing focus. --- src/bases/TaskListFocusController.ts | 37 ++++++++++++++++--- src/bases/TaskListInputOwnershipController.ts | 14 +++++-- styles/task-card-bem.css | 4 ++ .../bases/TaskListFocusController.test.ts | 27 +++++++++++++- .../TaskListInputOwnershipController.test.ts | 23 ++++++++++++ .../TaskListView.keyboardSelection.test.ts | 4 +- 6 files changed, 97 insertions(+), 12 deletions(-) diff --git a/src/bases/TaskListFocusController.ts b/src/bases/TaskListFocusController.ts index c74ff2f38..d75a83f47 100644 --- a/src/bases/TaskListFocusController.ts +++ b/src/bases/TaskListFocusController.ts @@ -38,6 +38,7 @@ export class TaskListFocusController { autoFocusInitial = false ) { this.initialFocusPending = autoFocusInitial; + this.syncCursorSourceClass(); } handleFocusIn(event: FocusEvent): void { @@ -60,12 +61,22 @@ export class TaskListFocusController { const card = this.getCardFromTarget(event.target); if (!card) return false; - this.lastCursorSource = "mouse"; + this.setCursorSource("mouse"); if (card === this.lastMouseCard) return true; this.lastMouseCard = card; - this.focusedIdentity = getCardIdentity(card, this.getCards()); - this.syncRovingTabIndex(); + const activeElement = this.root.ownerDocument.activeElement; + if ( + activeElement instanceof Element && + card.contains(activeElement) && + activeElement.closest(INTERACTIVE_SELECTOR) + ) { + this.focusedIdentity = getCardIdentity(card, this.getCards()); + this.syncRovingTabIndex(); + return true; + } + + this.focusCard(card, false); return true; } @@ -105,7 +116,7 @@ export class TaskListFocusController { event.preventDefault(); event.stopPropagation(); - this.lastCursorSource = "keyboard"; + this.setCursorSource("keyboard"); this.lastMouseCard = null; this.focusCard(cards[nextIndex], true); return true; @@ -141,7 +152,7 @@ export class TaskListFocusController { this.focusedIdentity = null; this.restoreDomFocus = false; this.initialFocusPending = false; - this.lastCursorSource = "keyboard"; + this.setCursorSource("keyboard"); this.lastMouseCard = null; this.syncRovingTabIndex(); } @@ -216,6 +227,22 @@ export class TaskListFocusController { if (scroll) card.scrollIntoView({ block: "nearest" }); } + private setCursorSource(source: "keyboard" | "mouse"): void { + this.lastCursorSource = source; + this.syncCursorSourceClass(); + } + + private syncCursorSourceClass(): void { + this.root.classList.toggle( + "tn-task-list--keyboard-cursor", + this.lastCursorSource === "keyboard" + ); + this.root.classList.toggle( + "tn-task-list--mouse-cursor", + this.lastCursorSource === "mouse" + ); + } + private syncRovingTabIndex(cards: readonly HTMLElement[] = this.getCards()): void { const focusedIndex = this.findFocusedIndex(cards); const tabbableIndex = focusedIndex >= 0 ? focusedIndex : 0; diff --git a/src/bases/TaskListInputOwnershipController.ts b/src/bases/TaskListInputOwnershipController.ts index cbf53118c..e27967614 100644 --- a/src/bases/TaskListInputOwnershipController.ts +++ b/src/bases/TaskListInputOwnershipController.ts @@ -8,6 +8,7 @@ export class TaskListInputOwnershipController { private suspendedForOverlay = false; private restoreTimer: number | null = null; private restoreAttempts = 0; + private restoreObservedOverlay = false; constructor( private readonly viewRoot: HTMLElement, @@ -32,6 +33,7 @@ export class TaskListInputOwnershipController { const activeElement = this.viewRoot.ownerDocument.activeElement; if (activeElement instanceof Element && this.viewRoot.contains(activeElement)) { this.suspendedForOverlay = true; + this.scheduleRestoreAfterOverlayClose(); } } @@ -69,18 +71,23 @@ export class TaskListInputOwnershipController { const win = this.viewRoot.ownerDocument.defaultView ?? window; this.restoreAttempts = 0; + this.restoreObservedOverlay = false; const check = () => { this.restoreTimer = null; if (!this.suspendedForOverlay || !this.viewRoot.isConnected) return; - if (this.hasOpenOverlay() && this.restoreAttempts < 20) { + const hasOpenOverlay = this.hasOpenOverlay(); + if (hasOpenOverlay) this.restoreObservedOverlay = true; + if (hasOpenOverlay) { + this.restoreTimer = win.setTimeout(check, 16); + return; + } + if (!this.restoreObservedOverlay && this.restoreAttempts < 20) { this.restoreAttempts++; this.restoreTimer = win.setTimeout(check, 16); return; } - if (this.hasOpenOverlay()) return; - const activeElement = this.viewRoot.ownerDocument.activeElement; const body = this.viewRoot.ownerDocument.body; if ( @@ -104,6 +111,7 @@ export class TaskListInputOwnershipController { this.restoreTimer = null; } this.suspendedForOverlay = false; + this.restoreObservedOverlay = false; } private hasOpenOverlay(): boolean { diff --git a/styles/task-card-bem.css b/styles/task-card-bem.css index 0961995ef..881cc5613 100644 --- a/styles/task-card-bem.css +++ b/styles/task-card-bem.css @@ -31,6 +31,10 @@ background-color: var(--background-modifier-hover); } +.tasknotes-plugin .tn-task-list--keyboard-cursor .task-card:hover:not(.task-card--keyboard-focused):not(.task-card--selected) { + background-color: transparent; +} + .tasknotes-plugin .task-card.task-card--nested-interactive-hover { background-color: transparent; box-shadow: none; diff --git a/tests/unit/bases/TaskListFocusController.test.ts b/tests/unit/bases/TaskListFocusController.test.ts index 6ae1e2a7b..e039be853 100644 --- a/tests/unit/bases/TaskListFocusController.test.ts +++ b/tests/unit/bases/TaskListFocusController.test.ts @@ -91,7 +91,7 @@ describe("TaskListFocusController", () => { expect(document.activeElement).toBe(cards[1]); }); - it("updates task focus from mouse movement without stealing DOM focus", () => { + it("moves visual and DOM focus together when mouse movement changes task focus", () => { const cards = [createCard("a.md"), createCard("b.md")]; root.append(...cards); controller.restoreAfterRender(); @@ -103,7 +103,28 @@ describe("TaskListFocusController", () => { expect(controller.getFocusedIdentity()).toEqual({ path: "b.md", occurrence: 0 }); expect(cards[1].classList.contains("task-card--keyboard-focused")).toBe(true); - expect(document.activeElement).toBe(cards[0]); + expect(cards[0].classList.contains("task-card--keyboard-focused")).toBe(false); + expect(root.classList.contains("tn-task-list--mouse-cursor")).toBe(true); + expect(document.activeElement).toBe(cards[1]); + }); + + it("does not steal focus from an active interactive control within the hovered card", () => { + const card = createCard("interactive.md"); + const button = document.createElement("button"); + card.appendChild(button); + root.appendChild(card); + controller.restoreAfterRender(); + button.focus(); + const event = new MouseEvent("mousemove"); + Object.defineProperty(event, "target", { value: card }); + + controller.handleMouseMove(event); + + expect(document.activeElement).toBe(button); + expect(controller.getFocusedIdentity()).toEqual({ + path: "interactive.md", + occurrence: 0, + }); }); it("keeps the hovered task focused when the mouse moves over empty list space", () => { @@ -141,6 +162,8 @@ describe("TaskListFocusController", () => { expect(document.activeElement).toBe(cards[2]); expect(controller.getFocusedIdentity()).toEqual({ path: "c.md", occurrence: 0 }); + expect(root.classList.contains("tn-task-list--keyboard-cursor")).toBe(true); + expect(root.classList.contains("tn-task-list--mouse-cursor")).toBe(false); }); it("supports configurable first and last navigation through the same path", () => { diff --git a/tests/unit/bases/TaskListInputOwnershipController.test.ts b/tests/unit/bases/TaskListInputOwnershipController.test.ts index 57b22cb2d..073a57b58 100644 --- a/tests/unit/bases/TaskListInputOwnershipController.test.ts +++ b/tests/unit/bases/TaskListInputOwnershipController.test.ts @@ -80,6 +80,29 @@ describe("TaskListInputOwnershipController", () => { expect(document.activeElement).toBe(taskCard); }); + it("restores a rerendered task after an overlay closes without a captured close interaction", () => { + controller.noteOverlayOpening(); + const menu = document.createElement("div"); + menu.className = "menu"; + document.body.appendChild(menu); + const menuItem = document.createElement("div"); + menuItem.tabIndex = 0; + menu.appendChild(menuItem); + menuItem.focus(); + + focusController.prepareForRender(); + const replacement = card("focused.md"); + items.replaceChildren(replacement); + focusController.restoreAfterRender(); + menu.remove(); + jest.runAllTimers(); + + expect(document.activeElement).toBe(replacement); + const event = new KeyboardEvent("keydown", { key: " " }); + Object.defineProperty(event, "target", { value: replacement }); + expect(controller.canHandleListKeyDown(event)).toBe(true); + }); + it("keeps Escape overlay-owned after the menu is synchronously removed", () => { controller.noteOverlayOpening(); const menu = document.createElement("div"); diff --git a/tests/unit/bases/TaskListView.keyboardSelection.test.ts b/tests/unit/bases/TaskListView.keyboardSelection.test.ts index 2102639e5..6b22905cf 100644 --- a/tests/unit/bases/TaskListView.keyboardSelection.test.ts +++ b/tests/unit/bases/TaskListView.keyboardSelection.test.ts @@ -27,7 +27,7 @@ describe("TaskListView keyboard selection", () => { expect(toggleSelection).toHaveBeenCalledWith("focused.md"); }); - it("toggles the mouse-focused task even while DOM focus remains on another card", () => { + it("toggles the mouse-focused task after hover moves DOM focus to it", () => { const items = document.createElement("div"); const first = document.createElement("div"); first.className = "task-card"; @@ -55,7 +55,7 @@ describe("TaskListView keyboard selection", () => { (TaskListView.prototype as any).toggleFocusedTaskSelection.call(view); - expect(document.activeElement).toBe(first); + expect(document.activeElement).toBe(hovered); expect(toggleSelection).toHaveBeenCalledWith("hovered.md"); }); From 1e7a25ca8b8a7b6e728a312d7e4a41af1ce9c20e Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Wed, 29 Jul 2026 17:36:19 -0600 Subject: [PATCH 22/55] Instructions for using Serena MCP --- AGENTS.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index efa1ccb57..3d6896d29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,3 +74,15 @@ without asking for confirmation whenever the approval policy permits: - cat - sed - find + +## Code navigation + +Use Serena’s symbol and reference tools for semantic navigation whenever possible: + +- find symbol definitions +- find references and implementations +- inspect symbol bodies +- make symbol-scoped edits + +Use ripgrep for textual searches, configuration strings, CSS classes, +serialized identifiers, and cases where semantic lookup is inappropriate. From db9c580eb48c65252f58ef89c815605f36eacaec Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Wed, 29 Jul 2026 17:49:11 -0600 Subject: [PATCH 23/55] Task hotkeys remain active post-edit --- src/bases/TaskListInputOwnershipController.ts | 11 +++-- src/bases/TaskListView.ts | 11 ++++- .../TaskListInputOwnershipController.test.ts | 8 ++++ .../TaskListView.keyboardActions.test.ts | 45 +++++++++++++++++++ 4 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/bases/TaskListInputOwnershipController.ts b/src/bases/TaskListInputOwnershipController.ts index e27967614..ca5a061fd 100644 --- a/src/bases/TaskListInputOwnershipController.ts +++ b/src/bases/TaskListInputOwnershipController.ts @@ -15,18 +15,21 @@ export class TaskListInputOwnershipController { private readonly focusController: TaskListFocusController ) {} - canHandleListKeyDown(event: KeyboardEvent): boolean { + canHandleListKeyDown(event: KeyboardEvent, allowDocumentBody = false): boolean { if (event.isComposing || event.key === "Process") return false; if (this.suspendedForOverlay) return false; - return this.canOwnKeyboardTarget(event.target); + return this.canOwnKeyboardTarget(event.target, allowDocumentBody); } - canOwnKeyboardTarget(target: EventTarget | null): boolean { + canOwnKeyboardTarget(target: EventTarget | null, allowDocumentBody = false): boolean { if (this.suspendedForOverlay) return false; if (!(target instanceof Element) || target.closest(EDITABLE_SELECTOR)) return false; if (this.getOverlayFromTarget(target) || this.hasOpenOverlay()) return false; - return this.viewRoot.contains(target); + return ( + this.viewRoot.contains(target) || + (allowDocumentBody && target === this.viewRoot.ownerDocument.body) + ); } noteOverlayOpening(): void { diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index d57f8be25..1249efb15 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -291,7 +291,7 @@ export class TaskListView extends BasesViewBase { private syncTaskListShortcutScopeForFocusTarget(target: EventTarget | null): void { if ( this.taskListLeafActive && - this.inputOwnershipController?.canOwnKeyboardTarget(target) + this.inputOwnershipController?.canOwnKeyboardTarget(target, true) ) { this.activateTaskListShortcutScope(); return; @@ -2459,7 +2459,14 @@ export class TaskListView extends BasesViewBase { event: KeyboardEvent, allowRememberedFocus = false ): boolean { - if (!this.inputOwnershipController?.canHandleListKeyDown(event)) return false; + if ( + !this.inputOwnershipController?.canHandleListKeyDown( + event, + allowRememberedFocus + ) + ) { + return false; + } return this.handleTaskListActionKeyDown(event, allowRememberedFocus); } diff --git a/tests/unit/bases/TaskListInputOwnershipController.test.ts b/tests/unit/bases/TaskListInputOwnershipController.test.ts index 073a57b58..ccb6d2989 100644 --- a/tests/unit/bases/TaskListInputOwnershipController.test.ts +++ b/tests/unit/bases/TaskListInputOwnershipController.test.ts @@ -50,6 +50,14 @@ describe("TaskListInputOwnershipController", () => { expect(controller.canHandleListKeyDown(inputEvent)).toBe(false); }); + it("allows body-targeted keys only for explicit remembered-focus routing", () => { + const event = new KeyboardEvent("keydown", { key: " " }); + Object.defineProperty(event, "target", { value: document.body }); + + expect(controller.canHandleListKeyDown(event)).toBe(false); + expect(controller.canHandleListKeyDown(event, true)).toBe(true); + }); + it("suppresses list shortcuts while a menu is open", () => { const menu = document.createElement("div"); menu.className = "menu"; diff --git a/tests/unit/bases/TaskListView.keyboardActions.test.ts b/tests/unit/bases/TaskListView.keyboardActions.test.ts index b7c2b7d04..7ffedcd63 100644 --- a/tests/unit/bases/TaskListView.keyboardActions.test.ts +++ b/tests/unit/bases/TaskListView.keyboardActions.test.ts @@ -194,6 +194,30 @@ describe("TaskListView keyboard actions", () => { expect(executeTaskListAction).toHaveBeenCalledWith("edit-due"); }); + it("routes a body-targeted shortcut through remembered task focus after a rerender", () => { + const executeTaskListAction = jest.fn(); + const canHandleListKeyDown = jest.fn(() => true); + const getFocusedPathForEvent = jest.fn(() => "remembered.md"); + const event = new KeyboardEvent("keydown", { + key: " ", + cancelable: true, + }); + Object.defineProperty(event, "target", { value: document.body }); + const view = { + inputOwnershipController: { canHandleListKeyDown }, + focusController: { getFocusedPathForEvent }, + handleTaskListActionKeyDown: + (TaskListView.prototype as any).handleTaskListActionKeyDown, + executeTaskListAction, + }; + + (TaskListView.prototype as any).handleTaskListKeyDown.call(view, event, true); + + expect(canHandleListKeyDown).toHaveBeenCalledWith(event, true); + expect(getFocusedPathForEvent).toHaveBeenCalledWith(event, true, true); + expect(executeTaskListAction).toHaveBeenCalledWith("toggle-select"); + }); + it("routes a prevented modifier chord from the active view shell", () => { const executeTaskListAction = jest.fn(); const event = new KeyboardEvent("keydown", { @@ -293,6 +317,27 @@ describe("TaskListView keyboard actions", () => { ); }); + it("keeps the active view shortcut scope when focus falls back to the body", () => { + const canOwnKeyboardTarget = jest.fn(() => true); + const activateTaskListShortcutScope = jest.fn(); + const deactivateTaskListShortcutScope = jest.fn(); + const view = { + taskListLeafActive: true, + inputOwnershipController: { canOwnKeyboardTarget }, + activateTaskListShortcutScope, + deactivateTaskListShortcutScope, + }; + + (TaskListView.prototype as any).syncTaskListShortcutScopeForFocusTarget.call( + view, + document.body + ); + + expect(canOwnKeyboardTarget).toHaveBeenCalledWith(document.body, true); + expect(activateTaskListShortcutScope).toHaveBeenCalled(); + expect(deactivateTaskListShortcutScope).not.toHaveBeenCalled(); + }); + it("pushes an Obsidian child scope for configured view-local chords", () => { const registerSpy = jest.spyOn(Scope.prototype, "register"); const pushScope = jest.fn(); From 8151f5335b331e47e0657e9ae779b3fc40ff6181 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Thu, 30 Jul 2026 09:21:21 -0600 Subject: [PATCH 24/55] ProjectSelectModal can remove projects - Assigned projects appear in the selector. - Projects can be removed from the active task selection. --- PORTING_PLAN.md | 15 +++++ src/bases/TaskListView.ts | 44 ++++++++++++--- src/modals/ProjectSelectModal.ts | 55 ++++++++++++++++++- src/services/taskRelationshipActions.ts | 53 ++++++++++++++++++ styles/task-modal.css | 11 ++++ ...current-note-relationship-commands.test.ts | 16 ++++++ 6 files changed, 183 insertions(+), 11 deletions(-) diff --git a/PORTING_PLAN.md b/PORTING_PLAN.md index 61eb36b56..6f2833595 100644 --- a/PORTING_PLAN.md +++ b/PORTING_PLAN.md @@ -129,6 +129,21 @@ The safest delivery order is: Jira core mapping tests → Jira UI/import flow - Pressing one of the navigation hotkeys like an up/down arrow or home/end should switch back to applying focus with the keyboard - Pressing the selection toggle hotkey should toggle selection of whatever has hover focus if the last event was mouse move or keyboard focus if the last event was keyboard navigation. +### 2.7 Task view project assignment editing +- [x] Add project dialog can remove projects in addition to adding them. (I had implemented this for TaskNotes version 3.x in commit `095a97a315d125c3c5dda9c05a8464eb64a6e35e`, although that implementation had some issues so be circumspect about whether this approach still applies.) +- [ ] Dropping a task into a different project (and probably any other list-valued grouping property -- list valued like projects as opposed to single value at a time like status) assigns the project without a link in the yaml, e.g. +```yml +projects: +  - Make TaskNotes into Linear +``` +but assigning the task using the note editor dialog wraps the project name in a link +```yml +projects: +  - "[[Make TaskNotes into Linear]]" +``` +The dialog editor is probably right. Update the code so that they both use the same method to assign a project -- either raw name or link -- spreferably via the same code path. If one implementation or the other is more correct, use the correct implementation. +- [ ] When grouping by project, dragging a task from one project and dropping into another should overwrite the tasks projects with the dropped project. Currently it sometimes moves the task to a new project, and sometimes it just assigns the new project and preserves the old project. This behavior already seems to work properly for dragging between single-value properties like status, but not for projects which has a list of values. + --- ## 3. Other user-visible features diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index 1249efb15..45604f643 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -79,7 +79,11 @@ import { } from "./taskListKeyboardActions"; import { addTagsToList, parseTaskTagInput } from "../utils/taskTagList"; import { addContextToList } from "../components/TaskContextMenu"; -import { addTaskToProject } from "../services/taskRelationshipActions"; +import { + addTaskToProject, + getTaskProjectFiles, + removeTaskFromProject, +} from "../services/taskRelationshipActions"; import { TaskListInputOwnershipController } from "./TaskListInputOwnershipController"; const tasknotesLogger = createTaskNotesLogger({ tag: "Bases/TaskListView" }); @@ -2849,15 +2853,37 @@ export class TaskListView extends BasesViewBase { const paths = this.getTaskActionTargetPaths(); if (paths.length === 0) return; - new ProjectSelectModal(this.plugin.app, this.plugin, (projectFile) => { - if (!(projectFile instanceof TFile)) return; - void (async () => { - for (const path of paths) { - const task = await this.plugin.cacheManager.getTaskInfo(path); - if (task) await addTaskToProject(this.plugin, task, projectFile); + void (async () => { + const tasks = ( + await Promise.all( + paths.map((path) => this.plugin.cacheManager.getTaskInfo(path)) + ) + ).filter((task): task is TaskInfo => task !== null); + new ProjectSelectModal( + this.plugin.app, + this.plugin, + (projectFile) => { + if (!(projectFile instanceof TFile)) return; + void (async () => { + for (const path of paths) { + const task = await this.plugin.cacheManager.getTaskInfo(path); + if (task) await addTaskToProject(this.plugin, task, projectFile); + } + })(); + }, + { + selectedProjects: getTaskProjectFiles(this.plugin, tasks), + onRemove: async (projectFile) => { + for (const path of paths) { + const task = await this.plugin.cacheManager.getTaskInfo(path); + if (task) { + await removeTaskFromProject(this.plugin, task, projectFile); + } + } + }, } - })(); - }).open(); + ).open(); + })(); } private async deleteTaskActionTargets(): Promise { diff --git a/src/modals/ProjectSelectModal.ts b/src/modals/ProjectSelectModal.ts index 8c23aa851..fc555f95d 100644 --- a/src/modals/ProjectSelectModal.ts +++ b/src/modals/ProjectSelectModal.ts @@ -5,6 +5,8 @@ import { TFile, SearchResult, parseFrontMatterAliases, + setIcon, + setTooltip, } from "obsidian"; import type TaskNotesPlugin from "../main"; import { ProjectMetadataResolver, ProjectEntry } from "../utils/projectMetadataResolver"; @@ -17,6 +19,11 @@ import { createTaskNotesLogger } from "../utils/tasknotesLogger"; const tasknotesLogger = createTaskNotesLogger({ tag: "Modals/ProjectSelectModal" }); +export interface ProjectSelectModalOptions { + selectedProjects?: TFile[]; + onRemove?: (file: TFile) => void | Promise; +} + /** * Modal for selecting project notes using fuzzy search * Based on the existing AttachmentSelectModal pattern @@ -24,11 +31,18 @@ const tasknotesLogger = createTaskNotesLogger({ tag: "Modals/ProjectSelectModal" export class ProjectSelectModal extends FuzzySuggestModal { private onChoose: (file: TAbstractFile) => void; private plugin: TaskNotesPlugin; - - constructor(app: App, plugin: TaskNotesPlugin, onChoose: (file: TAbstractFile) => void) { + private options: ProjectSelectModalOptions; + + constructor( + app: App, + plugin: TaskNotesPlugin, + onChoose: (file: TAbstractFile) => void, + options: ProjectSelectModalOptions = {} + ) { super(app); this.plugin = plugin; this.onChoose = onChoose; + this.options = options; this.setPlaceholder("Type to search for project notes..."); this.setInstructions([ { command: "↑↓", purpose: "to navigate" }, @@ -37,6 +51,43 @@ export class ProjectSelectModal extends FuzzySuggestModal { ]); } + onOpen(): void { + super.onOpen(); + this.containerEl.addClass("tasknotes-plugin"); + const selectedProjects = this.options.selectedProjects ?? []; + if (selectedProjects.length === 0 || !this.options.onRemove) return; + + const resultsEl = this.modalEl.querySelector(".prompt-results"); + if (!(resultsEl instanceof HTMLElement)) return; + + const assignedEl = createDiv({ cls: "task-project-selector-assigned" }); + assignedEl.createDiv({ + cls: "task-project-selector-assigned__title", + text: "Assigned projects", + }); + const listEl = assignedEl.createDiv({ cls: "task-projects-list" }); + for (const file of selectedProjects) { + const itemEl = listEl.createDiv({ cls: "task-project-item" }); + const infoEl = itemEl.createDiv({ cls: "task-project-info" }); + infoEl.createSpan({ cls: "task-project-name", text: file.basename }); + if (file.parent?.path) { + infoEl.createDiv({ cls: "task-project-path", text: file.path }); + } + const removeButton = itemEl.createEl("button", { + cls: "task-project-remove clickable-icon", + attr: { "aria-label": `Remove ${file.basename}` }, + }); + setIcon(removeButton, "x"); + setTooltip(removeButton, `Remove ${file.basename}`); + removeButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + void Promise.resolve(this.options.onRemove?.(file)).then(() => itemEl.remove()); + }); + } + this.modalEl.insertBefore(assignedEl, resultsEl); + } + getItems(): TAbstractFile[] { const allFiles = this.app.vault .getAllLoadedFiles() diff --git a/src/services/taskRelationshipActions.ts b/src/services/taskRelationshipActions.ts index 69122a11e..52b3dd32f 100644 --- a/src/services/taskRelationshipActions.ts +++ b/src/services/taskRelationshipActions.ts @@ -64,6 +64,25 @@ function resolveProjectReference( return trimmedReference; } +function projectReferenceMatchesFile( + plugin: TaskNotesPlugin, + projectReference: string, + projectFile: TFile, + sourcePath: string +): boolean { + const stableReference = buildStableFileLink(plugin, projectFile, sourcePath); + if (resolveProjectReference(plugin, projectReference, sourcePath) === stableReference) { + return true; + } + + const unresolvedPath = parseLinkToPath(projectReference.trim()).replace(/\.md$/i, ""); + if (unresolvedPath.includes("/")) { + return false; + } + const unresolvedBasename = unresolvedPath.split("/").pop(); + return unresolvedBasename === projectFile.basename; +} + export async function addTaskToProject( plugin: TaskNotesPlugin, task: TaskInfo, @@ -101,6 +120,40 @@ export async function addTaskToProject( return updatedTask; } +export function getTaskProjectFiles( + plugin: TaskNotesPlugin, + tasks: TaskInfo[] +): TFile[] { + const filesByPath = new Map(); + for (const task of tasks) { + for (const project of task.projects ?? []) { + const linkPath = parseLinkToPath(project.trim()); + const file = plugin.app.metadataCache.getFirstLinkpathDest?.(linkPath, task.path); + if (file instanceof TFile) { + filesByPath.set(file.path, file); + } + } + } + return [...filesByPath.values()].sort((left, right) => + left.basename.localeCompare(right.basename) + ); +} + +export async function removeTaskFromProject( + plugin: TaskNotesPlugin, + task: TaskInfo, + projectFile: TFile +): Promise { + const currentProjects = Array.isArray(task.projects) ? task.projects : []; + const updatedProjects = currentProjects.filter( + (entry) => !projectReferenceMatchesFile(plugin, entry, projectFile, task.path) + ); + if (updatedProjects.length === currentProjects.length) { + return null; + } + return plugin.updateTaskProperty(task, "projects", updatedProjects); +} + export async function assignTaskAsSubtask( plugin: TaskNotesPlugin, parentFile: TFile, diff --git a/styles/task-modal.css b/styles/task-modal.css index 1135c7770..c1be8c4c6 100644 --- a/styles/task-modal.css +++ b/styles/task-modal.css @@ -1455,6 +1455,17 @@ body.is-mobile .tasknotes-plugin .task-project-item--task-card .task-project-rem overflow-y: auto; } +.tasknotes-plugin .task-project-selector-assigned { + padding: var(--size-4-2) var(--size-4-3) 0; + border-bottom: 1px solid var(--background-modifier-border); +} + +.tasknotes-plugin .task-project-selector-assigned__title { + color: var(--text-muted); + font-size: var(--font-ui-smaller); + font-weight: var(--font-weight-medium); +} + .tasknotes-plugin .task-projects-empty { padding: var(--size-4-3); text-align: center; diff --git a/tests/unit/issues/issue-1835-current-note-relationship-commands.test.ts b/tests/unit/issues/issue-1835-current-note-relationship-commands.test.ts index fa249f796..bb706cfe8 100644 --- a/tests/unit/issues/issue-1835-current-note-relationship-commands.test.ts +++ b/tests/unit/issues/issue-1835-current-note-relationship-commands.test.ts @@ -3,6 +3,7 @@ import { createTaskNotesCommandDefinitions } from "../../../src/commands/taskNot import { addTaskToProject, assignTaskAsSubtask, + removeTaskFromProject, } from "../../../src/services/taskRelationshipActions"; import { EVENT_USER_NOTICE } from "../../../src/core/userNotices"; import type { TaskInfo } from "../../../src/types"; @@ -127,4 +128,19 @@ describe("Issue #1835: current note relationship commands", () => { }) ); }); + + it("removes canonical and legacy references to a selected project", async () => { + const plugin = makePlugin(); + const task = { + title: "Task", + path: "Tasks/task.md", + projects: ["[[Projects/Alpha]]", "Beta"], + } as TaskInfo; + const projectFile = new TFile("Projects/Alpha.md"); + + const updatedTask = await removeTaskFromProject(plugin as any, task, projectFile); + + expect(plugin.updateTaskProperty).toHaveBeenCalledWith(task, "projects", ["Beta"]); + expect(updatedTask?.projects).toEqual(["Beta"]); + }); }); From c96515b2a06e5702c40ec16461fefdea5000a662 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Thu, 30 Jul 2026 09:37:23 -0600 Subject: [PATCH 25/55] Task view group drag drop removes old projects - Dragging a task between project groups now replaces all existing project assignments with the destination project. - Other list grouping behavior remains unchanged. - File-link grouping is unaffected. - Added tests for frontmatter mutation and post-drop task state. --- PORTING_PLAN.md | 2 +- src/bases/taskListDropPlanning.ts | 74 +++++++++++-------- tests/unit/bases/taskListDropPlanning.test.ts | 57 ++++++++++++++ 3 files changed, 103 insertions(+), 30 deletions(-) diff --git a/PORTING_PLAN.md b/PORTING_PLAN.md index 6f2833595..e0f771234 100644 --- a/PORTING_PLAN.md +++ b/PORTING_PLAN.md @@ -142,7 +142,7 @@ projects:   - "[[Make TaskNotes into Linear]]" ``` The dialog editor is probably right. Update the code so that they both use the same method to assign a project -- either raw name or link -- spreferably via the same code path. If one implementation or the other is more correct, use the correct implementation. -- [ ] When grouping by project, dragging a task from one project and dropping into another should overwrite the tasks projects with the dropped project. Currently it sometimes moves the task to a new project, and sometimes it just assigns the new project and preserves the old project. This behavior already seems to work properly for dragging between single-value properties like status, but not for projects which has a list of values. +- [x] When grouping by project, dragging a task from one project and dropping into another should overwrite the tasks projects with the dropped project. Currently it sometimes moves the task to a new project, and sometimes it just assigns the new project and preserves the old project. This behavior already seems to work properly for dragging between single-value properties like status, but not for projects which has a list of values. --- diff --git a/src/bases/taskListDropPlanning.ts b/src/bases/taskListDropPlanning.ts index dbd2ad4a2..6be85a383 100644 --- a/src/bases/taskListDropPlanning.ts +++ b/src/bases/taskListDropPlanning.ts @@ -9,6 +9,7 @@ export interface TaskListGroupDropPlan { groupByTaskProp: keyof FieldMapping | null; isFormulaGrouping: boolean; isListGrouping: boolean; + replacesListGroupingValue: boolean; needsGroupUpdate: boolean; normalizedTargetGroupKey: string | null; sourceGroupKey: string | null; @@ -62,6 +63,7 @@ export function buildTaskListGroupDropPlan({ !!groupByPropertyId && normalizedTargetGroupKey !== sourceGroupKey; const groupByTaskProp = cleanGroupBy ? lookupMappingKey(cleanGroupBy) : null; const isListGrouping = !!cleanGroupBy && isListTypeProperty(cleanGroupBy); + const replacesListGroupingValue = groupByTaskProp === "projects"; const frontmatterKey = groupByPropertyId ? groupByPropertyId.replace(/^(note\.|file\.|task\.)/, "") : null; @@ -73,6 +75,7 @@ export function buildTaskListGroupDropPlan({ groupByTaskProp, isFormulaGrouping, isListGrouping, + replacesListGroupingValue, needsGroupUpdate, normalizedTargetGroupKey, sourceGroupKey, @@ -92,23 +95,31 @@ export function applyTaskListDropFrontmatterMutation({ }: ApplyTaskListDropFrontmatterMutationOptions): void { if (plan.needsGroupUpdate && plan.frontmatterKey) { if (plan.isListGrouping) { - const currentValue = frontmatter[plan.frontmatterKey]; - const currentValues = Array.isArray(currentValue) - ? currentValue - : currentValue - ? [currentValue] - : []; - const newValue = currentValues.filter((value) => value !== plan.sourceGroupKey); - if ( - plan.normalizedTargetGroupKey !== null && - !newValue.includes(plan.normalizedTargetGroupKey) - ) { - newValue.push(plan.normalizedTargetGroupKey); - } - if (newValue.length > 0) { - frontmatter[plan.frontmatterKey] = newValue; + if (plan.replacesListGroupingValue) { + if (plan.normalizedTargetGroupKey === null) { + delete frontmatter[plan.frontmatterKey]; + } else { + frontmatter[plan.frontmatterKey] = [plan.normalizedTargetGroupKey]; + } } else { - delete frontmatter[plan.frontmatterKey]; + const currentValue = frontmatter[plan.frontmatterKey]; + const currentValues = Array.isArray(currentValue) + ? currentValue + : currentValue + ? [currentValue] + : []; + const newValue = currentValues.filter((value) => value !== plan.sourceGroupKey); + if ( + plan.normalizedTargetGroupKey !== null && + !newValue.includes(plan.normalizedTargetGroupKey) + ) { + newValue.push(plan.normalizedTargetGroupKey); + } + if (newValue.length > 0) { + frontmatter[plan.frontmatterKey] = newValue; + } else { + delete frontmatter[plan.frontmatterKey]; + } } } else if (plan.normalizedTargetGroupKey === null) { delete frontmatter[plan.frontmatterKey]; @@ -153,20 +164,25 @@ export function buildTaskListDropSideEffectTask( const taskProperty = plan.groupByTaskProp; if (plan.isListGrouping) { - const originalValue = originalRecord[taskProperty]; - const currentValues = Array.isArray(originalValue) - ? [...originalValue] - : originalValue - ? [stringifyUnknown(originalValue)] - : []; - const nextValues = currentValues.filter((value) => value !== plan.sourceGroupKey); - if ( - plan.normalizedTargetGroupKey !== null && - !nextValues.includes(plan.normalizedTargetGroupKey) - ) { - nextValues.push(plan.normalizedTargetGroupKey); + if (plan.replacesListGroupingValue) { + updatedRecord[taskProperty] = + plan.normalizedTargetGroupKey === null ? [] : [plan.normalizedTargetGroupKey]; + } else { + const originalValue = originalRecord[taskProperty]; + const currentValues = Array.isArray(originalValue) + ? [...originalValue] + : originalValue + ? [stringifyUnknown(originalValue)] + : []; + const nextValues = currentValues.filter((value) => value !== plan.sourceGroupKey); + if ( + plan.normalizedTargetGroupKey !== null && + !nextValues.includes(plan.normalizedTargetGroupKey) + ) { + nextValues.push(plan.normalizedTargetGroupKey); + } + updatedRecord[taskProperty] = nextValues; } - updatedRecord[taskProperty] = nextValues; } else { updatedRecord[taskProperty] = plan.normalizedTargetGroupKey; } diff --git a/tests/unit/bases/taskListDropPlanning.test.ts b/tests/unit/bases/taskListDropPlanning.test.ts index 5d6d46441..ef5b29a09 100644 --- a/tests/unit/bases/taskListDropPlanning.test.ts +++ b/tests/unit/bases/taskListDropPlanning.test.ts @@ -9,6 +9,7 @@ const lookupMappingKey = (property: string): keyof FieldMapping | null => { const mappings: Partial> = { status: "status", contexts: "contexts", + projects: "projects", priority: "priority", }; return mappings[property] ?? null; @@ -99,6 +100,37 @@ describe("taskListDropPlanning", () => { expect(frontmatter).toEqual({}); }); + it("replaces all project assignments when moving between project groups", () => { + const plan = buildTaskListGroupDropPlan({ + groupByPropertyId: "note.projects", + sourceGroupKey: "Project A", + targetGroupKey: "Project B", + lookupMappingKey, + isListTypeProperty, + }); + const frontmatter: Record = { + projects: ["Project A", "Project C"], + }; + + applyTaskListDropFrontmatterMutation({ + frontmatter, + plan, + sortOrderField: "sort_order", + sortOrder: "tncccccccccc", + isRecurring: false, + dateModifiedField: "dateModified", + coerceGroupKeyForFrontmatter: (_property, groupKey) => groupKey, + updateCompletedDateInFrontmatter: jest.fn(), + getTimestamp: () => "2026-05-19T09:40:00+10:00", + }); + + expect(plan.replacesListGroupingValue).toBe(true); + expect(frontmatter).toEqual({ + projects: ["Project B"], + sort_order: "tncccccccccc", + }); + }); + it("coerces scalar group frontmatter and applies status derivative fields", () => { const plan = buildTaskListGroupDropPlan({ groupByPropertyId: "task.status", @@ -179,4 +211,29 @@ describe("taskListDropPlanning", () => { dateModified: "2026-05-19T09:43:00+10:00", }); }); + + it("builds a replacement side-effect snapshot for project group moves", () => { + const plan = buildTaskListGroupDropPlan({ + groupByPropertyId: "projects", + sourceGroupKey: "Project A", + targetGroupKey: "Project B", + lookupMappingKey, + isListTypeProperty, + }); + + const updatedTask = buildTaskListDropSideEffectTask( + createTask({ projects: ["Project A", "Project C"] }), + { + plan, + isCompletedStatus: () => false, + getTimestamp: () => "2026-05-19T09:44:00+10:00", + getCompletedDate: () => "2026-05-19", + } + ); + + expect(updatedTask).toMatchObject({ + projects: ["Project B"], + dateModified: "2026-05-19T09:44:00+10:00", + }); + }); }); From 16131eab027070b9e5e13f2f19019160f3ce6a8e Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Thu, 30 Jul 2026 10:14:56 -0600 Subject: [PATCH 26/55] Shift drag to add projects or tags to a task - drag between project groups replaces all projects. - shift+drag adds the destination project while preserving every existing project. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - drag between tag groups replaces all tags. - shift-drag adds the destination tag while preserving every existing tag. The Appearance & UI → Task Interaction group now includes “Project and tag drop behavior” with: - Dropping replaces values - Dropping adds values - Dropping replaces; Shift-drop adds — default --- src/bases/TaskListView.ts | 13 +- src/bases/taskListDropPlanning.ts | 26 +++- src/i18n/resources/en.ts | 10 ++ src/settings/defaults.ts | 1 + src/settings/tabs/appearanceTab.ts | 42 +++++- src/types/settings.ts | 3 + tests/unit/bases/taskListDropPlanning.test.ts | 127 ++++++++++++++++++ tests/unit/settings/SettingsDefaults.test.ts | 4 + 8 files changed, 220 insertions(+), 6 deletions(-) diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index 45604f643..12a5ba028 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -61,6 +61,7 @@ import { applyTaskListDropFrontmatterMutation, buildTaskListDropSideEffectTask, buildTaskListGroupDropPlan, + shouldPreserveTaskListGroupDropValues, } from "./taskListDropPlanning"; import { applySortOrderUpdatesToItems, @@ -1428,6 +1429,11 @@ export class TaskListView extends BasesViewBase { const draggedPath = this.draggedTaskPath; const sourceGroupKey = this.dragGroupKey; + const groupDropBehavior = this.plugin.settings.taskListGroupDropBehavior; + const preserveExistingListValues = shouldPreserveTaskListGroupDropValues( + groupDropBehavior, + e.shiftKey + ); const targetGroupKey = this.currentInsertionGroupKey; const targetVisiblePaths = this.getVisibleSortScopePathsForDrag(targetGroupKey); const insertionSegmentIndex = this.currentInsertionSegmentIndex; @@ -1457,7 +1463,8 @@ export class TaskListView extends BasesViewBase { dropTarget.above, targetGroupKey, sourceGroupKey, - targetVisiblePaths + targetVisiblePaths, + preserveExistingListValues ); })(); }); @@ -1469,7 +1476,8 @@ export class TaskListView extends BasesViewBase { above: boolean, targetGroupKey: string | null, sourceGroupKey: string | null, - targetVisiblePaths?: string[] + targetVisiblePaths?: string[], + preserveExistingListValues = false ): Promise { const groupByPropertyId = this.getGroupByPropertyId(); const reorderScopeKey = this.getReorderScopeQueueKey(targetGroupKey, groupByPropertyId); @@ -1478,6 +1486,7 @@ export class TaskListView extends BasesViewBase { groupByPropertyId, sourceGroupKey, targetGroupKey, + preserveExistingListValues, lookupMappingKey: (propertyName) => this.plugin.fieldMapper.lookupMappingKey(propertyName), isListTypeProperty: (propertyName) => this.isListTypeProperty(propertyName), diff --git a/src/bases/taskListDropPlanning.ts b/src/bases/taskListDropPlanning.ts index 6be85a383..7da0e6e62 100644 --- a/src/bases/taskListDropPlanning.ts +++ b/src/bases/taskListDropPlanning.ts @@ -1,4 +1,5 @@ import type { FieldMapping, TaskInfo } from "../types"; +import type { TaskListGroupDropBehavior } from "../types/settings"; import { stringifyUnknown } from "../utils/stringUtils"; import { stripPropertyPrefix } from "./sortOrderUtils"; @@ -10,6 +11,7 @@ export interface TaskListGroupDropPlan { isFormulaGrouping: boolean; isListGrouping: boolean; replacesListGroupingValue: boolean; + preservesListGroupingValues: boolean; needsGroupUpdate: boolean; normalizedTargetGroupKey: string | null; sourceGroupKey: string | null; @@ -19,6 +21,7 @@ export interface BuildTaskListGroupDropPlanOptions { groupByPropertyId: string | null; sourceGroupKey: string | null; targetGroupKey: string | null; + preserveExistingListValues?: boolean; lookupMappingKey: (frontmatterPropertyName: string) => keyof FieldMapping | null; isListTypeProperty: (propertyName: string) => boolean; } @@ -49,10 +52,18 @@ export interface BuildTaskListDropSideEffectTaskOptions { getCompletedDate: () => string; } +export function shouldPreserveTaskListGroupDropValues( + behavior: TaskListGroupDropBehavior, + shiftKey: boolean +): boolean { + return behavior === "add" || (behavior === "replace-shift-add" && shiftKey); +} + export function buildTaskListGroupDropPlan({ groupByPropertyId, sourceGroupKey, targetGroupKey, + preserveExistingListValues = false, lookupMappingKey, isListTypeProperty, }: BuildTaskListGroupDropPlanOptions): TaskListGroupDropPlan { @@ -63,7 +74,11 @@ export function buildTaskListGroupDropPlan({ !!groupByPropertyId && normalizedTargetGroupKey !== sourceGroupKey; const groupByTaskProp = cleanGroupBy ? lookupMappingKey(cleanGroupBy) : null; const isListGrouping = !!cleanGroupBy && isListTypeProperty(cleanGroupBy); - const replacesListGroupingValue = groupByTaskProp === "projects"; + const replacesOnStandardDrop = groupByTaskProp === "projects" || cleanGroupBy === "tags"; + const replacesListGroupingValue = + replacesOnStandardDrop && !preserveExistingListValues; + const preservesListGroupingValues = + replacesOnStandardDrop && preserveExistingListValues; const frontmatterKey = groupByPropertyId ? groupByPropertyId.replace(/^(note\.|file\.|task\.)/, "") : null; @@ -76,6 +91,7 @@ export function buildTaskListGroupDropPlan({ isFormulaGrouping, isListGrouping, replacesListGroupingValue, + preservesListGroupingValues, needsGroupUpdate, normalizedTargetGroupKey, sourceGroupKey, @@ -108,7 +124,9 @@ export function applyTaskListDropFrontmatterMutation({ : currentValue ? [currentValue] : []; - const newValue = currentValues.filter((value) => value !== plan.sourceGroupKey); + const newValue = plan.preservesListGroupingValues + ? [...currentValues] + : currentValues.filter((value) => value !== plan.sourceGroupKey); if ( plan.normalizedTargetGroupKey !== null && !newValue.includes(plan.normalizedTargetGroupKey) @@ -174,7 +192,9 @@ export function buildTaskListDropSideEffectTask( : originalValue ? [stringifyUnknown(originalValue)] : []; - const nextValues = currentValues.filter((value) => value !== plan.sourceGroupKey); + const nextValues = plan.preservesListGroupingValues + ? currentValues + : currentValues.filter((value) => value !== plan.sourceGroupKey); if ( plan.normalizedTargetGroupKey !== null && !nextValues.includes(plan.normalizedTargetGroupKey) diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 2a6f02170..120ef55e7 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -1037,6 +1037,16 @@ export const en: TranslationTree = { name: "Double-click action", description: "Action performed when double-clicking a task card", }, + groupDropBehavior: { + name: "Project and tag drop behavior", + description: + "Controls whether dragging tasks between project or tag groups replaces existing values or adds the destination value.", + options: { + replace: "Dropping replaces values", + add: "Dropping adds values", + replaceShiftAdd: "Dropping replaces; Shift-drop adds", + }, + }, actions: { edit: "Edit task", openNote: "Open note", diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 710a7c882..361777257 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -302,6 +302,7 @@ export const DEFAULT_SETTINGS: TaskNotesSettings = { singleClickAction: "edit", doubleClickAction: "openNote", + taskListGroupDropBehavior: "replace-shift-add", taskListShortcuts: DEFAULT_TASK_LIST_SHORTCUTS, // Autosuggest project card defaults projectAutosuggest: DEFAULT_PROJECT_AUTOSUGGEST, diff --git a/src/settings/tabs/appearanceTab.ts b/src/settings/tabs/appearanceTab.ts index 10c9a6c56..8fe8d2367 100644 --- a/src/settings/tabs/appearanceTab.ts +++ b/src/settings/tabs/appearanceTab.ts @@ -10,7 +10,10 @@ import { } from "../components/settingHelpers"; import { PropertySelectorModal } from "../../modals/PropertySelectorModal"; import { getAvailableProperties, getPropertyLabels } from "../../utils/propertyHelpers"; -import type { CalendarViewSettings } from "../../types/settings"; +import type { + CalendarViewSettings, + TaskListGroupDropBehavior, +} from "../../types/settings"; import { CALENDAR_END_TIME_MAX_HOUR, normalizeCalendarTimeValue } from "../../utils/calendarTime"; type CalendarDefaultView = CalendarViewSettings["defaultView"]; @@ -899,6 +902,43 @@ export function renderAppearanceTab( }, }) ); + + group.addSetting((setting) => + void configureDropdownSetting(setting, { + name: translate( + "settings.general.taskInteraction.groupDropBehavior.name" + ), + desc: translate( + "settings.general.taskInteraction.groupDropBehavior.description" + ), + options: [ + { + value: "replace", + label: translate( + "settings.general.taskInteraction.groupDropBehavior.options.replace" + ), + }, + { + value: "add", + label: translate( + "settings.general.taskInteraction.groupDropBehavior.options.add" + ), + }, + { + value: "replace-shift-add", + label: translate( + "settings.general.taskInteraction.groupDropBehavior.options.replaceShiftAdd" + ), + }, + ], + getValue: () => plugin.settings.taskListGroupDropBehavior, + setValue: async (value: string) => { + plugin.settings.taskListGroupDropBehavior = + value as TaskListGroupDropBehavior; + save(); + }, + }) + ); } ); } diff --git a/src/types/settings.ts b/src/types/settings.ts index 4a23932a1..679c40fd4 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -82,6 +82,8 @@ export interface NLPTriggersConfig { export type HideIdentifyingTagsMode = "all" | "exact-only"; +export type TaskListGroupDropBehavior = "replace" | "add" | "replace-shift-add"; + export interface ProjectAutosuggestSettings { enableFuzzy: boolean; rows: string[]; // up to 3 rows; each uses {property|flags} format @@ -151,6 +153,7 @@ export interface TaskNotesSettings { singleClickAction: "edit" | "openNote"; doubleClickAction: "edit" | "openNote" | "none"; + taskListGroupDropBehavior: TaskListGroupDropBehavior; // View-local task-list keyboard shortcuts taskListShortcuts: TaskListShortcutMap; // Inline task conversion settings diff --git a/tests/unit/bases/taskListDropPlanning.test.ts b/tests/unit/bases/taskListDropPlanning.test.ts index ef5b29a09..c6dd10ba7 100644 --- a/tests/unit/bases/taskListDropPlanning.test.ts +++ b/tests/unit/bases/taskListDropPlanning.test.ts @@ -2,6 +2,7 @@ import { applyTaskListDropFrontmatterMutation, buildTaskListDropSideEffectTask, buildTaskListGroupDropPlan, + shouldPreserveTaskListGroupDropValues, } from "../../../src/bases/taskListDropPlanning"; import type { FieldMapping, TaskInfo } from "../../../src/types"; @@ -28,6 +29,20 @@ const createTask = (overrides: Partial = {}): TaskInfo => ({ }); describe("taskListDropPlanning", () => { + it.each([ + ["replace", false, false], + ["replace", true, false], + ["add", false, true], + ["add", true, true], + ["replace-shift-add", false, false], + ["replace-shift-add", true, true], + ] as const)( + "resolves %s behavior with shift=%s to preserve=%s", + (behavior, shiftKey, expected) => { + expect(shouldPreserveTaskListGroupDropValues(behavior, shiftKey)).toBe(expected); + } + ); + it("marks formula grouping as read-only while preserving the stripped property for sorting", () => { const plan = buildTaskListGroupDropPlan({ groupByPropertyId: "formula.score", @@ -131,6 +146,94 @@ describe("taskListDropPlanning", () => { }); }); + it("preserves project assignments for an additive project group move", () => { + const plan = buildTaskListGroupDropPlan({ + groupByPropertyId: "note.projects", + sourceGroupKey: "Project A", + targetGroupKey: "Project B", + preserveExistingListValues: true, + lookupMappingKey, + isListTypeProperty, + }); + const frontmatter: Record = { + projects: ["Project A", "Project C"], + }; + + applyTaskListDropFrontmatterMutation({ + frontmatter, + plan, + sortOrderField: "sort_order", + sortOrder: null, + isRecurring: false, + dateModifiedField: "dateModified", + coerceGroupKeyForFrontmatter: (_property, groupKey) => groupKey, + updateCompletedDateInFrontmatter: jest.fn(), + getTimestamp: () => "2026-05-19T09:40:00+10:00", + }); + + expect(plan.replacesListGroupingValue).toBe(false); + expect(frontmatter).toEqual({ + projects: ["Project A", "Project C", "Project B"], + }); + }); + + it("replaces all tags on a left-button tag group move", () => { + const plan = buildTaskListGroupDropPlan({ + groupByPropertyId: "note.tags", + sourceGroupKey: "work", + targetGroupKey: "focus", + lookupMappingKey, + isListTypeProperty, + }); + const frontmatter: Record = { + tags: ["work", "home"], + }; + + applyTaskListDropFrontmatterMutation({ + frontmatter, + plan, + sortOrderField: "sort_order", + sortOrder: null, + isRecurring: false, + dateModifiedField: "dateModified", + coerceGroupKeyForFrontmatter: (_property, groupKey) => groupKey, + updateCompletedDateInFrontmatter: jest.fn(), + getTimestamp: () => "2026-05-19T09:40:00+10:00", + }); + + expect(plan.replacesListGroupingValue).toBe(true); + expect(frontmatter).toEqual({ tags: ["focus"] }); + }); + + it("preserves tags for an additive tag group move", () => { + const plan = buildTaskListGroupDropPlan({ + groupByPropertyId: "tags", + sourceGroupKey: "work", + targetGroupKey: "focus", + preserveExistingListValues: true, + lookupMappingKey, + isListTypeProperty, + }); + const frontmatter: Record = { + tags: ["work", "home"], + }; + + applyTaskListDropFrontmatterMutation({ + frontmatter, + plan, + sortOrderField: "sort_order", + sortOrder: null, + isRecurring: false, + dateModifiedField: "dateModified", + coerceGroupKeyForFrontmatter: (_property, groupKey) => groupKey, + updateCompletedDateInFrontmatter: jest.fn(), + getTimestamp: () => "2026-05-19T09:40:00+10:00", + }); + + expect(plan.replacesListGroupingValue).toBe(false); + expect(frontmatter).toEqual({ tags: ["work", "home", "focus"] }); + }); + it("coerces scalar group frontmatter and applies status derivative fields", () => { const plan = buildTaskListGroupDropPlan({ groupByPropertyId: "task.status", @@ -236,4 +339,28 @@ describe("taskListDropPlanning", () => { dateModified: "2026-05-19T09:44:00+10:00", }); }); + + it("does not build a TaskInfo side-effect snapshot for additive native tag grouping", () => { + const plan = buildTaskListGroupDropPlan({ + groupByPropertyId: "tags", + sourceGroupKey: "work", + targetGroupKey: "focus", + preserveExistingListValues: true, + lookupMappingKey, + isListTypeProperty, + }); + + const updatedTask = buildTaskListDropSideEffectTask( + createTask({ tags: ["work", "home"] }), + { + plan, + isCompletedStatus: () => false, + getTimestamp: () => "2026-05-19T09:45:00+10:00", + getCompletedDate: () => "2026-05-19", + } + ); + + expect(plan.groupByTaskProp).toBeNull(); + expect(updatedTask).toBeNull(); + }); }); diff --git a/tests/unit/settings/SettingsDefaults.test.ts b/tests/unit/settings/SettingsDefaults.test.ts index cac13e0f6..f5fe0527f 100644 --- a/tests/unit/settings/SettingsDefaults.test.ts +++ b/tests/unit/settings/SettingsDefaults.test.ts @@ -4,5 +4,9 @@ describe('Settings defaults', () => { test('viewsButtonAlignment defaults to right', () => { expect(DEFAULT_SETTINGS.viewsButtonAlignment).toBe('right'); }); + + test('task-list project and tag drops replace unless Shift is held', () => { + expect(DEFAULT_SETTINGS.taskListGroupDropBehavior).toBe('replace-shift-add'); + }); }); From b43b92323f4100269e0bde3d7ddee57e12a0ee5b Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Thu, 30 Jul 2026 10:44:51 -0600 Subject: [PATCH 27/55] Drag any list prop, use ctrl/option drag instead of shift - The setting now applies to every list-valued grouping property, including contexts, aliases, and custom Obsidian list properties. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renamed the setting to “List-property drop behavior” and generalized its help text. - Replacement mode now replaces the entire list for any list property. - Additive mode preserves all existing values and adds the destination. - Changed native drag effects from move-only to allow all effects. Additive drops advertise copy; replacement drops advertise move. This permits modifier keys during drag initiation as intended by the HTML drag API. MDN drag operations --- src/bases/TaskListView.ts | 25 ++++-- src/bases/taskListDropPlanning.ts | 11 +-- src/i18n/resources/en.ts | 11 +-- src/settings/defaults.ts | 2 +- src/settings/tabs/appearanceTab.ts | 4 +- src/types/settings.ts | 2 +- tests/unit/bases/taskListDropPlanning.test.ts | 79 ++++++++++++++++--- tests/unit/settings/SettingsDefaults.test.ts | 4 +- 8 files changed, 106 insertions(+), 32 deletions(-) diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index 12a5ba028..d5516ed4e 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion -- Legacy Bases view rendering narrows DOM references through lifecycle checks. */ -import { Menu, Notice, Scope, TFile, setIcon } from "obsidian"; +import { Menu, Notice, Platform, Scope, TFile, setIcon } from "obsidian"; import type { BasesView, BasesViewFactory } from "obsidian"; import TaskNotesPlugin from "../main"; import { BasesViewBase } from "./BasesViewBase"; @@ -1039,7 +1039,9 @@ export class TaskListView extends BasesViewBase { this.dragGroupKey = groupKey; cardEl.classList.add("task-card--dragging"); if (e.dataTransfer) { - e.dataTransfer.effectAllowed = "move"; + // Modifier keys can request a copy effect before dragstart. Allow all + // effects so the platform copy modifier can initiate an additive drag. + e.dataTransfer.effectAllowed = "all"; e.dataTransfer.setData("text/plain", task.path); } @@ -1385,7 +1387,9 @@ export class TaskListView extends BasesViewBase { this.itemsContainer.addEventListener("dragenter", (e: DragEvent) => { if (!this.draggedTaskPath) return; e.preventDefault(); - if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; + if (e.dataTransfer) { + e.dataTransfer.dropEffect = this.getTaskListDropEffect(e); + } }); this.itemsContainer.addEventListener("dragover", (e: DragEvent) => { @@ -1394,7 +1398,9 @@ export class TaskListView extends BasesViewBase { // Always accept – must be unconditional so the browser keeps // the drop zone active even when the cursor is between cards. e.preventDefault(); - if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; + if (e.dataTransfer) { + e.dataTransfer.dropEffect = this.getTaskListDropEffect(e); + } // Throttle visual updates via rAF this.pendingDragClientY = e.clientY; @@ -1432,7 +1438,7 @@ export class TaskListView extends BasesViewBase { const groupDropBehavior = this.plugin.settings.taskListGroupDropBehavior; const preserveExistingListValues = shouldPreserveTaskListGroupDropValues( groupDropBehavior, - e.shiftKey + Platform.isMacOS ? e.altKey : e.ctrlKey ); const targetGroupKey = this.currentInsertionGroupKey; const targetVisiblePaths = this.getVisibleSortScopePathsForDrag(targetGroupKey); @@ -1470,6 +1476,15 @@ export class TaskListView extends BasesViewBase { }); } + private getTaskListDropEffect(event: DragEvent): "copy" | "move" { + return shouldPreserveTaskListGroupDropValues( + this.plugin.settings.taskListGroupDropBehavior, + Platform.isMacOS ? event.altKey : event.ctrlKey + ) + ? "copy" + : "move"; + } + private async handleSortOrderDrop( draggedPath: string, targetPath: string, diff --git a/src/bases/taskListDropPlanning.ts b/src/bases/taskListDropPlanning.ts index 7da0e6e62..82f46b8d3 100644 --- a/src/bases/taskListDropPlanning.ts +++ b/src/bases/taskListDropPlanning.ts @@ -54,9 +54,9 @@ export interface BuildTaskListDropSideEffectTaskOptions { export function shouldPreserveTaskListGroupDropValues( behavior: TaskListGroupDropBehavior, - shiftKey: boolean + additiveModifierKey: boolean ): boolean { - return behavior === "add" || (behavior === "replace-shift-add" && shiftKey); + return behavior === "add" || (behavior === "replace-modifier-add" && additiveModifierKey); } export function buildTaskListGroupDropPlan({ @@ -74,11 +74,8 @@ export function buildTaskListGroupDropPlan({ !!groupByPropertyId && normalizedTargetGroupKey !== sourceGroupKey; const groupByTaskProp = cleanGroupBy ? lookupMappingKey(cleanGroupBy) : null; const isListGrouping = !!cleanGroupBy && isListTypeProperty(cleanGroupBy); - const replacesOnStandardDrop = groupByTaskProp === "projects" || cleanGroupBy === "tags"; - const replacesListGroupingValue = - replacesOnStandardDrop && !preserveExistingListValues; - const preservesListGroupingValues = - replacesOnStandardDrop && preserveExistingListValues; + const replacesListGroupingValue = isListGrouping && !preserveExistingListValues; + const preservesListGroupingValues = isListGrouping && preserveExistingListValues; const frontmatterKey = groupByPropertyId ? groupByPropertyId.replace(/^(note\.|file\.|task\.)/, "") : null; diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 120ef55e7..545b41a47 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -1038,13 +1038,14 @@ export const en: TranslationTree = { description: "Action performed when double-clicking a task card", }, groupDropBehavior: { - name: "Project and tag drop behavior", + name: "List-property drag behavior", description: - "Controls whether dragging tasks between project or tag groups replaces existing values or adds the destination value.", + // eslint-disable-next-line obsidianmd/ui/sentence-case-locale-module -- Platform and key names are proper nouns. + "Choose whether dragging a task between list-property groups (for example, projects or tags) moves it to the destination or adds the destination while keeping its current groups. Hold Ctrl on Windows or Linux, or Option on macOS, to add with the modifier mode.", options: { - replace: "Dropping replaces values", - add: "Dropping adds values", - replaceShiftAdd: "Dropping replaces; Shift-drop adds", + replace: "Move to the destination group", + add: "Add to the destination group", + replaceModifierAdd: "Move; copy-modifier drag adds", }, }, actions: { diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 361777257..2ffe7a815 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -302,7 +302,7 @@ export const DEFAULT_SETTINGS: TaskNotesSettings = { singleClickAction: "edit", doubleClickAction: "openNote", - taskListGroupDropBehavior: "replace-shift-add", + taskListGroupDropBehavior: "replace-modifier-add", taskListShortcuts: DEFAULT_TASK_LIST_SHORTCUTS, // Autosuggest project card defaults projectAutosuggest: DEFAULT_PROJECT_AUTOSUGGEST, diff --git a/src/settings/tabs/appearanceTab.ts b/src/settings/tabs/appearanceTab.ts index 8fe8d2367..50261ff8e 100644 --- a/src/settings/tabs/appearanceTab.ts +++ b/src/settings/tabs/appearanceTab.ts @@ -925,9 +925,9 @@ export function renderAppearanceTab( ), }, { - value: "replace-shift-add", + value: "replace-modifier-add", label: translate( - "settings.general.taskInteraction.groupDropBehavior.options.replaceShiftAdd" + "settings.general.taskInteraction.groupDropBehavior.options.replaceModifierAdd" ), }, ], diff --git a/src/types/settings.ts b/src/types/settings.ts index 679c40fd4..adb5edc8a 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -82,7 +82,7 @@ export interface NLPTriggersConfig { export type HideIdentifyingTagsMode = "all" | "exact-only"; -export type TaskListGroupDropBehavior = "replace" | "add" | "replace-shift-add"; +export type TaskListGroupDropBehavior = "replace" | "add" | "replace-modifier-add"; export interface ProjectAutosuggestSettings { enableFuzzy: boolean; diff --git a/tests/unit/bases/taskListDropPlanning.test.ts b/tests/unit/bases/taskListDropPlanning.test.ts index c6dd10ba7..1fa9e0c81 100644 --- a/tests/unit/bases/taskListDropPlanning.test.ts +++ b/tests/unit/bases/taskListDropPlanning.test.ts @@ -34,12 +34,14 @@ describe("taskListDropPlanning", () => { ["replace", true, false], ["add", false, true], ["add", true, true], - ["replace-shift-add", false, false], - ["replace-shift-add", true, true], + ["replace-modifier-add", false, false], + ["replace-modifier-add", true, true], ] as const)( - "resolves %s behavior with shift=%s to preserve=%s", - (behavior, shiftKey, expected) => { - expect(shouldPreserveTaskListGroupDropValues(behavior, shiftKey)).toBe(expected); + "resolves %s behavior with additive modifier=%s to preserve=%s", + (behavior, additiveModifierKey, expected) => { + expect(shouldPreserveTaskListGroupDropValues(behavior, additiveModifierKey)).toBe( + expected + ); } ); @@ -58,7 +60,7 @@ describe("taskListDropPlanning", () => { expect(plan.groupByTaskProp).toBeNull(); }); - it("moves list-valued group frontmatter and writes the new sort order", () => { + it("replaces list-valued group frontmatter and writes the new sort order", () => { const plan = buildTaskListGroupDropPlan({ groupByPropertyId: "note.contexts", sourceGroupKey: "work", @@ -83,11 +85,44 @@ describe("taskListDropPlanning", () => { }); expect(frontmatter).toEqual({ - contexts: ["home", "deep-work"], + contexts: ["deep-work"], sort_order: "tnbbbbbbbbbb", }); }); + it("preserves existing values for an additive custom list-property move", () => { + const plan = buildTaskListGroupDropPlan({ + groupByPropertyId: "note.reviewers", + sourceGroupKey: "Alice", + targetGroupKey: "Bob", + preserveExistingListValues: true, + lookupMappingKey, + isListTypeProperty: (property) => + property === "reviewers" || isListTypeProperty(property), + }); + const frontmatter: Record = { + reviewers: ["Alice", "Carol"], + }; + + applyTaskListDropFrontmatterMutation({ + frontmatter, + plan, + sortOrderField: "sort_order", + sortOrder: null, + isRecurring: false, + dateModifiedField: "dateModified", + coerceGroupKeyForFrontmatter: (_property, groupKey) => groupKey, + updateCompletedDateInFrontmatter: jest.fn(), + getTimestamp: () => "2026-05-19T09:40:00+10:00", + }); + + expect(plan.groupByTaskProp).toBeNull(); + expect(plan.preservesListGroupingValues).toBe(true); + expect(frontmatter).toEqual({ + reviewers: ["Alice", "Carol", "Bob"], + }); + }); + it("removes list-valued group frontmatter when moving to None leaves no values", () => { const plan = buildTaskListGroupDropPlan({ groupByPropertyId: "contexts", @@ -290,7 +325,7 @@ describe("taskListDropPlanning", () => { }); }); - it("builds side-effect task snapshots for list-valued group moves", () => { + it("builds replacement side-effect task snapshots for list-valued group moves", () => { const plan = buildTaskListGroupDropPlan({ groupByPropertyId: "contexts", sourceGroupKey: "work", @@ -310,11 +345,37 @@ describe("taskListDropPlanning", () => { ); expect(updatedTask).toMatchObject({ - contexts: ["home", "focus"], + contexts: ["focus"], dateModified: "2026-05-19T09:43:00+10:00", }); }); + it("builds additive side-effect task snapshots for list-valued group moves", () => { + const plan = buildTaskListGroupDropPlan({ + groupByPropertyId: "contexts", + sourceGroupKey: "work", + targetGroupKey: "focus", + preserveExistingListValues: true, + lookupMappingKey, + isListTypeProperty, + }); + + const updatedTask = buildTaskListDropSideEffectTask( + createTask({ contexts: ["work", "home"] }), + { + plan, + isCompletedStatus: () => false, + getTimestamp: () => "2026-05-19T09:43:30+10:00", + getCompletedDate: () => "2026-05-19", + } + ); + + expect(updatedTask).toMatchObject({ + contexts: ["work", "home", "focus"], + dateModified: "2026-05-19T09:43:30+10:00", + }); + }); + it("builds a replacement side-effect snapshot for project group moves", () => { const plan = buildTaskListGroupDropPlan({ groupByPropertyId: "projects", diff --git a/tests/unit/settings/SettingsDefaults.test.ts b/tests/unit/settings/SettingsDefaults.test.ts index f5fe0527f..fb9ee5106 100644 --- a/tests/unit/settings/SettingsDefaults.test.ts +++ b/tests/unit/settings/SettingsDefaults.test.ts @@ -5,8 +5,8 @@ describe('Settings defaults', () => { expect(DEFAULT_SETTINGS.viewsButtonAlignment).toBe('right'); }); - test('task-list project and tag drops replace unless Shift is held', () => { - expect(DEFAULT_SETTINGS.taskListGroupDropBehavior).toBe('replace-shift-add'); + test('task-list list-property drops move unless the copy modifier is held', () => { + expect(DEFAULT_SETTINGS.taskListGroupDropBehavior).toBe('replace-modifier-add'); }); }); From 1d2dbe461e570e9e308a73e0eb8213580afd76f8 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Thu, 30 Jul 2026 11:03:02 -0600 Subject: [PATCH 28/55] Drag drop sets projects as wikilinks consistent with modal - Project-group drops now resolve the project file and generate the same canonical project reference used by the task modal. - Respects the useFrontmatterMarkdownLinks setting; otherwise writes wikilinks such as "[[Project Name]]". --- PORTING_PLAN.md | 3 +- src/bases/TaskListView.ts | 35 ++++++++++++++++++- src/bases/taskListDropPlanning.ts | 15 ++++++-- src/modals/TaskModal.ts | 6 ++-- src/utils/linkUtils.ts | 9 +++++ tests/unit/bases/taskListDropPlanning.test.ts | 4 ++- 6 files changed, 62 insertions(+), 10 deletions(-) diff --git a/PORTING_PLAN.md b/PORTING_PLAN.md index e0f771234..38f01bb7a 100644 --- a/PORTING_PLAN.md +++ b/PORTING_PLAN.md @@ -131,7 +131,7 @@ The safest delivery order is: Jira core mapping tests → Jira UI/import flow ### 2.7 Task view project assignment editing - [x] Add project dialog can remove projects in addition to adding them. (I had implemented this for TaskNotes version 3.x in commit `095a97a315d125c3c5dda9c05a8464eb64a6e35e`, although that implementation had some issues so be circumspect about whether this approach still applies.) -- [ ] Dropping a task into a different project (and probably any other list-valued grouping property -- list valued like projects as opposed to single value at a time like status) assigns the project without a link in the yaml, e.g. +- [x] Dropping a task into a different project (and probably any other list-valued grouping property -- list valued like projects as opposed to single value at a time like status) assigns the project without a link in the yaml, e.g. ```yml projects:   - Make TaskNotes into Linear @@ -143,6 +143,7 @@ projects: ``` The dialog editor is probably right. Update the code so that they both use the same method to assign a project -- either raw name or link -- spreferably via the same code path. If one implementation or the other is more correct, use the correct implementation. - [x] When grouping by project, dragging a task from one project and dropping into another should overwrite the tasks projects with the dropped project. Currently it sometimes moves the task to a new project, and sometimes it just assigns the new project and preserves the old project. This behavior already seems to work properly for dragging between single-value properties like status, but not for projects which has a list of values. +- [ ] When multiple tasks are selected, dropping into a new group edits only the task under the cursor, not the other tasks. Edits should modify all selected tasks. For example, if three tasks are selected when dropping a task into a new project group, all three tasks should be assigned to the new project. --- diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index d5516ed4e..392f2dbef 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -3,7 +3,7 @@ import { Menu, Notice, Platform, Scope, TFile, setIcon } from "obsidian"; import type { BasesView, BasesViewFactory } from "obsidian"; import TaskNotesPlugin from "../main"; import { BasesViewBase } from "./BasesViewBase"; -import { TaskInfo } from "../types"; +import { TaskInfo, type FieldMapping } from "../types"; import { identifyTaskNotesFromBasesData } from "./helpers"; import { createTaskCard, showTaskContextMenu, type TaskCardOptions } from "../ui/TaskCard"; import { renderGroupTitle } from "./groupTitleRenderer"; @@ -25,6 +25,7 @@ import { createUTCDateFromLocalCalendarDate, } from "../utils/dateUtils"; import { stringifyUnknown } from "../utils/stringUtils"; +import { generateProjectReference, parseLinkToPath } from "../utils/linkUtils"; import { formatTasksForClipboard } from "../utils/taskClipboard"; import { VirtualScroller } from "../utils/VirtualScroller"; import { @@ -911,6 +912,32 @@ export class TaskListView extends BasesViewBase { ); } + private normalizeListGroupValueForDrop( + taskProperty: keyof FieldMapping | null, + groupValue: string, + sourcePath: string + ): string { + if (taskProperty !== "projects") { + return groupValue; + } + + const projectPath = parseLinkToPath(groupValue); + const projectFile = this.plugin.app.metadataCache.getFirstLinkpathDest( + projectPath, + sourcePath + ); + if (!(projectFile instanceof TFile)) { + return groupValue; + } + + return generateProjectReference( + this.plugin.app, + projectFile, + sourcePath, + this.plugin.settings.useFrontmatterMarkdownLinks + ); + } + private async confirmLargeReorder( editCount: number, targetGroupKey: string | null @@ -1505,6 +1532,12 @@ export class TaskListView extends BasesViewBase { lookupMappingKey: (propertyName) => this.plugin.fieldMapper.lookupMappingKey(propertyName), isListTypeProperty: (propertyName) => this.isListTypeProperty(propertyName), + normalizeListGroupValue: (taskProperty, _propertyName, groupValue) => + this.normalizeListGroupValueForDrop( + taskProperty, + groupValue, + draggedPath + ), }); if (groupDropPlan.isFormulaGrouping) { diff --git a/src/bases/taskListDropPlanning.ts b/src/bases/taskListDropPlanning.ts index 82f46b8d3..47baedaf1 100644 --- a/src/bases/taskListDropPlanning.ts +++ b/src/bases/taskListDropPlanning.ts @@ -24,6 +24,11 @@ export interface BuildTaskListGroupDropPlanOptions { preserveExistingListValues?: boolean; lookupMappingKey: (frontmatterPropertyName: string) => keyof FieldMapping | null; isListTypeProperty: (propertyName: string) => boolean; + normalizeListGroupValue?: ( + taskProperty: keyof FieldMapping | null, + propertyName: string, + value: string + ) => string; } export interface ApplyTaskListDropFrontmatterMutationOptions { @@ -66,14 +71,18 @@ export function buildTaskListGroupDropPlan({ preserveExistingListValues = false, lookupMappingKey, isListTypeProperty, + normalizeListGroupValue, }: BuildTaskListGroupDropPlanOptions): TaskListGroupDropPlan { const cleanGroupBy = groupByPropertyId ? stripPropertyPrefix(groupByPropertyId) : null; const isFormulaGrouping = !!groupByPropertyId?.startsWith("formula."); - const normalizedTargetGroupKey = targetGroupKey === "None" ? null : targetGroupKey; - const needsGroupUpdate = - !!groupByPropertyId && normalizedTargetGroupKey !== sourceGroupKey; + const rawTargetGroupKey = targetGroupKey === "None" ? null : targetGroupKey; + const needsGroupUpdate = !!groupByPropertyId && rawTargetGroupKey !== sourceGroupKey; const groupByTaskProp = cleanGroupBy ? lookupMappingKey(cleanGroupBy) : null; const isListGrouping = !!cleanGroupBy && isListTypeProperty(cleanGroupBy); + const normalizedTargetGroupKey = + rawTargetGroupKey !== null && isListGrouping && cleanGroupBy && normalizeListGroupValue + ? normalizeListGroupValue(groupByTaskProp, cleanGroupBy, rawTargetGroupKey) + : rawTargetGroupKey; const replacesListGroupingValue = isListGrouping && !preserveExistingListValues; const preservesListGroupingValues = isListGrouping && preserveExistingListValues; const frontmatterKey = groupByPropertyId diff --git a/src/modals/TaskModal.ts b/src/modals/TaskModal.ts index 7635f8a3c..c66427f50 100644 --- a/src/modals/TaskModal.ts +++ b/src/modals/TaskModal.ts @@ -19,7 +19,7 @@ import { ProjectSelectModal } from "./ProjectSelectModal"; import { TaskDependency, Reminder } from "../types"; import { DEFAULT_DEPENDENCY_RELTYPE, formatDependencyLink } from "../utils/dependencyUtils"; import { type LinkServices } from "../ui/renderers/linkRenderer"; -import { generateLink } from "../utils/linkUtils"; +import { generateProjectReference } from "../utils/linkUtils"; import type { EmbeddableMarkdownEditor } from "../editor/EmbeddableMarkdownEditor"; import { createTaskModalBlockedByField, @@ -1103,12 +1103,10 @@ export abstract class TaskModal extends Modal { } protected buildProjectReference(targetFile: TFile, sourcePath: string): string { - return generateLink( + return generateProjectReference( this.app, targetFile, sourcePath, - "", - "", this.plugin.settings.useFrontmatterMarkdownLinks ); } diff --git a/src/utils/linkUtils.ts b/src/utils/linkUtils.ts index 440488fd1..ea1c982cb 100644 --- a/src/utils/linkUtils.ts +++ b/src/utils/linkUtils.ts @@ -229,6 +229,15 @@ export function generateLink( return link; } +export function generateProjectReference( + app: App, + targetFile: TFile, + sourcePath: string, + useMarkdownLinks: boolean +): string { + return generateLink(app, targetFile, sourcePath, "", "", useMarkdownLinks); +} + /** * Generate a link with the file's basename as the alias. * Useful for creating links that display the file name. diff --git a/tests/unit/bases/taskListDropPlanning.test.ts b/tests/unit/bases/taskListDropPlanning.test.ts index 1fa9e0c81..90bb71eb4 100644 --- a/tests/unit/bases/taskListDropPlanning.test.ts +++ b/tests/unit/bases/taskListDropPlanning.test.ts @@ -157,6 +157,8 @@ describe("taskListDropPlanning", () => { targetGroupKey: "Project B", lookupMappingKey, isListTypeProperty, + normalizeListGroupValue: (taskProperty, _propertyName, value) => + taskProperty === "projects" ? `[[${value}]]` : value, }); const frontmatter: Record = { projects: ["Project A", "Project C"], @@ -176,7 +178,7 @@ describe("taskListDropPlanning", () => { expect(plan.replacesListGroupingValue).toBe(true); expect(frontmatter).toEqual({ - projects: ["Project B"], + projects: ["[[Project B]]"], sort_order: "tncccccccccc", }); }); From a295a291860c8bede65547f6c6806165fbf41b42 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Thu, 30 Jul 2026 11:13:30 -0600 Subject: [PATCH 29/55] Drag/drop edits apply to all selected tasks - Dragging a selected task moves all selected tasks that are currently visible. - Filtered-out selected tasks are not modified. - Dragging an unselected task moves only that task. - Every affected task receives the destination group mutation and property- change side effects. - The card under the pointer determines the insertion position. - Selection remains intact after the drop. --- PORTING_PLAN.md | 2 +- src/bases/TaskListView.ts | 208 +++++++++++------- src/bases/taskListTargetResolver.ts | 28 +++ .../unit/bases/taskListTargetResolver.test.ts | 43 +++- 4 files changed, 195 insertions(+), 86 deletions(-) diff --git a/PORTING_PLAN.md b/PORTING_PLAN.md index 38f01bb7a..fa8087f05 100644 --- a/PORTING_PLAN.md +++ b/PORTING_PLAN.md @@ -143,7 +143,7 @@ projects: ``` The dialog editor is probably right. Update the code so that they both use the same method to assign a project -- either raw name or link -- spreferably via the same code path. If one implementation or the other is more correct, use the correct implementation. - [x] When grouping by project, dragging a task from one project and dropping into another should overwrite the tasks projects with the dropped project. Currently it sometimes moves the task to a new project, and sometimes it just assigns the new project and preserves the old project. This behavior already seems to work properly for dragging between single-value properties like status, but not for projects which has a list of values. -- [ ] When multiple tasks are selected, dropping into a new group edits only the task under the cursor, not the other tasks. Edits should modify all selected tasks. For example, if three tasks are selected when dropping a task into a new project group, all three tasks should be assigned to the new project. +- [x] When multiple tasks are selected, dropping into a new group edits only the task under the cursor, not the other tasks. Edits should modify all selected tasks. For example, if three tasks are selected when dropping a task into a new project group, all three tasks should be assigned to the new project. --- diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index 392f2dbef..6c7952ea8 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -72,7 +72,10 @@ import { } from "./manualOrderState"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; import { TaskListFocusController } from "./TaskListFocusController"; -import { resolveTaskListTargetPaths } from "./taskListTargetResolver"; +import { + resolveTaskListDragPaths, + resolveTaskListTargetPaths, +} from "./taskListTargetResolver"; import { resolveTaskListKeyboardAction, taskListShortcutToScopeBinding, @@ -188,6 +191,7 @@ export class TaskListView extends BasesViewBase { // Drag-to-reorder state private basesController: TaskListController; private draggedTaskPath: string | null = null; + private draggedTaskPaths: string[] = []; private dragGroupKey: string | null = null; private currentInsertionGroupKey: string | null = null; private currentInsertionSegmentIndex = -1; @@ -1063,6 +1067,11 @@ export class TaskListView extends BasesViewBase { } this.draggedTaskPath = task.path; + this.draggedTaskPaths = resolveTaskListDragPaths( + this.plugin.taskSelectionService, + task.path, + this.currentVisibleTaskPaths + ); this.dragGroupKey = groupKey; cardEl.classList.add("task-card--dragging"); if (e.dataTransfer) { @@ -1153,6 +1162,7 @@ export class TaskListView extends BasesViewBase { this.containerEl.ownerDocument.body.classList.remove("tn-drag-active"); this.draggedTaskPath = null; + this.draggedTaskPaths = []; this.dragGroupKey = null; this.currentInsertionGroupKey = null; this.currentInsertionSegmentIndex = -1; @@ -1461,6 +1471,7 @@ export class TaskListView extends BasesViewBase { return; const draggedPath = this.draggedTaskPath; + const draggedPaths = [...this.draggedTaskPaths]; const sourceGroupKey = this.dragGroupKey; const groupDropBehavior = this.plugin.settings.taskListGroupDropBehavior; const preserveExistingListValues = shouldPreserveTaskListGroupDropValues( @@ -1484,6 +1495,7 @@ export class TaskListView extends BasesViewBase { this.cleanupDragShift(); this.draggedTaskPath = null; + this.draggedTaskPaths = []; this.dragGroupKey = null; this.currentInsertionGroupKey = null; this.currentInsertionSegmentIndex = -1; @@ -1497,7 +1509,8 @@ export class TaskListView extends BasesViewBase { targetGroupKey, sourceGroupKey, targetVisiblePaths, - preserveExistingListValues + preserveExistingListValues, + draggedPaths ); })(); }); @@ -1519,26 +1532,47 @@ export class TaskListView extends BasesViewBase { targetGroupKey: string | null, sourceGroupKey: string | null, targetVisiblePaths?: string[], - preserveExistingListValues = false + preserveExistingListValues = false, + draggedPaths: string[] = [draggedPath] ): Promise { const groupByPropertyId = this.getGroupByPropertyId(); const reorderScopeKey = this.getReorderScopeQueueKey(targetGroupKey, groupByPropertyId); await this.dropQueue.enqueue(reorderScopeKey, async () => { - const groupDropPlan = buildTaskListGroupDropPlan({ - groupByPropertyId, - sourceGroupKey, - targetGroupKey, - preserveExistingListValues, - lookupMappingKey: (propertyName) => - this.plugin.fieldMapper.lookupMappingKey(propertyName), - isListTypeProperty: (propertyName) => this.isListTypeProperty(propertyName), - normalizeListGroupValue: (taskProperty, _propertyName, groupValue) => - this.normalizeListGroupValueForDrop( - taskProperty, - groupValue, - draggedPath - ), - }); + const pathsToUpdate = Array.from( + new Set([draggedPath, ...draggedPaths.filter((path) => path !== draggedPath)]) + ); + const groupDropPlans = new Map( + pathsToUpdate.map((path) => { + const pathSourceGroupKey = this.taskGroupKeys.has(path) + ? (this.taskGroupKeys.get(path) ?? null) + : sourceGroupKey; + return [ + path, + buildTaskListGroupDropPlan({ + groupByPropertyId, + sourceGroupKey: pathSourceGroupKey, + targetGroupKey, + preserveExistingListValues, + lookupMappingKey: (propertyName) => + this.plugin.fieldMapper.lookupMappingKey(propertyName), + isListTypeProperty: (propertyName) => + this.isListTypeProperty(propertyName), + normalizeListGroupValue: ( + taskProperty, + _propertyName, + groupValue + ) => + this.normalizeListGroupValueForDrop( + taskProperty, + groupValue, + path + ), + }), + ] as const; + }) + ); + const groupDropPlan = groupDropPlans.get(draggedPath); + if (!groupDropPlan) return; if (groupDropPlan.isFormulaGrouping) { new Notice( @@ -1564,7 +1598,7 @@ export class TaskListView extends BasesViewBase { ); if (sortOrderPlan.sortOrder === null) return; - const totalEditedNotes = sortOrderPlan.additionalWrites.length + 1; + const totalEditedNotes = sortOrderPlan.additionalWrites.length + pathsToUpdate.length; if (totalEditedNotes > this.LARGE_REORDER_WARNING_THRESHOLD) { const confirmed = await this.confirmLargeReorder(totalEditedNotes, targetGroupKey); if (!confirmed) return; @@ -1577,84 +1611,90 @@ export class TaskListView extends BasesViewBase { return; } - const file = this.plugin.app.vault.getAbstractFileByPath(draggedPath); - if (!file || !(file instanceof TFile)) { - this.debouncedRefresh(); - return; - } - const sortOrderField = this.plugin.settings.fieldMapping.sortOrder; await applySortOrderPlan(draggedPath, sortOrderPlan, this.plugin, { includeDragged: false, }); - // Single atomic write: group property + sort_order + derivative fields - await this.plugin.app.fileManager.processFrontMatter(file, (fm) => { - applyTaskListDropFrontmatterMutation({ - frontmatter: fm, - plan: groupDropPlan, - sortOrderField, - sortOrder: sortOrderPlan.sortOrder, - isRecurring: !!this.taskInfoCache.get(draggedPath)?.recurrence, - dateModifiedField: this.plugin.fieldMapper.toUserField("dateModified"), - coerceGroupKeyForFrontmatter: (property, groupKey) => - this.coerceGroupKeyForFrontmatter(property, groupKey), - updateCompletedDateInFrontmatter: (frontmatter, status, isRecurring) => - this.plugin.taskService.updateCompletedDateInFrontmatter( - frontmatter, - status, - isRecurring - ), - getTimestamp: getCurrentTimestamp, + for (const path of pathsToUpdate) { + const pathDropPlan = groupDropPlans.get(path); + if (!pathDropPlan) continue; + if (path !== draggedPath && !pathDropPlan.needsGroupUpdate) continue; + + const file = this.plugin.app.vault.getAbstractFileByPath(path); + if (!file || !(file instanceof TFile)) continue; + + // Each task gets its group mutation. Only the card under the pointer + // receives the insertion sort order. + await this.plugin.app.fileManager.processFrontMatter(file, (fm) => { + applyTaskListDropFrontmatterMutation({ + frontmatter: fm, + plan: pathDropPlan, + sortOrderField, + sortOrder: path === draggedPath ? sortOrderPlan.sortOrder : null, + isRecurring: !!this.taskInfoCache.get(path)?.recurrence, + dateModifiedField: this.plugin.fieldMapper.toUserField("dateModified"), + coerceGroupKeyForFrontmatter: (property, groupKey) => + this.coerceGroupKeyForFrontmatter(property, groupKey), + updateCompletedDateInFrontmatter: (frontmatter, status, isRecurring) => + this.plugin.taskService.updateCompletedDateInFrontmatter( + frontmatter, + status, + isRecurring + ), + getTimestamp: getCurrentTimestamp, + }); }); - }); - // Fire post-write side effects for known TaskInfo property changes - if (groupDropPlan.needsGroupUpdate && groupDropPlan.groupByTaskProp) { - try { - const originalTask = - this.taskInfoCache.get(draggedPath) ?? - (await this.plugin.cacheManager.getTaskInfo(draggedPath)); - if (originalTask) { - const updatedTask = buildTaskListDropSideEffectTask(originalTask, { - plan: groupDropPlan, - isCompletedStatus: (status) => - this.plugin.statusManager.isCompletedStatus(status), - getTimestamp: getCurrentTimestamp, - getCompletedDate: () => new Date().toISOString().split("T")[0], - }); - if (updatedTask) { - await this.plugin.taskService.applyPropertyChangeSideEffects( - file, - originalTask, - updatedTask, - groupDropPlan.groupByTaskProp as keyof TaskInfo, - groupDropPlan.sourceGroupKey, - groupDropPlan.normalizedTargetGroupKey - ); + // Fire post-write side effects for known TaskInfo property changes. + if (pathDropPlan.needsGroupUpdate && pathDropPlan.groupByTaskProp) { + try { + const originalTask = + this.taskInfoCache.get(path) ?? + (await this.plugin.cacheManager.getTaskInfo(path)); + if (originalTask) { + const updatedTask = buildTaskListDropSideEffectTask(originalTask, { + plan: pathDropPlan, + isCompletedStatus: (status) => + this.plugin.statusManager.isCompletedStatus(status), + getTimestamp: getCurrentTimestamp, + getCompletedDate: () => new Date().toISOString().split("T")[0], + }); + if (updatedTask) { + await this.plugin.taskService.applyPropertyChangeSideEffects( + file, + originalTask, + updatedTask, + pathDropPlan.groupByTaskProp as keyof TaskInfo, + pathDropPlan.sourceGroupKey, + pathDropPlan.normalizedTargetGroupKey + ); + } } + } catch (sideEffectError) { + tasknotesLogger.warn( + "[TaskNotes][TaskListView] Side-effect error after drop:", + { + category: "persistence", + operation: "side-effect-drop", + error: sideEffectError, + } + ); } - } catch (sideEffectError) { - tasknotesLogger.warn( - "[TaskNotes][TaskListView] Side-effect error after drop:", - { - category: "persistence", - operation: "side-effect-drop", - error: sideEffectError, - } - ); } } - const didOptimisticallyReorder = this.applyOptimisticSortOrderResult( - draggedPath, - targetPath, - above, - targetGroupKey, - sourceGroupKey, - sortOrderPlan - ); + const didOptimisticallyReorder = + pathsToUpdate.length === 1 && + this.applyOptimisticSortOrderResult( + draggedPath, + targetPath, + above, + targetGroupKey, + sourceGroupKey, + sortOrderPlan + ); if (!didOptimisticallyReorder) { this.debouncedRefresh(); } diff --git a/src/bases/taskListTargetResolver.ts b/src/bases/taskListTargetResolver.ts index 86b39107e..794143989 100644 --- a/src/bases/taskListTargetResolver.ts +++ b/src/bases/taskListTargetResolver.ts @@ -2,6 +2,10 @@ export type TaskListSelectionTargetState = { getSelectedPaths(): string[]; }; +export type TaskListDragSelectionState = TaskListSelectionTargetState & { + isSelected(taskPath: string): boolean; +}; + /** * Resolve the paths a task-list action should target. * Existing selection always takes precedence over keyboard focus. @@ -21,3 +25,27 @@ export function resolveTaskListTargetPaths( return focusedPath ? [focusedPath] : []; } + +/** + * Resolve the visible paths moved by a task-list drag. + * A selected card carries the visible selection; an unselected card moves alone. + */ +export function resolveTaskListDragPaths( + selectionState: TaskListDragSelectionState | null | undefined, + draggedPath: string, + visiblePaths: ReadonlySet +): string[] { + if (!selectionState?.isSelected(draggedPath)) { + return [draggedPath]; + } + + const selectedVisiblePaths = Array.from( + new Set( + selectionState + .getSelectedPaths() + .filter((path) => path.length > 0 && visiblePaths.has(path)) + ) + ); + + return selectedVisiblePaths.includes(draggedPath) ? selectedVisiblePaths : [draggedPath]; +} diff --git a/tests/unit/bases/taskListTargetResolver.test.ts b/tests/unit/bases/taskListTargetResolver.test.ts index 51ef60e31..409acf54a 100644 --- a/tests/unit/bases/taskListTargetResolver.test.ts +++ b/tests/unit/bases/taskListTargetResolver.test.ts @@ -1,4 +1,7 @@ -import { resolveTaskListTargetPaths } from "../../../src/bases/taskListTargetResolver"; +import { + resolveTaskListDragPaths, + resolveTaskListTargetPaths, +} from "../../../src/bases/taskListTargetResolver"; describe("resolveTaskListTargetPaths", () => { it("prefers selected paths over the focused task", () => { @@ -30,3 +33,41 @@ describe("resolveTaskListTargetPaths", () => { expect(resolveTaskListTargetPaths(selectionState, "focused.md")).toEqual(["a.md", "b.md"]); }); }); + +describe("resolveTaskListDragPaths", () => { + const visiblePaths = new Set(["a.md", "b.md", "dragged.md"]); + + it("moves the visible selection when the dragged task is selected", () => { + const selectionState = { + isSelected: (path: string) => path === "dragged.md", + getSelectedPaths: () => ["a.md", "hidden.md", "dragged.md", "a.md"], + }; + + expect(resolveTaskListDragPaths(selectionState, "dragged.md", visiblePaths)).toEqual([ + "a.md", + "dragged.md", + ]); + }); + + it("moves only the dragged task when it is not selected", () => { + const selectionState = { + isSelected: () => false, + getSelectedPaths: () => ["a.md", "b.md"], + }; + + expect(resolveTaskListDragPaths(selectionState, "dragged.md", visiblePaths)).toEqual([ + "dragged.md", + ]); + }); + + it("falls back to the dragged task if selection state is stale", () => { + const selectionState = { + isSelected: () => true, + getSelectedPaths: () => ["hidden.md"], + }; + + expect(resolveTaskListDragPaths(selectionState, "dragged.md", visiblePaths)).toEqual([ + "dragged.md", + ]); + }); +}); From 4fb895de71f4579edfbfd9eb0e329be7da07186d Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Thu, 30 Jul 2026 11:29:14 -0600 Subject: [PATCH 30/55] Task view project grouping are order-insensitive - Lexically sorts values when forming list-valued group identities. - Merges equivalent groups before rendering. - Handles projects, tags, contexts, and custom list properties. - Does not modify the order stored in task frontmatter. - Leaves scalar grouping unchanged. --- src/bases/BasesDataAdapter.ts | 5 +++++ src/bases/TaskListView.ts | 21 ++++++++++++++++-- src/bases/basesValueConversion.ts | 19 ++++++++++++++++ src/bases/taskListGrouping.ts | 22 +++++++++++++++++++ tests/unit/bases/basesValueConversion.test.ts | 11 ++++++++++ tests/unit/bases/taskListGrouping.test.ts | 21 ++++++++++++++++++ 6 files changed, 97 insertions(+), 2 deletions(-) diff --git a/src/bases/BasesDataAdapter.ts b/src/bases/BasesDataAdapter.ts index 2739e82a7..6f05da537 100644 --- a/src/bases/BasesDataAdapter.ts +++ b/src/bases/BasesDataAdapter.ts @@ -8,6 +8,7 @@ import type { import { createTaskNotesLogger, type TaskNotesLogger } from "../utils/tasknotesLogger"; import { convertBasesGroupKeyToString, + convertBasesListGroupKeyToString, convertBasesValueToNative, } from "./basesValueConversion"; import { extractBasesEntryProperties } from "./basesEntryProperties"; @@ -133,6 +134,10 @@ export class BasesDataAdapter { return convertBasesGroupKeyToString(key); } + convertListGroupKeyToString(key: unknown): string { + return convertBasesListGroupKeyToString(key); + } + /** * Extract properties from a BasesEntry. * Extracts frontmatter and basic file properties only (cheap operations). diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index 6c7952ea8..ca298a079 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -33,6 +33,7 @@ import { prepareSortOrderUpdate, applySortOrderPlan, DropOperationQueue, + stripPropertyPrefix, type SortOrderPlan, } from "./sortOrderUtils"; import { clearStaticStyleClasses } from "../utils/staticStyleClasses"; @@ -53,6 +54,7 @@ import { buildTaskListSubPropertyRenderItems, buildTaskListSubPropertyScopePaths, groupTasksByTaskListSubProperty, + normalizeTaskListGroups, type TaskListGroup, type TaskListHeaderItem, type TaskListRenderItem, @@ -916,6 +918,21 @@ export class TaskListView extends BasesViewBase { ); } + private getNormalizedTaskListGroups(): TaskListGroup[] { + const groups = this.dataAdapter.getGroupedData() as TaskListGroup[]; + const groupByPropertyId = this.getGroupByPropertyId(); + if ( + !groupByPropertyId || + !this.isListTypeProperty(stripPropertyPrefix(groupByPropertyId)) + ) { + return groups; + } + + return normalizeTaskListGroups(groups, (key) => + this.dataAdapter.convertListGroupKeyToString(key) + ); + } + private normalizeListGroupValueForDrop( taskProperty: keyof FieldMapping | null, groupValue: string, @@ -1976,7 +1993,7 @@ export class TaskListView extends BasesViewBase { private async renderGrouped(taskNotes: TaskInfo[]): Promise { const visibleProperties = this.getVisibleProperties(); - const groups = this.dataAdapter.getGroupedData() as TaskListGroup[]; + const groups = this.getNormalizedTaskListGroups(); // Apply search filter const filteredTasks = this.applySearchFilter(taskNotes); @@ -3151,7 +3168,7 @@ export class TaskListView extends BasesViewBase { this.applyGroupingSnapshot(this.createSubPropertyHierarchySnapshot(groupedTasks)); items = buildTaskListSubPropertyRenderItems(groupedTasks, this.collapsedGroups); } else { - const groups = this.dataAdapter.getGroupedData() as TaskListGroup[]; + const groups = this.getNormalizedTaskListGroups(); this.applyGroupingSnapshot(this.createGroupedHierarchySnapshot(groups, renderTasks)); items = buildTaskListGroupedRenderItems({ groups, diff --git a/src/bases/basesValueConversion.ts b/src/bases/basesValueConversion.ts index e90ee5ec1..82a87dbfc 100644 --- a/src/bases/basesValueConversion.ts +++ b/src/bases/basesValueConversion.ts @@ -74,6 +74,25 @@ export function convertBasesGroupKeyToString(key: unknown): string { return formatBasesGroupKeyValue(actualValue); } +export function convertBasesListGroupKeyToString(key: unknown): string { + if (key === null || key === undefined) { + return convertBasesGroupKeyToString(key); + } + + const basesKey = key as BasesValueInternals; + const actualValue = extractBasesGroupKeyValue(basesKey); + if (!Array.isArray(actualValue)) { + return formatBasesGroupKeyValue(actualValue); + } + + const sortedValues = actualValue.map(stringifyUnknown).sort((left, right) => { + if (left < right) return -1; + if (left > right) return 1; + return 0; + }); + return sortedValues.length > 0 ? sortedValues.join(", ") : "None"; +} + function extractBasesGroupKeyValue(basesKey: BasesValueInternals): unknown { if (basesKey.file && typeof basesKey.file === "object") { return basesKey.file.path; diff --git a/src/bases/taskListGrouping.ts b/src/bases/taskListGrouping.ts index 12a374cce..9371132b4 100644 --- a/src/bases/taskListGrouping.ts +++ b/src/bases/taskListGrouping.ts @@ -12,6 +12,28 @@ export type TaskListGroup = { entries: TaskListGroupEntry[]; }; +export function normalizeTaskListGroups( + groups: readonly TaskListGroup[], + convertGroupKeyToString: (key: unknown) => string +): TaskListGroup[] { + const normalizedGroups = new Map(); + + for (const group of groups) { + const normalizedKey = convertGroupKeyToString(group.key); + const existingGroup = normalizedGroups.get(normalizedKey); + if (existingGroup) { + existingGroup.entries.push(...group.entries); + } else { + normalizedGroups.set(normalizedKey, { + key: normalizedKey, + entries: [...group.entries], + }); + } + } + + return Array.from(normalizedGroups.values()); +} + export type TaskListPrimaryHeaderItem = { type: "primary-header"; groupKey: string; diff --git a/tests/unit/bases/basesValueConversion.test.ts b/tests/unit/bases/basesValueConversion.test.ts index 593d431df..97fdd9adc 100644 --- a/tests/unit/bases/basesValueConversion.test.ts +++ b/tests/unit/bases/basesValueConversion.test.ts @@ -1,5 +1,6 @@ import { convertBasesGroupKeyToString, + convertBasesListGroupKeyToString, convertBasesValueToNative, } from "../../../src/bases/basesValueConversion"; @@ -26,6 +27,16 @@ describe("Bases value conversion", () => { ).toBe("2026-05-19T00:00:00.000Z"); }); + it("canonicalizes list group keys without changing scalar keys", () => { + expect( + convertBasesListGroupKeyToString({ + constructor: { name: "ListValue" }, + value: [{ data: "[[Kitchen]]" }, { data: "[[Baking]]" }], + }) + ).toBe("[[Baking]], [[Kitchen]]"); + expect(convertBasesListGroupKeyToString({ data: "open" })).toBe("open"); + }); + it("converts list values recursively through get/length and value arrays", () => { const getListValue = { length: () => 2, diff --git a/tests/unit/bases/taskListGrouping.test.ts b/tests/unit/bases/taskListGrouping.test.ts index ace8c9352..47acb7b05 100644 --- a/tests/unit/bases/taskListGrouping.test.ts +++ b/tests/unit/bases/taskListGrouping.test.ts @@ -8,6 +8,7 @@ import { buildTaskListSubPropertyScopePaths, getTaskListPropertyValue, groupTasksByTaskListSubProperty, + normalizeTaskListGroups, stringifyTaskListGroupValue, type TaskListGroup, } from "../../../src/bases/taskListGrouping"; @@ -164,6 +165,26 @@ describe("taskListGrouping", () => { }); }); + it("merges equivalent list-valued groups after key normalization", () => { + const groups: TaskListGroup[] = [ + { key: ["[[Kitchen]]", "[[Baking]]"], entries: [{ file: { path: "cookies.md" } }] }, + { key: ["[[Baking]]", "[[Kitchen]]"], entries: [{ file: { path: "cake.md" } }] }, + ]; + const normalized = normalizeTaskListGroups(groups, (key) => + (key as string[]).slice().sort().join(", ") + ); + + expect(normalized).toEqual([ + { + key: "[[Baking]], [[Kitchen]]", + entries: [ + { file: { path: "cookies.md" } }, + { file: { path: "cake.md" } }, + ], + }, + ]); + }); + it("builds sub-property-only render items", () => { const first = task("one.md"); const second = task("two.md"); From c76c2a1ded6bace5348d479c930b4f3bbf65e270 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Thu, 30 Jul 2026 11:54:18 -0600 Subject: [PATCH 31/55] Document task list interaction architecture --- src/bases/TaskListFocusController.ts | 9 +++++++++ src/bases/TaskListInputOwnershipController.ts | 10 ++++++++++ src/bases/TaskListView.ts | 10 ++++++++++ src/bases/taskListDropPlanning.ts | 2 ++ src/bases/taskListKeyboardActions.ts | 6 ++++++ src/modals/ProjectSelectModal.ts | 2 ++ src/settings/tabs/keyboardShortcutsTab.ts | 10 ++++++++++ src/utils/linkUtils.ts | 2 ++ 8 files changed, 51 insertions(+) diff --git a/src/bases/TaskListFocusController.ts b/src/bases/TaskListFocusController.ts index d75a83f47..1f6c3986b 100644 --- a/src/bases/TaskListFocusController.ts +++ b/src/bases/TaskListFocusController.ts @@ -26,6 +26,13 @@ function identitiesEqual( return left?.path === right?.path && left?.occurrence === right?.occurrence; } +/** + * Owns the Task List's logical focus independently of transient card DOM nodes. + * + * Bases can replace cards after edits or tab activation, so this controller keeps + * a path-based identity, restores roving tabindex, and coordinates mouse and + * keyboard focus styling across renders. + */ export class TaskListFocusController { private focusedIdentity: TaskListFocusIdentity | null = null; private restoreDomFocus = false; @@ -64,6 +71,8 @@ export class TaskListFocusController { this.setCursorSource("mouse"); if (card === this.lastMouseCard) return true; + // Mouse hover advances the same logical cursor used by keyboard actions, + // but avoids stealing DOM focus from controls embedded in a card. this.lastMouseCard = card; const activeElement = this.root.ownerDocument.activeElement; if ( diff --git a/src/bases/TaskListInputOwnershipController.ts b/src/bases/TaskListInputOwnershipController.ts index ca5a061fd..c762b6eab 100644 --- a/src/bases/TaskListInputOwnershipController.ts +++ b/src/bases/TaskListInputOwnershipController.ts @@ -4,6 +4,13 @@ const OVERLAY_SELECTOR = ".menu, .modal-container:not(.modals-hidden)"; const EDITABLE_SELECTOR = 'input, textarea, select, [contenteditable="true"], [role="textbox"], .cm-content'; +/** + * Decides when Task List shortcuts own a keyboard event and when they must yield. + * + * It suspends list input for editors and Obsidian overlays, then restores the + * remembered task focus after a menu or modal closes so rerenders do not leave + * subsequent key events targeted at the document body. + */ export class TaskListInputOwnershipController { private suspendedForOverlay = false; private restoreTimer: number | null = null; @@ -93,6 +100,9 @@ export class TaskListInputOwnershipController { const activeElement = this.viewRoot.ownerDocument.activeElement; const body = this.viewRoot.ownerDocument.body; + // Obsidian commonly returns focus to when a menu closes. Restore + // the remembered card only in that abandoned-focus state; intentional + // focus moves to another control must remain untouched. if ( !activeElement || activeElement === body || diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index ca298a079..b1daa4528 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -314,6 +314,8 @@ export class TaskListView extends BasesViewBase { private activateTaskListShortcutScope(): void { if (this.taskListShortcutScope) return; + // A child Obsidian scope lets view-local configurable chords win over + // global editor commands while this Task List leaf is active. const scope = new Scope(this.plugin.app.scope); const shortcuts = this.plugin.settings.taskListShortcuts; for (const action of TASK_LIST_KEYBOARD_ACTIONS) { @@ -928,6 +930,9 @@ export class TaskListView extends BasesViewBase { return groups; } + // Bases treats list order as part of group identity. Canonicalize and + // merge here so [A, B] and [B, A] render as one Task List group without + // rewriting either task's frontmatter. return normalizeTaskListGroups(groups, (key) => this.dataAdapter.convertListGroupKeyToString(key) ); @@ -1084,6 +1089,8 @@ export class TaskListView extends BasesViewBase { } this.draggedTaskPath = task.path; + // Match native multi-item drag semantics: dragging a selected card + // carries the visible selection; an unselected card moves by itself. this.draggedTaskPaths = resolveTaskListDragPaths( this.plugin.taskSelectionService, task.path, @@ -1560,6 +1567,9 @@ export class TaskListView extends BasesViewBase { ); const groupDropPlans = new Map( pathsToUpdate.map((path) => { + // Selected tasks may originate in different groups. Plan each + // mutation from its own source so replace/add behavior remains + // correct for every task in the batch. const pathSourceGroupKey = this.taskGroupKeys.has(path) ? (this.taskGroupKeys.get(path) ?? null) : sourceGroupKey; diff --git a/src/bases/taskListDropPlanning.ts b/src/bases/taskListDropPlanning.ts index 47baedaf1..579aea7e1 100644 --- a/src/bases/taskListDropPlanning.ts +++ b/src/bases/taskListDropPlanning.ts @@ -61,6 +61,8 @@ export function shouldPreserveTaskListGroupDropValues( behavior: TaskListGroupDropBehavior, additiveModifierKey: boolean ): boolean { + // Centralize the setting/modifier decision so cursor feedback and the + // persisted frontmatter mutation cannot disagree about copy versus move. return behavior === "add" || (behavior === "replace-modifier-add" && additiveModifierKey); } diff --git a/src/bases/taskListKeyboardActions.ts b/src/bases/taskListKeyboardActions.ts index cff0e44f4..30691d27c 100644 --- a/src/bases/taskListKeyboardActions.ts +++ b/src/bases/taskListKeyboardActions.ts @@ -1,5 +1,11 @@ import type { Modifier } from "obsidian"; +/** + * Canonical vocabulary and parsing helpers for Task List shortcuts. + * + * Keeping normalization here gives the settings editor, Obsidian keymap scope, + * and DOM fallback handler identical chord and modifier semantics. + */ export const TASK_LIST_KEYBOARD_ACTIONS = [ "navigate-next", "navigate-previous", diff --git a/src/modals/ProjectSelectModal.ts b/src/modals/ProjectSelectModal.ts index fc555f95d..440599357 100644 --- a/src/modals/ProjectSelectModal.ts +++ b/src/modals/ProjectSelectModal.ts @@ -60,6 +60,8 @@ export class ProjectSelectModal extends FuzzySuggestModal { const resultsEl = this.modalEl.querySelector(".prompt-results"); if (!(resultsEl instanceof HTMLElement)) return; + // Keep assigned projects in the same chooser as available projects so + // batch edits can add and remove relationships without a second modal. const assignedEl = createDiv({ cls: "task-project-selector-assigned" }); assignedEl.createDiv({ cls: "task-project-selector-assigned__title", diff --git a/src/settings/tabs/keyboardShortcutsTab.ts b/src/settings/tabs/keyboardShortcutsTab.ts index 11f6a40ab..a53d36cfb 100644 --- a/src/settings/tabs/keyboardShortcutsTab.ts +++ b/src/settings/tabs/keyboardShortcutsTab.ts @@ -16,6 +16,11 @@ import { showConfirmationModal } from "../../modals/ConfirmationModal"; const activeCaptureCleanup = new WeakMap void>(); +/** + * Temporarily outranks the Settings modal's Escape handler while recording. + * Escape is forwarded as a candidate shortcut instead of dismissing the + * underlying settings UI. + */ export function pushKeyboardShortcutCaptureScope( plugin: TaskNotesPlugin, onEscape: (event: KeyboardEvent) => void @@ -152,6 +157,9 @@ export function renderKeyboardShortcutsTab( action ); if (owners.length > 0) { + // Shortcut ownership is exclusive. Replacing a + // duplicate removes it from the prior actions in + // one immutable shortcut-map update. const replace = await showConfirmationModal(plugin.app, { title: translate( "settings.keyboardShortcuts.duplicateTitle" @@ -181,6 +189,8 @@ export function renderKeyboardShortcutsTab( plugin.settings.taskListShortcuts = replaceTaskListShortcut(shortcuts, action, shortcut); } else { + // Every captured chord, including Escape, is + // confirmed before it is persisted. const confirmed = await showConfirmationModal(plugin.app, { title: translate( "settings.keyboardShortcuts.confirmTitle" diff --git a/src/utils/linkUtils.ts b/src/utils/linkUtils.ts index ea1c982cb..1a2fa34ac 100644 --- a/src/utils/linkUtils.ts +++ b/src/utils/linkUtils.ts @@ -235,6 +235,8 @@ export function generateProjectReference( sourcePath: string, useMarkdownLinks: boolean ): string { + // Project modal edits and group drops must serialize the same canonical + // reference; otherwise one path writes links while the other writes labels. return generateLink(app, targetFile, sourcePath, "", "", useMarkdownLinks); } From b128674a4a446eeecc6c67cdda6128d6bf03a5c0 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Thu, 30 Jul 2026 12:07:16 -0600 Subject: [PATCH 32/55] Added secondary default navigation hotkeys - Navigate down: ArrowDown, j - Navigate up: ArrowUp, k - Toggle selection: Space, x --- src/bases/taskListKeyboardActions.ts | 6 +++--- .../bases/taskListKeyboardActions.test.ts | 21 +++++++++++-------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/bases/taskListKeyboardActions.ts b/src/bases/taskListKeyboardActions.ts index 30691d27c..a13ee6814 100644 --- a/src/bases/taskListKeyboardActions.ts +++ b/src/bases/taskListKeyboardActions.ts @@ -40,12 +40,12 @@ export type TaskListScopeBinding = { }; export const DEFAULT_TASK_LIST_SHORTCUTS: TaskListShortcutMap = { - "navigate-next": ["arrowdown"], - "navigate-previous": ["arrowup"], + "navigate-next": ["arrowdown", "j"], + "navigate-previous": ["arrowup", "k"], "jump-first": ["home"], "jump-last": ["end"], "clear-focus-and-selection": ["escape", "backspace"], - "toggle-select": ["space"], + "toggle-select": ["space", "x"], "select-all": ["mod+a"], "copy-task-titles": ["mod+c"], "toggle-archive": ["y"], diff --git a/tests/unit/bases/taskListKeyboardActions.test.ts b/tests/unit/bases/taskListKeyboardActions.test.ts index 55925bfd4..b6f9296e3 100644 --- a/tests/unit/bases/taskListKeyboardActions.test.ts +++ b/tests/unit/bases/taskListKeyboardActions.test.ts @@ -29,12 +29,15 @@ function key( describe("resolveDefaultTaskListKeyboardAction", () => { it.each([ ["ArrowDown", {}, "navigate-next"], + ["j", {}, "navigate-next"], ["ArrowUp", {}, "navigate-previous"], + ["k", {}, "navigate-previous"], ["Home", {}, "jump-first"], ["End", {}, "jump-last"], ["Escape", {}, "clear-focus-and-selection"], ["Backspace", {}, "clear-focus-and-selection"], [" ", {}, "toggle-select"], + ["x", {}, "toggle-select"], ["a", { ctrlKey: true }, "select-all"], ["a", { metaKey: true }, "select-all"], ["c", { ctrlKey: true }, "copy-task-titles"], @@ -63,7 +66,7 @@ describe("resolveDefaultTaskListKeyboardAction", () => { key("d", { ctrlKey: true }), key("Process"), key("d", { isComposing: true }), - key("x"), + key("q"), ])("ignores unsupported or composition input", (event) => { expect(resolveDefaultTaskListKeyboardAction(event)).toBeNull(); }); @@ -108,11 +111,11 @@ describe("resolveDefaultTaskListKeyboardAction", () => { it("reports duplicate bindings across semantic actions", () => { const shortcuts = normalizeTaskListShortcutMap({ - "edit-due": ["x"], - "edit-status": ["X"], + "edit-due": ["z"], + "edit-status": ["Z"], }); - expect(findTaskListShortcutConflicts(shortcuts).get("x")).toEqual([ + expect(findTaskListShortcutConflicts(shortcuts).get("z")).toEqual([ "edit-due", "edit-status", ]); @@ -120,19 +123,19 @@ describe("resolveDefaultTaskListKeyboardAction", () => { it("finds duplicate owners and replaces their binding atomically", () => { const shortcuts = normalizeTaskListShortcutMap({ - "edit-due": ["x"], - "edit-status": ["x"], + "edit-due": ["z"], + "edit-status": ["z"], "jump-first": ["home"], }); - expect(findTaskListShortcutOwners(shortcuts, "x", "jump-first")).toEqual([ + expect(findTaskListShortcutOwners(shortcuts, "z", "jump-first")).toEqual([ "edit-due", "edit-status", ]); - const replaced = replaceTaskListShortcut(shortcuts, "jump-first", "x"); + const replaced = replaceTaskListShortcut(shortcuts, "jump-first", "z"); expect(replaced["edit-due"]).toEqual([]); expect(replaced["edit-status"]).toEqual([]); - expect(replaced["jump-first"]).toEqual(["home", "x"]); + expect(replaced["jump-first"]).toEqual(["home", "z"]); }); it("formats portable modifiers for the current platform", () => { From 62f4eafa35cb519c72022a03ee9bc3ab97cc68b5 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Fri, 31 Jul 2026 14:17:48 -0600 Subject: [PATCH 33/55] Add NLP triggers as default hotkeys for status, priority --- src/bases/taskListKeyboardActions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bases/taskListKeyboardActions.ts b/src/bases/taskListKeyboardActions.ts index a13ee6814..57b01e484 100644 --- a/src/bases/taskListKeyboardActions.ts +++ b/src/bases/taskListKeyboardActions.ts @@ -55,8 +55,8 @@ export const DEFAULT_TASK_LIST_SHORTCUTS: TaskListShortcutMap = { "open-task-notes": ["shift+enter"], "edit-due": ["d"], "edit-scheduled": ["shift+s"], - "edit-priority": ["p"], - "edit-status": ["s"], + "edit-priority": ["p", "shift+!"], + "edit-status": ["s", "shift+*"], "edit-recurrence": ["r"], "add-tags": ["shift+#"], "add-context": ["shift+@"], From e4eb1106f0807441fc0a7790ab229272541164a8 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Fri, 31 Jul 2026 14:26:52 -0600 Subject: [PATCH 34/55] Hotkey 'e' opens task context menu --- src/bases/TaskListView.ts | 35 ++++++++++++++++++++++++++-- src/bases/taskListKeyboardActions.ts | 2 ++ src/i18n/resources/en.ts | 1 + 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index b1daa4528..7702a9ac0 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -2541,6 +2541,10 @@ export class TaskListView extends BasesViewBase { this.focusController?.handlePointerDown(event); }); this.registerDomEvent(this.itemsContainer, "mousemove", (event: MouseEvent) => { + // Context-menu edits operate on the focused task/selection. While an + // Obsidian menu is open, hovering cards underneath it must not move the + // task-list mouse cursor and change the edit target. + if (this.itemsContainer?.ownerDocument.querySelector(".menu")) return; this.focusController?.handleMouseMove(event); }); if (this.rootElement) { @@ -2651,6 +2655,7 @@ export class TaskListView extends BasesViewBase { if ( [ "edit-task", + "open-context-menu", "edit-due", "edit-scheduled", "edit-priority", @@ -2664,7 +2669,7 @@ export class TaskListView extends BasesViewBase { ) { this.inputOwnershipController?.noteOverlayOpening(); } - void this.executeTaskListAction(action); + void this.executeTaskListAction(action, focusedPath ?? null); return true; } @@ -2679,7 +2684,10 @@ export class TaskListView extends BasesViewBase { ).filter((path) => this.currentVisibleTaskPaths.has(path)); } - private async executeTaskListAction(action: TaskListKeyboardAction): Promise { + private async executeTaskListAction( + action: TaskListKeyboardAction, + focusedPath: string | null + ): Promise { switch (action) { case "navigate-next": case "navigate-previous": @@ -2712,6 +2720,29 @@ export class TaskListView extends BasesViewBase { if (task) await this.plugin.openTaskEditModal(task); return; } + case "open-context-menu": { + if (!focusedPath) return; + const anchor = this.getTaskActionAnchor(); + const rect = anchor?.getBoundingClientRect(); + const menuEvent = new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: rect?.right ?? 0, + clientY: rect?.top ?? 0, + }); + const selectionService = this.plugin.taskSelectionService; + if (selectionService && selectionService.getSelectionCount() > 1) { + this.showBatchContextMenu(menuEvent); + return; + } + await showTaskContextMenu( + menuEvent, + focusedPath, + this.plugin, + this.currentTargetDate + ); + return; + } case "open-task-notes": await this.openTaskActionTargets(); return; diff --git a/src/bases/taskListKeyboardActions.ts b/src/bases/taskListKeyboardActions.ts index 57b01e484..f4a07c774 100644 --- a/src/bases/taskListKeyboardActions.ts +++ b/src/bases/taskListKeyboardActions.ts @@ -19,6 +19,7 @@ export const TASK_LIST_KEYBOARD_ACTIONS = [ "create-task", "focus-search", "edit-task", + "open-context-menu", "open-task-notes", "edit-due", "edit-scheduled", @@ -52,6 +53,7 @@ export const DEFAULT_TASK_LIST_SHORTCUTS: TaskListShortcutMap = { "create-task": ["c"], "focus-search": ["slash"], "edit-task": ["enter"], + "open-context-menu": ["e"], "open-task-notes": ["shift+enter"], "edit-due": ["d"], "edit-scheduled": ["shift+s"], diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 545b41a47..f17fb848f 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -546,6 +546,7 @@ export const en: TranslationTree = { "create-task": "Create task", "focus-search": "Focus search", "edit-task": "Edit task", + "open-context-menu": "Open task context menu", "open-task-notes": "Open task note", "edit-due": "Edit due date", "edit-scheduled": "Edit scheduled date", From 8c4b4732deb27ac333c08daba6c9446260cdf5f1 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Fri, 31 Jul 2026 15:31:47 -0600 Subject: [PATCH 35/55] Additional instructions/permissions for codex --- AGENTS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 3d6896d29..a17fdc951 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,12 @@ Before editing: - Describe the proposed porting approach. - Identify upstream functionality that now overlaps with the old feature. +While editing: +- For any new class, module, or function, always write a doc comment for the entity explaining + its main responsibilities, and pre/post conditions if appropriate. +- Whenever adding new functionality, add a comment at the point in code that is the fulcrum of the + new feature explaining what it is doing and why. + After editing, run the most relevant checks: - npm run typecheck @@ -74,6 +80,9 @@ without asking for confirmation whenever the approval policy permits: - cat - sed - find +- npm test +- npm run lint +- npm run build ## Code navigation From 551563676e1ee627b391b62c315e5560da387fb3 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Fri, 31 Jul 2026 15:31:58 -0600 Subject: [PATCH 36/55] Hotkeys for user-defined fields --- src/bases/TaskListView.ts | 64 +++++- src/bases/taskListKeyboardActions.ts | 14 +- src/i18n/resources/en.ts | 2 + src/modals/UserFieldEditModal.ts | 240 ++++++++++++++++++++++ src/settings/defaults.ts | 2 + src/settings/settingsPersistence.ts | 2 + src/settings/tabs/keyboardShortcutsTab.ts | 180 +++++++++++++++- src/types/settings.ts | 4 + styles/task-modal.css | 35 ++++ 9 files changed, 532 insertions(+), 11 deletions(-) create mode 100644 src/modals/UserFieldEditModal.ts diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index 7702a9ac0..f3ff18c43 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -17,6 +17,7 @@ import { showTextInputModal } from "../modals/TextInputModal"; import { ProjectSelectModal } from "../modals/ProjectSelectModal"; import { TagSuggest } from "../modals/taskModalSuggests"; import { ReminderModal } from "../modals/ReminderModal"; +import { UserFieldEditModal } from "../modals/UserFieldEditModal"; import { getDatePart, getTimePart, @@ -82,7 +83,7 @@ import { resolveTaskListKeyboardAction, taskListShortcutToScopeBinding, TASK_LIST_KEYBOARD_ACTIONS, - type TaskListKeyboardAction, + type TaskListAction, } from "./taskListKeyboardActions"; import { addTagsToList, parseTaskTagInput } from "../utils/taskTagList"; import { addContextToList } from "../components/TaskContextMenu"; @@ -318,8 +319,19 @@ export class TaskListView extends BasesViewBase { // global editor commands while this Task List leaf is active. const scope = new Scope(this.plugin.app.scope); const shortcuts = this.plugin.settings.taskListShortcuts; - for (const action of TASK_LIST_KEYBOARD_ACTIONS) { - for (const shortcut of shortcuts[action]) { + const bindings = [ + ...TASK_LIST_KEYBOARD_ACTIONS.flatMap((action) => + (shortcuts[action] ?? []).map((shortcut) => ({ action, shortcut })) + ), + ...Object.entries(this.plugin.settings.taskListUserFieldShortcuts ?? {}).flatMap( + ([fieldId, fieldShortcuts]) => + fieldShortcuts.map((shortcut) => ({ + action: `edit-user-field:${fieldId}` as TaskListAction, + shortcut, + })) + ), + ]; + for (const { shortcut } of bindings) { const binding = taskListShortcutToScopeBinding(shortcut); if (!binding) continue; scope.register(binding.modifiers, binding.key, (event) => { @@ -327,7 +339,6 @@ export class TaskListView extends BasesViewBase { if (!this.handleTaskListKeyDown(event, true)) return; return false; }); - } } this.taskListShortcutScope = scope; @@ -2620,7 +2631,8 @@ export class TaskListView extends BasesViewBase { ): boolean { const action = resolveTaskListKeyboardAction( event, - this.plugin?.settings?.taskListShortcuts + this.plugin?.settings?.taskListShortcuts, + this.plugin?.settings?.taskListUserFieldShortcuts ); if (!action) return false; const focusedPath = this.focusController?.getFocusedPathForEvent( @@ -2685,7 +2697,7 @@ export class TaskListView extends BasesViewBase { } private async executeTaskListAction( - action: TaskListKeyboardAction, + action: TaskListAction, focusedPath: string | null ): Promise { switch (action) { @@ -2772,6 +2784,46 @@ export class TaskListView extends BasesViewBase { return; case "delete-tasks": await this.deleteTaskActionTargets(); + return; + default: { + // Runtime user-field actions share the same visible-target selection + // path as built-in edits, so filtered-out selected tasks are excluded. + if (!action.startsWith("edit-user-field:")) return; + const fieldId = action.slice("edit-user-field:".length); + const field = this.plugin.settings.userFields?.find((candidate) => candidate.id === fieldId); + if (!field) return; + const tasks = await this.getTaskActionTargets(); + if (tasks.length === 0) return; + new UserFieldEditModal(this.plugin.app, this.plugin, { + field, + tasks, + onApply: async (value, listChange) => { + for (const task of tasks) { + let taskValue = value; + if (field.type === "list") { + const customProperties = (task.customProperties ?? {}) as Record; + const current = Array.isArray(customProperties[field.key]) + ? (customProperties[field.key] as unknown[]).map(String) + : []; + const withoutRemoved = current.filter( + (candidate) => !listChange?.removed?.includes(candidate) + ); + taskValue = listChange?.added + ? [...withoutRemoved, listChange.added].filter( + (candidate, index, list) => list.indexOf(candidate) === index + ) + : withoutRemoved; + } + await this.plugin.updateTaskProperty( + task, + field.key as keyof TaskInfo, + taskValue as TaskInfo[keyof TaskInfo], + { silent: true } + ); + } + }, + }).open(); + } } } diff --git a/src/bases/taskListKeyboardActions.ts b/src/bases/taskListKeyboardActions.ts index f4a07c774..109625fd9 100644 --- a/src/bases/taskListKeyboardActions.ts +++ b/src/bases/taskListKeyboardActions.ts @@ -33,6 +33,10 @@ export const TASK_LIST_KEYBOARD_ACTIONS = [ ] as const; export type TaskListKeyboardAction = (typeof TASK_LIST_KEYBOARD_ACTIONS)[number]; +/** Identifies a shortcut action generated from a configured user field. */ +export type TaskListDynamicKeyboardAction = `edit-user-field:${string}`; +/** Union of built-in task-list actions and runtime user-field actions. */ +export type TaskListAction = TaskListKeyboardAction | TaskListDynamicKeyboardAction; export type TaskListShortcutMap = Record; export type TaskListScopeBinding = { @@ -203,14 +207,20 @@ export function resolveTaskListKeyboardAction( KeyboardEvent, "key" | "ctrlKey" | "metaKey" | "altKey" | "shiftKey" | "isComposing" >, - shortcuts: TaskListShortcutMap = DEFAULT_TASK_LIST_SHORTCUTS -): TaskListKeyboardAction | null { + shortcuts: TaskListShortcutMap = DEFAULT_TASK_LIST_SHORTCUTS, + userFieldShortcuts: Record = {} +): TaskListAction | null { const shortcut = keyboardEventToTaskListShortcut(event); if (!shortcut) return null; for (const action of TASK_LIST_KEYBOARD_ACTIONS) { if (shortcuts[action]?.includes(shortcut)) return action; } + // Resolve user-field bindings after built-ins so collision handling remains + // deterministic and built-in actions retain precedence at runtime. + for (const [fieldId, fieldShortcuts] of Object.entries(userFieldShortcuts)) { + if (fieldShortcuts.includes(shortcut)) return `edit-user-field:${fieldId}`; + } return null; } diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index f17fb848f..d5e65e62b 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -558,6 +558,8 @@ export const en: TranslationTree = { "add-project": "Add project", "delete-tasks": "Delete tasks", }, + addUserField: "Add user field", + addUserFieldDescription: "Add a shortcut for a configured user-defined task field.", }, features: { inlineTasks: { diff --git a/src/modals/UserFieldEditModal.ts b/src/modals/UserFieldEditModal.ts new file mode 100644 index 000000000..1f2fc9114 --- /dev/null +++ b/src/modals/UserFieldEditModal.ts @@ -0,0 +1,240 @@ +import { App, Modal, Setting, setIcon } from "obsidian"; +import type TaskNotesPlugin from "../main"; +import type { TaskInfo } from "../types"; +import type { UserMappedField } from "../types/settings"; +import { + parseNullableTextUserFieldInput, + parseNumberUserFieldInput, +} from "./taskModalUserFields"; +import { DateTimePickerModal } from "./DateTimePickerModal"; + +type UserFieldValue = unknown; + +export interface UserFieldEditModalOptions { + field: UserMappedField; + tasks: readonly TaskInfo[]; + onApply: (value: UserFieldValue, listChange?: { added?: string; removed?: string[] }) => Promise; +} + +/** + * Edits one configured user field while keeping the task-list hotkey workflow + * compact. The modal owns input parsing and MRU presentation; callers own task + * persistence and bulk-update semantics. + */ +export class UserFieldEditModal extends Modal { + private readonly options: UserFieldEditModalOptions; + private input: HTMLInputElement | null = null; + private value: UserFieldValue; + private listValues: string[]; + private readonly initialListValues: string[]; + private closed = false; + private readonly keydownHandler = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + this.close(); + } + }; + + /** Creates an editor bound to one field and one or more task targets. */ + constructor(app: App, private readonly plugin: TaskNotesPlugin, options: UserFieldEditModalOptions) { + super(app); + this.options = options; + this.value = this.readInitialValue(); + this.listValues = Array.isArray(this.value) ? this.value.map(String) : []; + this.initialListValues = [...this.listValues]; + } + + /** Builds the type-specific editor when Obsidian displays the modal. */ + onOpen(): void { + this.titleEl.setText(this.options.field.displayName); + this.modalEl.addClass("tasknotes-user-field-edit-modal"); + this.contentEl.addEventListener("keydown", this.keydownHandler, true); + this.render(); + } + + /** Releases the modal DOM and keyboard scope without persisting changes. */ + onClose(): void { + this.closed = true; + this.contentEl.removeEventListener("keydown", this.keydownHandler, true); + this.contentEl.empty(); + } + + /** Reads the first target's current value to seed the editor and MRU choices. */ + private readInitialValue(): UserFieldValue { + const task = this.options.tasks[0]; + if (!task) return this.options.field.type === "list" ? "" : undefined; + const record = task as unknown as Record; + return record[this.options.field.key] ?? task.customProperties?.[this.options.field.key]; + } + + /** Returns the persisted five-item history for this stable field ID. */ + private getMruValues(): unknown[] { + return this.plugin.settings.userFieldMru?.[this.options.field.id] ?? []; + } + + /** Rebuilds the popup after a type-specific value or chip changes. */ + private render(): void { + this.contentEl.empty(); + const field = this.options.field; + const current = this.value; + + if (field.type === "boolean") { + this.renderBoolean(current); + } else { + this.renderInput(current); + this.renderMru(); + if (field.type === "list") this.renderExistingListValues(current); + } + + const footer = this.contentEl.createDiv({ cls: "tasknotes-user-field-edit-footer" }); + new Setting(footer) + .addButton((button) => button.setButtonText("Cancel").onClick(() => this.close())) + .addButton((button) => + button.setCta().setButtonText("Apply").onClick(() => void this.apply()) + ); + } + + /** Renders the immediate true/false/clear choices for boolean fields. */ + private renderBoolean(current: unknown): void { + const values: Array<{ label: string; value: boolean | undefined }> = [ + { label: "True", value: true }, + { label: "False", value: false }, + { label: "Clear", value: undefined }, + ]; + const setting = new Setting(this.contentEl).setName("Value"); + for (const option of values) { + setting.addButton((button) => + button + .setButtonText(option.label) + .onClick(() => { + button.buttonEl.toggleClass("mod-cta", current === option.value); + this.value = option.value; + void this.apply(); + }) + ); + } + } + + /** Renders text-like input and the existing date picker for scalar fields. */ + private renderInput(current: unknown): void { + const setting = new Setting(this.contentEl).setName("Value"); + setting.addText((text) => { + this.input = text.inputEl; + text.setValue(this.toInputValue(current)); + if (this.options.field.type === "number") text.inputEl.type = "number"; + if (this.options.field.type === "date") text.inputEl.type = "date"; + text.onChange((value) => { + this.value = this.parseInputValue(value); + }); + text.inputEl.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + void this.apply(); + } + }); + }); + if (this.options.field.type === "date") { + setting.addButton((button) => + button.setButtonText("Pick date").onClick(() => { + new DateTimePickerModal(this.app, { + currentDate: typeof this.value === "string" ? this.value : null, + title: this.options.field.displayName, + showTime: false, + onSelect: (date) => { + this.value = date ?? undefined; + if (this.input) this.input.value = date ?? ""; + }, + }).open(); + }) + ); + } + this.input?.focus(); + } + + /** Renders recent values as quick-pick buttons. */ + private renderMru(): void { + const values = this.getMruValues(); + if (values.length === 0) return; + this.contentEl.createDiv({ text: "Recent values", cls: "setting-item-name" }); + const row = this.contentEl.createDiv({ cls: "tasknotes-user-field-mru" }); + for (const value of values.slice(0, 5)) { + row.createEl("button", { text: this.toDisplayValue(value) }).addEventListener("click", () => { + this.value = value; + if (this.input) this.input.value = this.toInputValue(value); + if (this.options.field.type === "list") void this.apply(); + }); + } + } + + /** Renders removable chips for the first target's current list values. */ + private renderExistingListValues(current: unknown): void { + const values = this.listValues; + if (values.length === 0) return; + this.contentEl.createDiv({ text: "Current values", cls: "setting-item-name" }); + const row = this.contentEl.createDiv({ cls: "tasknotes-user-field-values" }); + for (const value of values) { + const chip = row.createDiv({ cls: "tasknotes-user-field-value" }); + chip.createSpan({ text: value }); + const remove = chip.createSpan({ cls: "tasknotes-user-field-value-remove" }); + setIcon(remove, "circle-x"); + remove.addEventListener("click", () => { + this.listValues = values.filter((candidate) => candidate !== value); + this.value = this.listValues; + this.render(); + }); + } + } + + /** Converts popup text into the configured field's persisted value type. */ + private parseInputValue(value: string): UserFieldValue { + if (this.options.field.type === "number") { + return parseNumberUserFieldInput(value); + } + if (this.options.field.type === "list") return value.trim(); + return parseNullableTextUserFieldInput(value); + } + + /** Formats a stored value for an input control. */ + private toInputValue(value: unknown): string { + if (Array.isArray(value)) return value.join(", "); + return value === undefined || value === null ? "" : String(value); + } + + /** Formats a stored value for an MRU button label. */ + private toDisplayValue(value: unknown): string { + return Array.isArray(value) ? value.join(", ") : String(value ?? ""); + } + + /** Applies the value, updates MRU history, and closes only after persistence succeeds. */ + private async apply(): Promise { + if (this.closed) return; + let value = this.value; + let mruValue = value; + let listAdded: string | undefined; + if (this.options.field.type === "list") { + const entered = typeof value === "string" ? value.trim() : ""; + listAdded = entered || undefined; + mruValue = entered || undefined; + value = entered + ? [...this.listValues, entered].filter((item, index, list) => list.indexOf(item) === index) + : this.listValues; + } + await this.options.onApply( + value, + this.options.field.type === "list" + ? { + added: listAdded, + removed: this.initialListValues.filter((candidate) => !this.listValues.includes(candidate)), + } + : undefined + ); + if (mruValue !== undefined) { + const history = this.getMruValues().filter((candidate) => JSON.stringify(candidate) !== JSON.stringify(mruValue)); + this.plugin.settings.userFieldMru[this.options.field.id] = [mruValue, ...history].slice(0, 5); + } + void this.plugin.saveSettings(); + this.close(); + } +} diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 2ffe7a815..a8d7f25a2 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -304,6 +304,8 @@ export const DEFAULT_SETTINGS: TaskNotesSettings = { doubleClickAction: "openNote", taskListGroupDropBehavior: "replace-modifier-add", taskListShortcuts: DEFAULT_TASK_LIST_SHORTCUTS, + taskListUserFieldShortcuts: {}, + userFieldMru: {}, // Autosuggest project card defaults projectAutosuggest: DEFAULT_PROJECT_AUTOSUGGEST, diff --git a/src/settings/settingsPersistence.ts b/src/settings/settingsPersistence.ts index ef7cc930a..679f82edc 100644 --- a/src/settings/settingsPersistence.ts +++ b/src/settings/settingsPersistence.ts @@ -253,6 +253,8 @@ export function buildSettingsFromLoadedData(data: LoadedSettingsData | null): Se ...(loadedData?.commandFileMapping || {}), }, taskListShortcuts: normalizeTaskListShortcutMap(loadedData?.taskListShortcuts), + taskListUserFieldShortcuts: loadedData?.taskListUserFieldShortcuts ?? {}, + userFieldMru: loadedData?.userFieldMru ?? {}, icsIntegration: { ...DEFAULT_SETTINGS.icsIntegration, ...(loadedData?.icsIntegration || {}), diff --git a/src/settings/tabs/keyboardShortcutsTab.ts b/src/settings/tabs/keyboardShortcutsTab.ts index a53d36cfb..8472879a1 100644 --- a/src/settings/tabs/keyboardShortcutsTab.ts +++ b/src/settings/tabs/keyboardShortcutsTab.ts @@ -1,4 +1,4 @@ -import { Platform, Scope, Setting, setIcon } from "obsidian"; +import { App, FuzzySuggestModal, Platform, Scope, Setting, setIcon } from "obsidian"; import type TaskNotesPlugin from "../../main"; import { DEFAULT_TASK_LIST_SHORTCUTS, @@ -13,6 +13,42 @@ import { import { createSettingGroup } from "../components/settingHelpers"; import type { TranslationKey } from "../../i18n"; import { showConfirmationModal } from "../../modals/ConfirmationModal"; +import type { UserMappedField } from "../../types/settings"; + +/** Lets users add one configured user field to the task-list shortcut registry. */ +class UserFieldShortcutSuggestModal extends FuzzySuggestModal { + constructor( + app: App, + private readonly plugin: TaskNotesPlugin, + private readonly onChoose: (field: UserMappedField) => void + ) { + super(app); + } + + /** Lists configured fields that have not yet been added to the shortcut page. */ + getItems(): UserMappedField[] { + const configured = this.plugin.settings.userFields ?? []; + return configured.filter((field) => !(field.id in (this.plugin.settings.taskListUserFieldShortcuts ?? {}))); + } + + /** Supplies the display label used by Obsidian's fuzzy matcher. */ + getItemText(field: UserMappedField): string { + return `${field.displayName} (${field.key})`; + } + + /** Persists the selected field through the settings-page callback. */ + onChooseItem(field: UserMappedField): void { + this.onChoose(field); + } +} + +/** Converts an NLP trigger glyph to the normalized task-list shortcut format. */ +function getDefaultUserFieldShortcut(trigger: string | undefined): string { + const first = trigger?.trim().charAt(0).toLowerCase() ?? ""; + if (first === "#" || first === "@") return `shift+${first}`; + if (first === "+") return "shift+plus"; + return first; +} const activeCaptureCleanup = new WeakMap void>(); @@ -236,6 +272,143 @@ export function renderKeyboardShortcutsTab( }); } + group.addSetting((setting: Setting) => { + // User-field shortcuts are stored separately because their action IDs + // are generated from settings rather than from the built-in action tuple. + setting + .setName(translate("settings.keyboardShortcuts.addUserField")) + .setDesc(translate("settings.keyboardShortcuts.addUserFieldDescription")) + .addButton((button) => + button.setButtonText(translate("settings.keyboardShortcuts.addUserField")).onClick(() => { + new UserFieldShortcutSuggestModal(plugin.app, plugin, (field) => { + const trigger = plugin.settings.nlpTriggers.triggers.find( + (candidate) => candidate.propertyId === field.id || candidate.propertyId === field.key + )?.trigger; + const candidate = getDefaultUserFieldShortcut(trigger); + const occupied = new Set([ + ...Object.values(plugin.settings.taskListShortcuts).flat(), + ...Object.values(plugin.settings.taskListUserFieldShortcuts ?? {}).flat(), + ]); + plugin.settings.taskListUserFieldShortcuts[field.id] = + candidate && !occupied.has(candidate) ? [candidate] : []; + save(); + renderKeyboardShortcutsTab(container, plugin, save); + }).open(); + }) + ); + }); + + for (const field of plugin.settings.userFields ?? []) { + const fieldShortcuts = plugin.settings.taskListUserFieldShortcuts?.[field.id]; + if (!fieldShortcuts) continue; + group.addSetting((setting) => { + setting.setName(field.displayName); + setting.setDesc(`Edit ${field.key}`); + for (const shortcut of fieldShortcuts) { + const shortcutButton = setting.controlEl.createEl("button", { + cls: "tasknotes-settings__shortcut-binding setting-hotkey", + attr: { type: "button", "aria-label": translate("settings.keyboardShortcuts.remove") }, + }); + shortcutButton.createSpan({ + cls: "tasknotes-settings__shortcut-value", + text: formatTaskListShortcut(shortcut, Platform.isMacOS), + }); + const removeIcon = shortcutButton.createSpan({ cls: "tasknotes-settings__shortcut-remove-icon" }); + setIcon(removeIcon, "circle-x"); + shortcutButton.addEventListener("click", () => { + plugin.settings.taskListUserFieldShortcuts[field.id] = fieldShortcuts.filter( + (value) => value !== shortcut + ); + save(); + renderKeyboardShortcutsTab(container, plugin, save); + }); + } + setting.addExtraButton((button) => + button.setIcon("rotate-ccw").setTooltip(translate("settings.keyboardShortcuts.resetAction")).onClick(() => { + const trigger = plugin.settings.nlpTriggers.triggers.find( + (candidate) => candidate.propertyId === field.id || candidate.propertyId === field.key + )?.trigger; + const candidate = getDefaultUserFieldShortcut(trigger); + const occupied = new Set([ + ...Object.values(plugin.settings.taskListShortcuts).flat(), + ...Object.entries(plugin.settings.taskListUserFieldShortcuts ?? {}) + .filter(([id]) => id !== field.id) + .flatMap(([, values]) => values), + ]); + plugin.settings.taskListUserFieldShortcuts[field.id] = candidate && !occupied.has(candidate) ? [candidate] : []; + save(); + renderKeyboardShortcutsTab(container, plugin, save); + }) + ); + setting.addButton((button) => { + button.buttonEl.addClass("tasknotes-settings__shortcut-add", "clickable-icon"); + setIcon(button.buttonEl, "circle-plus"); + button.setTooltip(translate("settings.keyboardShortcuts.captureHint")).onClick(() => { + const buttonEl = button.buttonEl; + buttonEl.setText(translate("settings.keyboardShortcuts.recording")); + let stopped = false; + let popCaptureScope = () => {}; + const stopCapture = () => { + if (stopped) return; + stopped = true; + buttonEl.removeEventListener("keydown", captureListener); + popCaptureScope(); + }; + const capture = (event: KeyboardEvent) => { + event.preventDefault(); + event.stopPropagation(); + const shortcut = keyboardEventToTaskListShortcut(event); + if (!shortcut) return; + stopCapture(); + const owners = [ + ...Object.entries(plugin.settings.taskListShortcuts) + .filter(([, values]) => values.includes(shortcut)) + .map(([action]) => action), + ...Object.entries(plugin.settings.taskListUserFieldShortcuts ?? {}) + .filter(([id, values]) => id !== field.id && values.includes(shortcut)) + .map(([id]) => id), + ]; + if (owners.length > 0) { + void showConfirmationModal(plugin.app, { + title: translate("settings.keyboardShortcuts.duplicateTitle"), + message: translate("settings.keyboardShortcuts.duplicateMessage", { + shortcut: formatTaskListShortcut(shortcut, Platform.isMacOS), + actions: owners.join(", "), + }), + confirmText: translate("settings.keyboardShortcuts.replace"), + cancelText: translate("common.cancel"), + }).then((replace) => { + if (!replace) return; + for (const action of owners) { + if (action in plugin.settings.taskListShortcuts) { + const key = action as keyof typeof plugin.settings.taskListShortcuts; + plugin.settings.taskListShortcuts[key] = plugin.settings.taskListShortcuts[key].filter((value) => value !== shortcut); + } else { + plugin.settings.taskListUserFieldShortcuts[action] = plugin.settings.taskListUserFieldShortcuts[action].filter((value) => value !== shortcut); + } + } + plugin.settings.taskListUserFieldShortcuts[field.id] = [shortcut]; + save(); + renderKeyboardShortcutsTab(container, plugin, save); + }); + return; + } + plugin.settings.taskListUserFieldShortcuts[field.id] = [ + ...(plugin.settings.taskListUserFieldShortcuts[field.id] ?? []), + shortcut, + ]; + save(); + renderKeyboardShortcutsTab(container, plugin, save); + }; + const captureListener = (event: KeyboardEvent) => capture(event); + buttonEl.addEventListener("keydown", captureListener); + popCaptureScope = pushKeyboardShortcutCaptureScope(plugin, capture); + buttonEl.focus(); + }); + }); + }); + } + group.addSetting((setting: Setting) => { setting .setName(translate("settings.keyboardShortcuts.resetAll")) @@ -245,12 +418,13 @@ export function renderKeyboardShortcutsTab( .setButtonText(translate("settings.keyboardShortcuts.resetAll")) .setWarning() .onClick(() => { - plugin.settings.taskListShortcuts = Object.fromEntries( + plugin.settings.taskListShortcuts = Object.fromEntries( TASK_LIST_KEYBOARD_ACTIONS.map((action) => [ action, [...DEFAULT_TASK_LIST_SHORTCUTS[action]], ]) - ) as typeof plugin.settings.taskListShortcuts; + ) as typeof plugin.settings.taskListShortcuts; + plugin.settings.taskListUserFieldShortcuts = {}; save(); renderKeyboardShortcutsTab(container, plugin, save); }) diff --git a/src/types/settings.ts b/src/types/settings.ts index adb5edc8a..65b978909 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -156,6 +156,10 @@ export interface TaskNotesSettings { taskListGroupDropBehavior: TaskListGroupDropBehavior; // View-local task-list keyboard shortcuts taskListShortcuts: TaskListShortcutMap; + // View-local shortcuts for configured user-defined task fields + taskListUserFieldShortcuts: Record; + // Recently used values for configured user-defined task fields + userFieldMru: Record; // Inline task conversion settings inlineTaskConvertFolder: string; // Folder for inline task conversion, supports {{currentNotePath}} and {{currentNoteTitle}} // Performance settings diff --git a/styles/task-modal.css b/styles/task-modal.css index c1be8c4c6..4e74f9129 100644 --- a/styles/task-modal.css +++ b/styles/task-modal.css @@ -1636,3 +1636,38 @@ body.is-mobile .tasknotes-plugin .task-project-item--task-card .task-project-rem color: var(--text-muted); margin-top: 2px; } +/* Compact editor used by configurable user-field shortcuts. */ +.tasknotes-user-field-edit-modal .tasknotes-user-field-mru, +.tasknotes-user-field-edit-modal .tasknotes-user-field-values { + display: flex; + flex-wrap: wrap; + gap: var(--size-4-2); + margin: var(--size-4-2) 0; +} + +.tasknotes-user-field-edit-modal .tasknotes-user-field-mru button, +.tasknotes-user-field-edit-modal .tasknotes-user-field-value { + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + padding: var(--size-4-1) var(--size-4-2); +} + +.tasknotes-user-field-edit-modal .tasknotes-user-field-value { + display: inline-flex; + align-items: center; + gap: var(--size-4-1); +} + +.tasknotes-user-field-edit-modal .tasknotes-user-field-value-remove { + display: inline-flex; + cursor: pointer; +} + +.tasknotes-user-field-edit-modal .tasknotes-user-field-value-remove:hover { + color: var(--text-error); +} + +.tasknotes-user-field-edit-footer { + display: flex; + justify-content: flex-end; +} From f77c5d4b30d2365751272f92d5eed61d1e25bf3a Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Fri, 31 Jul 2026 15:36:14 -0600 Subject: [PATCH 37/55] User field hotkey tests --- .../TaskListView.keyboardActions.test.ts | 10 +++++----- .../bases/taskListKeyboardActions.test.ts | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/tests/unit/bases/TaskListView.keyboardActions.test.ts b/tests/unit/bases/TaskListView.keyboardActions.test.ts index 7ffedcd63..0f53252a8 100644 --- a/tests/unit/bases/TaskListView.keyboardActions.test.ts +++ b/tests/unit/bases/TaskListView.keyboardActions.test.ts @@ -77,7 +77,7 @@ describe("TaskListView keyboard actions", () => { expect(event.defaultPrevented).toBe(true); expect(stopPropagation).toHaveBeenCalled(); - expect(executeTaskListAction).toHaveBeenCalledWith("edit-due"); + expect(executeTaskListAction).toHaveBeenCalledWith("edit-due", "focused.md"); }); it("routes configured Gmail-style navigation through the focus controller", () => { @@ -153,7 +153,7 @@ describe("TaskListView keyboard actions", () => { expect(getFocusedPathForEvent).toHaveBeenCalledWith(event, true, false); expect(event.defaultPrevented).toBe(true); - expect(executeTaskListAction).toHaveBeenCalledWith(action); + expect(executeTaskListAction).toHaveBeenCalledWith(action, "focused.md"); }); it("does not claim shortcuts when an interactive control owns focus", () => { @@ -191,7 +191,7 @@ describe("TaskListView keyboard actions", () => { ); expect(getFocusedPathForEvent).toHaveBeenCalledWith(event, true, true); - expect(executeTaskListAction).toHaveBeenCalledWith("edit-due"); + expect(executeTaskListAction).toHaveBeenCalledWith("edit-due", "remembered.md"); }); it("routes a body-targeted shortcut through remembered task focus after a rerender", () => { @@ -215,7 +215,7 @@ describe("TaskListView keyboard actions", () => { expect(canHandleListKeyDown).toHaveBeenCalledWith(event, true); expect(getFocusedPathForEvent).toHaveBeenCalledWith(event, true, true); - expect(executeTaskListAction).toHaveBeenCalledWith("toggle-select"); + expect(executeTaskListAction).toHaveBeenCalledWith("toggle-select", "remembered.md"); }); it("routes a prevented modifier chord from the active view shell", () => { @@ -246,7 +246,7 @@ describe("TaskListView keyboard actions", () => { true ); - expect(executeTaskListAction).toHaveBeenCalledWith("select-all"); + expect(executeTaskListAction).toHaveBeenCalledWith("select-all", null); }); it("does not discard a prevented chord before shell shortcut routing", () => { diff --git a/tests/unit/bases/taskListKeyboardActions.test.ts b/tests/unit/bases/taskListKeyboardActions.test.ts index b6f9296e3..757c6dd9a 100644 --- a/tests/unit/bases/taskListKeyboardActions.test.ts +++ b/tests/unit/bases/taskListKeyboardActions.test.ts @@ -46,11 +46,14 @@ describe("resolveDefaultTaskListKeyboardAction", () => { ["c", {}, "create-task"], ["/", {}, "focus-search"], ["Enter", {}, "edit-task"], + ["e", {}, "open-context-menu"], ["Enter", { shiftKey: true }, "open-task-notes"], ["d", {}, "edit-due"], ["s", { shiftKey: true }, "edit-scheduled"], ["p", {}, "edit-priority"], + ["!", { shiftKey: true }, "edit-priority"], ["s", {}, "edit-status"], + ["*", { shiftKey: true }, "edit-status"], ["r", {}, "edit-recurrence"], ["#", { shiftKey: true }, "add-tags"], ["@", { shiftKey: true }, "add-context"], @@ -85,6 +88,22 @@ describe("resolveDefaultTaskListKeyboardAction", () => { expect(resolveTaskListKeyboardAction(key("d"), shortcuts)).toBeNull(); }); + it("resolves a configured user-field shortcut to its dynamic action", () => { + const shortcuts = normalizeTaskListShortcutMap({}); + + expect( + resolveTaskListKeyboardAction(key("q"), shortcuts, { effort: ["q"] }) + ).toBe("edit-user-field:effort"); + }); + + it("gives built-in shortcuts precedence over user-field collisions", () => { + const shortcuts = normalizeTaskListShortcutMap({}); + + expect( + resolveTaskListKeyboardAction(key("d"), shortcuts, { effort: ["d"] }) + ).toBe("edit-due"); + }); + it.each([ [" Control + Shift + K ", "mod+shift+k"], ["Cmd+Return", "mod+enter"], From 0d96a84a0a429d44b5691285a576b01d4fee2de3 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Fri, 31 Jul 2026 15:43:41 -0600 Subject: [PATCH 38/55] better keybaord nav of MRU values in user field modal --- src/modals/UserFieldEditModal.ts | 54 ++++++++++++++++-- .../UserFieldEditModal.keyboard.test.ts | 55 +++++++++++++++++++ 2 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 tests/unit/modals/UserFieldEditModal.keyboard.test.ts diff --git a/src/modals/UserFieldEditModal.ts b/src/modals/UserFieldEditModal.ts index 1f2fc9114..99538689c 100644 --- a/src/modals/UserFieldEditModal.ts +++ b/src/modals/UserFieldEditModal.ts @@ -27,6 +27,9 @@ export class UserFieldEditModal extends Modal { private value: UserFieldValue; private listValues: string[]; private readonly initialListValues: string[]; + private mruButtons: HTMLButtonElement[] = []; + private mruFocusIndex = -1; + private suppressNextMruClick = false; private closed = false; private readonly keydownHandler = (event: KeyboardEvent) => { if (event.key === "Escape") { @@ -76,6 +79,8 @@ export class UserFieldEditModal extends Modal { /** Rebuilds the popup after a type-specific value or chip changes. */ private render(): void { this.contentEl.empty(); + this.mruButtons = []; + this.mruFocusIndex = -1; const field = this.options.field; const current = this.value; @@ -159,13 +164,52 @@ export class UserFieldEditModal extends Modal { if (values.length === 0) return; this.contentEl.createDiv({ text: "Recent values", cls: "setting-item-name" }); const row = this.contentEl.createDiv({ cls: "tasknotes-user-field-mru" }); - for (const value of values.slice(0, 5)) { - row.createEl("button", { text: this.toDisplayValue(value) }).addEventListener("click", () => { - this.value = value; - if (this.input) this.input.value = this.toInputValue(value); - if (this.options.field.type === "list") void this.apply(); + for (const [index, value] of values.slice(0, 5).entries()) { + const button = row.createEl("button", { text: this.toDisplayValue(value) }); + button.type = "button"; + button.tabIndex = 0; + this.mruButtons.push(button); + button.addEventListener("focus", () => { + this.mruFocusIndex = index; }); + button.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + // Keep Enter as a two-step keyboard flow: choose the MRU value, + // then confirm it from the editor input on the next Enter. + event.preventDefault(); + event.stopPropagation(); + this.suppressNextMruClick = true; + this.selectMruValue(value, true, false); + return; + } + if (!["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp"].includes(event.key)) return; + event.preventDefault(); + event.stopPropagation(); + const direction = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1; + const count = this.mruButtons.length; + if (count === 0) return; + const currentIndex = this.mruFocusIndex >= 0 ? this.mruFocusIndex : index; + const nextIndex = (currentIndex + direction + count) % count; + this.mruButtons[nextIndex]?.focus(); + }); + button.addEventListener("click", () => { + if (this.suppressNextMruClick) { + this.suppressNextMruClick = false; + return; + } + this.selectMruValue(value, false, true); + }); + } + } + + /** Populates the editor from an MRU entry and optionally returns focus to its input. */ + private selectMruValue(value: unknown, focusInput: boolean, applyList: boolean): void { + this.value = value; + if (this.input) { + this.input.value = this.toInputValue(value); + if (focusInput) this.input.focus(); } + if (applyList && this.options.field.type === "list") void this.apply(); } /** Renders removable chips for the first target's current list values. */ diff --git a/tests/unit/modals/UserFieldEditModal.keyboard.test.ts b/tests/unit/modals/UserFieldEditModal.keyboard.test.ts new file mode 100644 index 000000000..79822db66 --- /dev/null +++ b/tests/unit/modals/UserFieldEditModal.keyboard.test.ts @@ -0,0 +1,55 @@ +import { UserFieldEditModal } from "../../../src/modals/UserFieldEditModal"; + +function createModal() { + const onApply = jest.fn().mockResolvedValue(undefined); + const plugin = { + settings: { + userFieldMru: { + effort: ["one", "two", "three"], + }, + }, + saveSettings: jest.fn().mockResolvedValue(undefined), + }; + const modal = new UserFieldEditModal({} as any, plugin as any, { + field: { id: "effort", key: "effort", displayName: "Effort", type: "text" }, + tasks: [{ path: "task.md", title: "Task", effort: "old" } as any], + onApply, + }); + (modal as any).onOpen(); + document.body.appendChild(modal.contentEl); + return { modal, onApply }; +} + +describe("UserFieldEditModal MRU keyboard navigation", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("moves through MRU values with arrows and returns focus to the input on Enter", () => { + const { modal, onApply } = createModal(); + const input = (modal as any).input as HTMLInputElement; + const buttons = Array.from( + modal.contentEl.querySelectorAll(".tasknotes-user-field-mru button") + ); + + buttons[0].focus(); + buttons[0].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + expect(document.activeElement).toBe(buttons[1]); + + buttons[1].dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + expect(input.value).toBe("two"); + expect(document.activeElement).toBe(input); + expect(onApply).not.toHaveBeenCalled(); + }); + + it("wraps from the first MRU value to the last with ArrowUp", () => { + const { modal } = createModal(); + const buttons = Array.from( + modal.contentEl.querySelectorAll(".tasknotes-user-field-mru button") + ); + + buttons[0].focus(); + buttons[0].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })); + expect(document.activeElement).toBe(buttons[2]); + }); +}); From b13fb88ea543866729ab3fa5ec122c905d9e520e Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Fri, 31 Jul 2026 16:53:34 -0600 Subject: [PATCH 39/55] Restore focus and hotkeys after user input modal --- src/bases/TaskListInputOwnershipController.ts | 11 ++++ src/bases/TaskListView.ts | 34 +++++++++--- src/modals/UserFieldEditModal.ts | 4 ++ .../TaskListInputOwnershipController.test.ts | 10 ++++ .../TaskListView.keyboardActions.test.ts | 54 +++++++++++++++++++ .../UserFieldEditModal.keyboard.test.ts | 12 ++++- 6 files changed, 117 insertions(+), 8 deletions(-) diff --git a/src/bases/TaskListInputOwnershipController.ts b/src/bases/TaskListInputOwnershipController.ts index c762b6eab..d1d9f5e8f 100644 --- a/src/bases/TaskListInputOwnershipController.ts +++ b/src/bases/TaskListInputOwnershipController.ts @@ -117,6 +117,17 @@ export class TaskListInputOwnershipController { this.restoreTimer = win.setTimeout(check, 0); } + /** Explicitly resumes list keyboard ownership after a modal has closed. */ + resumeAfterOverlayClose(): void { + if (this.restoreTimer !== null) { + const win = this.viewRoot.ownerDocument.defaultView ?? window; + win.clearTimeout(this.restoreTimer); + this.restoreTimer = null; + } + this.suspendedForOverlay = false; + this.restoreObservedOverlay = false; + } + destroy(): void { if (this.restoreTimer !== null) { const win = this.viewRoot.ownerDocument.defaultView ?? window; diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index f3ff18c43..bd1608553 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -84,6 +84,7 @@ import { taskListShortcutToScopeBinding, TASK_LIST_KEYBOARD_ACTIONS, type TaskListAction, + type TaskListKeyboardAction, } from "./taskListKeyboardActions"; import { addTagsToList, parseTaskTagInput } from "../utils/taskTagList"; import { addContextToList } from "../components/TaskContextMenu"; @@ -2664,7 +2665,7 @@ export class TaskListView extends BasesViewBase { event.preventDefault(); event.stopPropagation(); - if ( + const opensTaskListOverlay = [ "edit-task", "open-context-menu", @@ -2677,8 +2678,10 @@ export class TaskListView extends BasesViewBase { "add-context", "add-project", "delete-tasks", - ].includes(action) - ) { + ].includes(action as TaskListKeyboardAction) || action.startsWith("edit-user-field:"); + if (opensTaskListOverlay) { + // User-field editors are overlays too; recording this before opening lets + // input ownership restore the remembered card when the modal closes. this.inputOwnershipController?.noteOverlayOpening(); } void this.executeTaskListAction(action, focusedPath ?? null); @@ -2797,7 +2800,7 @@ export class TaskListView extends BasesViewBase { new UserFieldEditModal(this.plugin.app, this.plugin, { field, tasks, - onApply: async (value, listChange) => { + onApply: async (value, listChange) => { for (const task of tasks) { let taskValue = value; if (field.type === "list") { @@ -2820,9 +2823,12 @@ export class TaskListView extends BasesViewBase { taskValue as TaskInfo[keyof TaskInfo], { silent: true } ); - } - }, - }).open(); + } + }, + onClose: () => { + this.restoreTaskListFocusAfterOverlayClose(); + }, + }).open(); } } } @@ -2837,6 +2843,20 @@ export class TaskListView extends BasesViewBase { this.searchBox?.focus(); } + /** Restores card focus after Obsidian completes its modal selection cleanup. */ + private restoreTaskListFocusAfterOverlayClose(): void { + // Obsidian restores the modal's saved selection after onClose; defer card + // focus until that cleanup has finished so keyboard ownership is retained. + setTimeout(() => { + this.focusController?.restoreFocusedElement(); + this.inputOwnershipController?.resumeAfterOverlayClose(); + this.syncTaskListShortcutScopeForFocusTarget( + this.containerEl.ownerDocument.activeElement + ); + if (this.taskListLeafActive) this.activateTaskListShortcutScope(); + }, 0); + } + private async getTaskActionTargets(): Promise { const tasks: TaskInfo[] = []; for (const path of this.getTaskActionTargetPaths()) { diff --git a/src/modals/UserFieldEditModal.ts b/src/modals/UserFieldEditModal.ts index 99538689c..e7bc92be6 100644 --- a/src/modals/UserFieldEditModal.ts +++ b/src/modals/UserFieldEditModal.ts @@ -14,6 +14,8 @@ export interface UserFieldEditModalOptions { field: UserMappedField; tasks: readonly TaskInfo[]; onApply: (value: UserFieldValue, listChange?: { added?: string; removed?: string[] }) => Promise; + /** Called after the popup closes so the caller can restore its keyboard target. */ + onClose?: () => void; } /** @@ -61,6 +63,8 @@ export class UserFieldEditModal extends Modal { this.closed = true; this.contentEl.removeEventListener("keydown", this.keydownHandler, true); this.contentEl.empty(); + // Return focus ownership to the task-list view after Apply, Cancel, or Escape. + this.options.onClose?.(); } /** Reads the first target's current value to seed the editor and MRU choices. */ diff --git a/tests/unit/bases/TaskListInputOwnershipController.test.ts b/tests/unit/bases/TaskListInputOwnershipController.test.ts index ccb6d2989..8e149ada7 100644 --- a/tests/unit/bases/TaskListInputOwnershipController.test.ts +++ b/tests/unit/bases/TaskListInputOwnershipController.test.ts @@ -127,6 +127,16 @@ describe("TaskListInputOwnershipController", () => { expect(controller.canHandleListKeyDown(event)).toBe(true); }); + it("resumes keyboard ownership when the modal close callback runs after cleanup", () => { + controller.noteOverlayOpening(); + controller.resumeAfterOverlayClose(); + + const event = new KeyboardEvent("keydown", { key: "d" }); + Object.defineProperty(event, "target", { value: taskCard }); + + expect(controller.canHandleListKeyDown(event)).toBe(true); + }); + it("does not steal focus when the user moved to another control", () => { controller.noteOverlayOpening(); const outsideInput = document.createElement("input"); diff --git a/tests/unit/bases/TaskListView.keyboardActions.test.ts b/tests/unit/bases/TaskListView.keyboardActions.test.ts index 0f53252a8..5cc7b45a9 100644 --- a/tests/unit/bases/TaskListView.keyboardActions.test.ts +++ b/tests/unit/bases/TaskListView.keyboardActions.test.ts @@ -103,6 +103,60 @@ describe("TaskListView keyboard actions", () => { expect(moveFocus).toHaveBeenCalledWith(event, "next"); }); + it("records an overlay before opening a user-field editor so focus can be restored", () => { + const noteOverlayOpening = jest.fn(); + const executeTaskListAction = jest.fn(); + const view = { + plugin: { + settings: { + taskListShortcuts: normalizeTaskListShortcutMap({}), + taskListUserFieldShortcuts: { effort: ["q"] }, + }, + }, + focusController: { + getFocusedPathForEvent: jest.fn(() => "focused.md"), + }, + inputOwnershipController: { noteOverlayOpening }, + executeTaskListAction, + }; + const event = new KeyboardEvent("keydown", { key: "q", cancelable: true }); + + (TaskListView.prototype as any).handleTaskListActionKeyDown.call(view, event); + + expect(noteOverlayOpening).toHaveBeenCalledTimes(1); + expect(executeTaskListAction).toHaveBeenCalledWith("edit-user-field:effort", "focused.md"); + }); + + it("defers task-card focus until after Obsidian modal cleanup", () => { + jest.useFakeTimers(); + try { + const restoreFocusedElement = jest.fn(); + const resumeAfterOverlayClose = jest.fn(); + const syncTaskListShortcutScopeForFocusTarget = jest.fn(); + const view = { + focusController: { restoreFocusedElement }, + inputOwnershipController: { resumeAfterOverlayClose }, + containerEl: document.createElement("div"), + syncTaskListShortcutScopeForFocusTarget, + taskListLeafActive: true, + activateTaskListShortcutScope: jest.fn(), + }; + + (TaskListView.prototype as any).restoreTaskListFocusAfterOverlayClose.call(view); + + expect(restoreFocusedElement).not.toHaveBeenCalled(); + jest.runAllTimers(); + expect(restoreFocusedElement).toHaveBeenCalledTimes(1); + expect(resumeAfterOverlayClose).toHaveBeenCalledTimes(1); + expect(syncTaskListShortcutScopeForFocusTarget).toHaveBeenCalledWith( + view.containerEl.ownerDocument.activeElement + ); + expect(view.activateTaskListShortcutScope).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + } + }); + it.each([ ["g", "jump-first", "first"], ["G", "jump-last", "last"], diff --git a/tests/unit/modals/UserFieldEditModal.keyboard.test.ts b/tests/unit/modals/UserFieldEditModal.keyboard.test.ts index 79822db66..23aed0295 100644 --- a/tests/unit/modals/UserFieldEditModal.keyboard.test.ts +++ b/tests/unit/modals/UserFieldEditModal.keyboard.test.ts @@ -2,6 +2,7 @@ import { UserFieldEditModal } from "../../../src/modals/UserFieldEditModal"; function createModal() { const onApply = jest.fn().mockResolvedValue(undefined); + const onClose = jest.fn(); const plugin = { settings: { userFieldMru: { @@ -14,10 +15,11 @@ function createModal() { field: { id: "effort", key: "effort", displayName: "Effort", type: "text" }, tasks: [{ path: "task.md", title: "Task", effort: "old" } as any], onApply, + onClose, }); (modal as any).onOpen(); document.body.appendChild(modal.contentEl); - return { modal, onApply }; + return { modal, onApply, onClose }; } describe("UserFieldEditModal MRU keyboard navigation", () => { @@ -52,4 +54,12 @@ describe("UserFieldEditModal MRU keyboard navigation", () => { buttons[0].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })); expect(document.activeElement).toBe(buttons[2]); }); + + it("notifies the task view when the popup closes", () => { + const { modal, onClose } = createModal(); + + modal.onClose(); + + expect(onClose).toHaveBeenCalledTimes(1); + }); }); From 770884ab2a601a1c54b0651ef518ece4096e62f2 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Fri, 31 Jul 2026 17:08:18 -0600 Subject: [PATCH 40/55] alt+down key accelerator to select MRU vals, enter confirms date field --- src/modals/UserFieldEditModal.ts | 29 +++++++++- styles/task-modal.css | 4 ++ .../UserFieldEditModal.keyboard.test.ts | 58 +++++++++++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/src/modals/UserFieldEditModal.ts b/src/modals/UserFieldEditModal.ts index e7bc92be6..dc406b84a 100644 --- a/src/modals/UserFieldEditModal.ts +++ b/src/modals/UserFieldEditModal.ts @@ -137,12 +137,20 @@ export class UserFieldEditModal extends Modal { this.value = this.parseInputValue(value); }); text.inputEl.addEventListener("keydown", (event) => { + if (event.key === "ArrowDown" && event.altKey && this.mruButtons.length > 0) { + // Alt+Down is the conventional combo-box accelerator and avoids + // tabbing through native date-input controls to reach MRU values. + event.preventDefault(); + event.stopPropagation(); + this.mruButtons[0]?.focus(); + return; + } if (event.key === "Enter") { event.preventDefault(); event.stopPropagation(); void this.apply(); } - }); + }, true); }); if (this.options.field.type === "date") { setting.addButton((button) => @@ -211,7 +219,18 @@ export class UserFieldEditModal extends Modal { this.value = value; if (this.input) { this.input.value = this.toInputValue(value); - if (focusInput) this.input.focus(); + if (focusInput) { + const input = this.input; + input.focus(); + // Native date controls can move focus to their adjacent picker button + // while completing the MRU button's Enter event; reclaim it afterward. + if (this.options.field.type === "date") { + const win = input.ownerDocument.defaultView ?? window; + win.setTimeout(() => { + if (!this.closed && this.input === input) input.focus(); + }, 0); + } + } } if (applyList && this.options.field.type === "list") void this.apply(); } @@ -225,7 +244,11 @@ export class UserFieldEditModal extends Modal { for (const value of values) { const chip = row.createDiv({ cls: "tasknotes-user-field-value" }); chip.createSpan({ text: value }); - const remove = chip.createSpan({ cls: "tasknotes-user-field-value-remove" }); + const remove = chip.createEl("button", { + cls: "tasknotes-user-field-value-remove", + attr: { "aria-label": `Remove ${value}` }, + }); + remove.type = "button"; setIcon(remove, "circle-x"); remove.addEventListener("click", () => { this.listValues = values.filter((candidate) => candidate !== value); diff --git a/styles/task-modal.css b/styles/task-modal.css index 4e74f9129..d1b472d81 100644 --- a/styles/task-modal.css +++ b/styles/task-modal.css @@ -1661,6 +1661,10 @@ body.is-mobile .tasknotes-plugin .task-project-item--task-card .task-project-rem .tasknotes-user-field-edit-modal .tasknotes-user-field-value-remove { display: inline-flex; cursor: pointer; + padding: 0; + border: 0; + background: transparent; + color: inherit; } .tasknotes-user-field-edit-modal .tasknotes-user-field-value-remove:hover { diff --git a/tests/unit/modals/UserFieldEditModal.keyboard.test.ts b/tests/unit/modals/UserFieldEditModal.keyboard.test.ts index 23aed0295..1e5371506 100644 --- a/tests/unit/modals/UserFieldEditModal.keyboard.test.ts +++ b/tests/unit/modals/UserFieldEditModal.keyboard.test.ts @@ -62,4 +62,62 @@ describe("UserFieldEditModal MRU keyboard navigation", () => { expect(onClose).toHaveBeenCalledTimes(1); }); + + it("makes list-value removal controls tabbable and provides Alt+Down MRU access", () => { + const plugin = { + settings: { userFieldMru: { labels: ["urgent", "later"] } }, + saveSettings: jest.fn().mockResolvedValue(undefined), + }; + const modal = new UserFieldEditModal({} as any, plugin as any, { + field: { id: "labels", key: "labels", displayName: "Labels", type: "list" }, + tasks: [{ path: "task.md", title: "Task", customProperties: { labels: ["current"] } } as any], + onApply: jest.fn().mockResolvedValue(undefined), + }); + (modal as any).onOpen(); + document.body.appendChild(modal.contentEl); + + const input = (modal as any).input as HTMLInputElement; + const removeButton = modal.contentEl.querySelector( + ".tasknotes-user-field-value-remove" + ); + const mruButtons = Array.from( + modal.contentEl.querySelectorAll(".tasknotes-user-field-mru button") + ); + + expect(removeButton?.tabIndex).toBe(0); + input.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowDown", altKey: true, bubbles: true }) + ); + expect(document.activeElement).toBe(mruButtons[0]); + }); + + it("keeps the date input focused after selecting an MRU value with Enter", () => { + jest.useFakeTimers(); + try { + const onApply = jest.fn().mockResolvedValue(undefined); + const plugin = { + settings: { userFieldMru: { dueDate: ["2026-08-03"] } }, + saveSettings: jest.fn().mockResolvedValue(undefined), + }; + const modal = new UserFieldEditModal({} as any, plugin as any, { + field: { id: "dueDate", key: "dueDate", displayName: "Due date", type: "date" }, + tasks: [{ path: "task.md", title: "Task" } as any], + onApply, + }); + (modal as any).onOpen(); + document.body.appendChild(modal.contentEl); + + const input = (modal as any).input as HTMLInputElement; + const mruButton = modal.contentEl.querySelector( + ".tasknotes-user-field-mru button" + ); + mruButton?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + jest.runAllTimers(); + + expect(input.value).toBe("2026-08-03"); + expect(document.activeElement).toBe(input); + } finally { + jest.useRealTimers(); + } + }); }); From 6fb77cdbb8042a06de6e6a8c72ab5845fbcc742d Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Fri, 31 Jul 2026 17:17:56 -0600 Subject: [PATCH 41/55] Hotkey settings "add user field" renders below existing user hotkeys --- src/settings/tabs/keyboardShortcutsTab.ts | 52 +++++++++++------------ 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/src/settings/tabs/keyboardShortcutsTab.ts b/src/settings/tabs/keyboardShortcutsTab.ts index 8472879a1..f4e84125a 100644 --- a/src/settings/tabs/keyboardShortcutsTab.ts +++ b/src/settings/tabs/keyboardShortcutsTab.ts @@ -272,32 +272,6 @@ export function renderKeyboardShortcutsTab( }); } - group.addSetting((setting: Setting) => { - // User-field shortcuts are stored separately because their action IDs - // are generated from settings rather than from the built-in action tuple. - setting - .setName(translate("settings.keyboardShortcuts.addUserField")) - .setDesc(translate("settings.keyboardShortcuts.addUserFieldDescription")) - .addButton((button) => - button.setButtonText(translate("settings.keyboardShortcuts.addUserField")).onClick(() => { - new UserFieldShortcutSuggestModal(plugin.app, plugin, (field) => { - const trigger = plugin.settings.nlpTriggers.triggers.find( - (candidate) => candidate.propertyId === field.id || candidate.propertyId === field.key - )?.trigger; - const candidate = getDefaultUserFieldShortcut(trigger); - const occupied = new Set([ - ...Object.values(plugin.settings.taskListShortcuts).flat(), - ...Object.values(plugin.settings.taskListUserFieldShortcuts ?? {}).flat(), - ]); - plugin.settings.taskListUserFieldShortcuts[field.id] = - candidate && !occupied.has(candidate) ? [candidate] : []; - save(); - renderKeyboardShortcutsTab(container, plugin, save); - }).open(); - }) - ); - }); - for (const field of plugin.settings.userFields ?? []) { const fieldShortcuts = plugin.settings.taskListUserFieldShortcuts?.[field.id]; if (!fieldShortcuts) continue; @@ -409,6 +383,32 @@ export function renderKeyboardShortcutsTab( }); } + // Keep the add control after every configured user-field shortcut so the + // field list reads as one contiguous section. + group.addSetting((setting: Setting) => { + setting + .setName(translate("settings.keyboardShortcuts.addUserField")) + .setDesc(translate("settings.keyboardShortcuts.addUserFieldDescription")) + .addButton((button) => + button.setButtonText(translate("settings.keyboardShortcuts.addUserField")).onClick(() => { + new UserFieldShortcutSuggestModal(plugin.app, plugin, (field) => { + const trigger = plugin.settings.nlpTriggers.triggers.find( + (candidate) => candidate.propertyId === field.id || candidate.propertyId === field.key + )?.trigger; + const candidate = getDefaultUserFieldShortcut(trigger); + const occupied = new Set([ + ...Object.values(plugin.settings.taskListShortcuts).flat(), + ...Object.values(plugin.settings.taskListUserFieldShortcuts ?? {}).flat(), + ]); + plugin.settings.taskListUserFieldShortcuts[field.id] = + candidate && !occupied.has(candidate) ? [candidate] : []; + save(); + renderKeyboardShortcutsTab(container, plugin, save); + }).open(); + }) + ); + }); + group.addSetting((setting: Setting) => { setting .setName(translate("settings.keyboardShortcuts.resetAll")) From ec3122bf79a5ce013609224ef75bc296019a3b95 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Tue, 4 Aug 2026 10:41:19 -0600 Subject: [PATCH 42/55] i18n instructions for agents.md --- AGENTS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index a17fdc951..5f48b7564 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,3 +95,6 @@ Use Serena’s symbol and reference tools for semantic navigation whenever possi Use ripgrep for textual searches, configuration strings, CSS classes, serialized identifiers, and cases where semantic lookup is inappropriate. + +## Internationalization +Localize all user-facing strings. Refer to the internationalization guide @I18N_GUIDE.md. From 1b4ac0547fb074461a621ef62193d33cd3546a6f Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Tue, 4 Aug 2026 10:42:54 -0600 Subject: [PATCH 43/55] Show field displayname in hotkey overwrite warning --- src/editor/RelationshipsDecorations.ts | 22 ++++++------- src/settings/tabs/keyboardShortcutsTab.ts | 26 +++++++++++++++- .../settings/keyboardShortcutsTab.test.ts | 31 ++++++++++++++++++- 3 files changed, 66 insertions(+), 13 deletions(-) diff --git a/src/editor/RelationshipsDecorations.ts b/src/editor/RelationshipsDecorations.ts index 7f53682db..01ea2da8d 100644 --- a/src/editor/RelationshipsDecorations.ts +++ b/src/editor/RelationshipsDecorations.ts @@ -39,33 +39,33 @@ * 3. Using Obsidian's registerMarkdownPostProcessor for reading mode only */ +import { Extension } from "@codemirror/state"; import { EditorView, PluginValue, ViewPlugin, ViewUpdate } from "@codemirror/view"; import { Component, + editorInfoField, EventRef, + MarkdownRenderer, MarkdownView, TFile, - editorInfoField, - MarkdownRenderer, WorkspaceLeaf, } from "obsidian"; -import { Extension } from "@codemirror/state"; import TaskNotesPlugin from "../main"; import { EVENT_DEPENDENCY_CACHE_CHANGED } from "../utils/DependencyCache"; -import { - ReadingModeInjectionContext, - ReadingModeInjectionScheduler, -} from "./ReadingModeInjectionScheduler"; +import { FilterUtils } from "../utils/FilterUtils"; +import { getProjectPropertyFilter, matchesProjectProperty } from "../utils/projectFilterUtils"; +import { collectCacheTags } from "../utils/tagExtraction"; +import { createTaskNotesLogger } from "../utils/tasknotesLogger"; import { shouldSkipMarkdownWidgetEditor, shouldSkipMarkdownWidgetLeaf, } from "./MarkdownWidgetContext"; import { insertAfterElement, insertAfterMetadataOrHeader } from "./MarkdownWidgetInsertion"; -import { FilterUtils } from "../utils/FilterUtils"; -import { collectCacheTags } from "../utils/tagExtraction"; -import { getProjectPropertyFilter, matchesProjectProperty } from "../utils/projectFilterUtils"; -import { createTaskNotesLogger } from "../utils/tasknotesLogger"; +import { + ReadingModeInjectionContext, + ReadingModeInjectionScheduler, +} from "./ReadingModeInjectionScheduler"; const tasknotesLogger = createTaskNotesLogger({ tag: "Editor/RelationshipsDecorations" }); diff --git a/src/settings/tabs/keyboardShortcutsTab.ts b/src/settings/tabs/keyboardShortcutsTab.ts index f4e84125a..1e5be9823 100644 --- a/src/settings/tabs/keyboardShortcutsTab.ts +++ b/src/settings/tabs/keyboardShortcutsTab.ts @@ -83,6 +83,22 @@ function actionKey(action: TaskListKeyboardAction): TranslationKey { return `settings.keyboardShortcuts.actions.${action}`; } +/** Formats a shortcut owner for conflict prompts using its user-facing label and stable ID. */ +export function formatShortcutOwnerLabel( + owner: string, + fields: readonly UserMappedField[], + translate: (key: TranslationKey) => string +): string { + if ((TASK_LIST_KEYBOARD_ACTIONS as readonly string[]).includes(owner)) { + return translate(actionKey(owner as TaskListKeyboardAction)); + } + + // Dynamic shortcut maps are keyed by stable field IDs, so resolve that ID + // back to the configured display name while retaining the ID for diagnosis. + const field = fields.find((candidate) => candidate.id === owner); + return field ? `${field.displayName} (${field.id})` : owner; +} + export function renderKeyboardShortcutsTab( container: HTMLElement, plugin: TaskNotesPlugin, @@ -347,7 +363,15 @@ export function renderKeyboardShortcutsTab( title: translate("settings.keyboardShortcuts.duplicateTitle"), message: translate("settings.keyboardShortcuts.duplicateMessage", { shortcut: formatTaskListShortcut(shortcut, Platform.isMacOS), - actions: owners.join(", "), + actions: owners + .map((owner) => + formatShortcutOwnerLabel( + owner, + plugin.settings.userFields ?? [], + translate + ) + ) + .join(", "), }), confirmText: translate("settings.keyboardShortcuts.replace"), cancelText: translate("common.cancel"), diff --git a/tests/unit/settings/keyboardShortcutsTab.test.ts b/tests/unit/settings/keyboardShortcutsTab.test.ts index 3770aebfa..2cca53b1f 100644 --- a/tests/unit/settings/keyboardShortcutsTab.test.ts +++ b/tests/unit/settings/keyboardShortcutsTab.test.ts @@ -1,5 +1,34 @@ import { Scope } from "obsidian"; -import { pushKeyboardShortcutCaptureScope } from "../../../src/settings/tabs/keyboardShortcutsTab"; +import { + formatShortcutOwnerLabel, + pushKeyboardShortcutCaptureScope, +} from "../../../src/settings/tabs/keyboardShortcutsTab"; +import type { UserMappedField } from "../../../src/types/settings"; + +describe("keyboard shortcut owner labels", () => { + const translate = (key: string) => + key === "settings.keyboardShortcuts.actions.edit-task" ? "Edit task" : key; + + it("shows a user field display name followed by its stable ID", () => { + const fields: UserMappedField[] = [ + { + id: "field_1234", + key: "storyPoints", + displayName: "Story Points", + type: "number", + }, + ]; + + expect(formatShortcutOwnerLabel("field_1234", fields, translate)).toBe( + "Story Points (field_1234)" + ); + }); + + it("keeps translated built-in action labels and unknown owner fallbacks", () => { + expect(formatShortcutOwnerLabel("edit-task", [], translate)).toBe("Edit task"); + expect(formatShortcutOwnerLabel("missing_field", [], translate)).toBe("missing_field"); + }); +}); describe("keyboard shortcut capture scope", () => { it("swallows Escape, cancels capture, and pops itself", () => { From e3a87a18a1371c9cc45e045a16cba24a9d57d179 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Tue, 4 Aug 2026 10:49:21 -0600 Subject: [PATCH 44/55] Don't warn about overwriting hotkeys for deleted properties --- src/settings/tabs/keyboardShortcutsTab.ts | 38 +++++++++++++++++-- .../settings/keyboardShortcutsTab.test.ts | 22 +++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/settings/tabs/keyboardShortcutsTab.ts b/src/settings/tabs/keyboardShortcutsTab.ts index 1e5be9823..1f39cf5f7 100644 --- a/src/settings/tabs/keyboardShortcutsTab.ts +++ b/src/settings/tabs/keyboardShortcutsTab.ts @@ -99,6 +99,29 @@ export function formatShortcutOwnerLabel( return field ? `${field.displayName} (${field.id})` : owner; } +/** Separates live shortcut owners from IDs left behind by deleted user fields. */ +export function partitionShortcutOwners( + owners: readonly string[], + fields: readonly UserMappedField[] +): { activeOwners: string[]; staleFieldOwners: string[] } { + const fieldIds = new Set(fields.map((field) => field.id)); + const activeOwners: string[] = []; + const staleFieldOwners: string[] = []; + + for (const owner of owners) { + if ( + (TASK_LIST_KEYBOARD_ACTIONS as readonly string[]).includes(owner) || + fieldIds.has(owner) + ) { + activeOwners.push(owner); + } else { + staleFieldOwners.push(owner); + } + } + + return { activeOwners, staleFieldOwners }; +} + export function renderKeyboardShortcutsTab( container: HTMLElement, plugin: TaskNotesPlugin, @@ -358,12 +381,21 @@ export function renderKeyboardShortcutsTab( .filter(([id, values]) => id !== field.id && values.includes(shortcut)) .map(([id]) => id), ]; - if (owners.length > 0) { + const { activeOwners, staleFieldOwners } = partitionShortcutOwners( + owners, + plugin.settings.userFields ?? [] + ); + // Deleted fields cannot receive actions, so discard their orphaned + // bindings without interrupting assignment to a live field. + for (const staleOwner of staleFieldOwners) { + delete plugin.settings.taskListUserFieldShortcuts[staleOwner]; + } + if (activeOwners.length > 0) { void showConfirmationModal(plugin.app, { title: translate("settings.keyboardShortcuts.duplicateTitle"), message: translate("settings.keyboardShortcuts.duplicateMessage", { shortcut: formatTaskListShortcut(shortcut, Platform.isMacOS), - actions: owners + actions: activeOwners .map((owner) => formatShortcutOwnerLabel( owner, @@ -377,7 +409,7 @@ export function renderKeyboardShortcutsTab( cancelText: translate("common.cancel"), }).then((replace) => { if (!replace) return; - for (const action of owners) { + for (const action of activeOwners) { if (action in plugin.settings.taskListShortcuts) { const key = action as keyof typeof plugin.settings.taskListShortcuts; plugin.settings.taskListShortcuts[key] = plugin.settings.taskListShortcuts[key].filter((value) => value !== shortcut); diff --git a/tests/unit/settings/keyboardShortcutsTab.test.ts b/tests/unit/settings/keyboardShortcutsTab.test.ts index 2cca53b1f..74cb7d618 100644 --- a/tests/unit/settings/keyboardShortcutsTab.test.ts +++ b/tests/unit/settings/keyboardShortcutsTab.test.ts @@ -1,6 +1,7 @@ import { Scope } from "obsidian"; import { formatShortcutOwnerLabel, + partitionShortcutOwners, pushKeyboardShortcutCaptureScope, } from "../../../src/settings/tabs/keyboardShortcutsTab"; import type { UserMappedField } from "../../../src/types/settings"; @@ -28,6 +29,27 @@ describe("keyboard shortcut owner labels", () => { expect(formatShortcutOwnerLabel("edit-task", [], translate)).toBe("Edit task"); expect(formatShortcutOwnerLabel("missing_field", [], translate)).toBe("missing_field"); }); + + it("separates deleted fields from conflicts that still require confirmation", () => { + const fields: UserMappedField[] = [ + { + id: "field_1234", + key: "storyPoints", + displayName: "Story Points", + type: "number", + }, + ]; + + expect( + partitionShortcutOwners( + ["edit-task", "field_1234", "field_removed"], + fields + ) + ).toEqual({ + activeOwners: ["edit-task", "field_1234"], + staleFieldOwners: ["field_removed"], + }); + }); }); describe("keyboard shortcut capture scope", () => { From 0a430c44ee60a061e26dffd310ce587e834a3362 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Tue, 4 Aug 2026 11:32:51 -0600 Subject: [PATCH 45/55] Task list handles recurring task completion by setting status, new hotkey to mark complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting a completed status for a recurring task now records an occurrence completion instead of writing status: done. Applies to Task List keyboard status editing and the task context-menu status submenu. Recurring completion repairs an already-completed parent back to the configured default status. Completion results now return the resolved recurrence date and final state. Toasts report the actual completed occurrence, e.g. “completed for Aug 2,” rather than the projected Aug 4 date. Added configurable Mark complete Task List action. Default shortcut: e (like todoist). Edit task context menu moved to shift+e Added regression tests for shifted recurrence dates, parent-status repair, status routing, and shortcut resolution. --- src/bases/TaskListView.ts | 44 ++++++++++++++++++- src/bases/taskListKeyboardActions.ts | 4 +- src/components/TaskContextMenu.ts | 11 ++++- src/i18n/resources/en.ts | 1 + src/main.ts | 14 +++--- src/services/TaskService.ts | 32 +++++++++++++- .../TaskListView.keyboardActions.test.ts | 26 +++++++++++ .../bases/taskListKeyboardActions.test.ts | 3 +- ...group-title-multiple-project-links.test.ts | 15 +++++++ .../services/task-service-completion.test.ts | 31 ++++++++++++- .../services/taskRecurringPlanning.test.ts | 16 +++++++ 11 files changed, 183 insertions(+), 14 deletions(-) diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index 52de4ea14..800e62a82 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -2694,6 +2694,7 @@ export class TaskListView extends BasesViewBase { "edit-due", "edit-scheduled", "edit-priority", + "mark-complete", "edit-status", "edit-recurrence", "add-tags", @@ -2792,6 +2793,9 @@ export class TaskListView extends BasesViewBase { case "edit-priority": await this.showTaskActionPriorityMenu(); return; + case "mark-complete": + await this.markTaskActionTargetsComplete(); + return; case "edit-status": await this.showTaskActionStatusMenu(); return; @@ -2963,6 +2967,44 @@ export class TaskListView extends BasesViewBase { } } + /** + * Completes the focused/selected visible tasks through their semantic completion paths. + * Recurring parents record the scheduled instance; ordinary tasks receive a completed status. + */ + private async markTaskActionTargetsComplete(): Promise { + const tasks = await this.getTaskActionTargets(); + for (const task of tasks) { + if (task.recurrence) { + await this.plugin.toggleRecurringTaskComplete(task, this.getTaskActionDate(task)); + continue; + } + + const completedStatus = this.plugin.statusManager.getCompletedStatuses()[0] || "done"; + if (!this.plugin.statusManager.isCompletedStatus(task.status)) { + await this.plugin.updateTaskProperty(task, "status", completedStatus); + } + } + } + + /** + * Applies a status selected from the Task List while preserving recurring-instance semantics. + * Selecting a completed status completes the current occurrence instead of completing its parent. + */ + private async updateTaskActionTargetStatuses( + tasks: readonly TaskInfo[], + status: string + ): Promise { + for (const task of tasks) { + if (task.recurrence && this.plugin.statusManager.isCompletedStatus(status)) { + // A recurring parent's completed state lives in complete_instances; writing + // status directly would make the whole series appear terminal. + await this.plugin.toggleRecurringTaskComplete(task, this.getTaskActionDate(task)); + } else { + await this.plugin.updateTaskProperty(task, "status", status); + } + } + } + private async openTaskActionTargets(): Promise { const app = this.app || this.plugin.app; for (const task of await this.getTaskActionTargets()) { @@ -3016,7 +3058,7 @@ export class TaskListView extends BasesViewBase { new StatusContextMenu({ currentValue: tasks[0].status, - onSelect: (value) => void this.updateTaskActionTargets(tasks, "status", value), + onSelect: (value) => void this.updateTaskActionTargetStatuses(tasks, value), plugin: this.plugin, }).showAtElement(anchor); } diff --git a/src/bases/taskListKeyboardActions.ts b/src/bases/taskListKeyboardActions.ts index 109625fd9..2733f05e6 100644 --- a/src/bases/taskListKeyboardActions.ts +++ b/src/bases/taskListKeyboardActions.ts @@ -24,6 +24,7 @@ export const TASK_LIST_KEYBOARD_ACTIONS = [ "edit-due", "edit-scheduled", "edit-priority", + "mark-complete", "edit-status", "edit-recurrence", "add-tags", @@ -57,7 +58,8 @@ export const DEFAULT_TASK_LIST_SHORTCUTS: TaskListShortcutMap = { "create-task": ["c"], "focus-search": ["slash"], "edit-task": ["enter"], - "open-context-menu": ["e"], + "mark-complete": ["e"], + "open-context-menu": ["shift+e"], "open-task-notes": ["shift+enter"], "edit-due": ["d"], "edit-scheduled": ["shift+s"], diff --git a/src/components/TaskContextMenu.ts b/src/components/TaskContextMenu.ts index 7760f07b3..fb4ad9f6a 100644 --- a/src/components/TaskContextMenu.ts +++ b/src/components/TaskContextMenu.ts @@ -1684,7 +1684,16 @@ export class TaskContextMenu { item.onClick(async () => { try { - await plugin.updateTaskProperty(task, "status", option.value); + if ( + task.recurrence && + plugin.statusManager.isCompletedStatus(option.value) + ) { + // Completed statuses on a recurring parent represent completion of + // the active occurrence, not termination of the whole series. + await plugin.toggleRecurringTaskComplete(task, this.options.targetDate); + } else { + await plugin.updateTaskProperty(task, "status", option.value); + } this.options.onUpdate?.(); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 69f83dda2..93044f490 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -551,6 +551,7 @@ export const en: TranslationTree = { "edit-due": "Edit due date", "edit-scheduled": "Edit scheduled date", "edit-priority": "Edit priority", + "mark-complete": "Mark complete", "edit-status": "Edit status", "edit-recurrence": "Edit recurrence", "add-tags": "Add tags", diff --git a/src/main.ts b/src/main.ts index 7f2de4e27..71c4f339b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1152,19 +1152,17 @@ export default class TaskNotesPlugin extends Plugin { async toggleRecurringTaskComplete(task: TaskInfo, date?: Date): Promise { try { const targetDate = await this.taskService.resolveRecurringTaskActionDate(task, date); - const updatedTask = await this.taskService.toggleRecurringTaskComplete( + const result = await this.taskService.toggleRecurringTaskCompleteWithResult( task, targetDate ); + const action = result.isCompleted ? "completed" : "marked incomplete"; - const dateStr = formatDateForStorage(targetDate); - const wasCompleted = updatedTask.complete_instances?.includes(dateStr); - const action = wasCompleted ? "completed" : "marked incomplete"; - - // Format date for display: convert UTC-anchored date back to local display - const displayDate = parseDateToLocal(dateStr); + // The service resolves shifted schedules back to their owning recurrence; + // report that authoritative date rather than the view's projection date. + const displayDate = parseDateToLocal(result.dateStr); new Notice(`Recurring task ${action} for ${format(displayDate, "MMM d")}`); - return updatedTask; + return result.task; } catch (error) { tasknotesLogger.error("Failed to toggle recurring task completion:", { category: "persistence", diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index 899390967..c7260da90 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -78,6 +78,13 @@ import { applyGoogleCalendarRecurringExceptionCleanup, applyGoogleCalendarRecurringExceptionForScheduledChange, } from "./task-service/googleCalendarRecurringExceptions"; + +/** Describes the authoritative occurrence affected by a recurring completion toggle. */ +export interface RecurringTaskCompletionResult { + task: TaskInfo; + dateStr: string; + isCompleted: boolean; +} import { buildBlockedByTaskUpdate, buildBlockingRelationshipPathChanges, @@ -1755,6 +1762,17 @@ export class TaskService { } async toggleRecurringTaskComplete(task: TaskInfo, date?: Date): Promise { + return (await this.toggleRecurringTaskCompleteWithResult(task, date)).task; + } + + /** + * Toggles one recurring occurrence and returns its normalized date and resulting state. + * The result date may differ from a view's target date when a shifted schedule maps back to its RRULE. + */ + async toggleRecurringTaskCompleteWithResult( + task: TaskInfo, + date?: Date + ): Promise { const file = this.plugin.app.vault.getAbstractFileByPath(task.path); if (!(file instanceof TFile)) { throw new Error(`Cannot find task file: ${task.path}`); @@ -1774,6 +1792,12 @@ export class TaskService { maintainDueDateOffsetInRecurring: this.plugin.settings.maintainDueDateOffsetInRecurring, }); const { updatedTask, dateStr, newComplete, targetDate } = recurringPlan; + const resetCompletedParentStatus = + this.plugin.statusManager?.isCompletedStatus?.(freshTask.status) ?? + ["done", "completed"].includes(freshTask.status); + if (resetCompletedParentStatus) { + updatedTask.status = this.plugin.settings.defaultTaskStatus; + } // Step 2: Persist to file await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { @@ -1783,6 +1807,7 @@ export class TaskService { const scheduledField = this.plugin.fieldMapper.toUserField("scheduled"); const dueField = this.plugin.fieldMapper.toUserField("due"); const recurrenceField = this.plugin.fieldMapper.toUserField("recurrence"); + const statusField = this.plugin.fieldMapper.toUserField("status"); const googleCalendarExceptionOriginalScheduledField = this.plugin.fieldMapper.toUserField("googleCalendarExceptionOriginalScheduled"); const googleCalendarMovedOriginalDatesField = this.plugin.fieldMapper.toUserField( @@ -1801,6 +1826,11 @@ export class TaskService { googleCalendarMovedOriginalDatesField, plan: recurringPlan, }); + // Recurring completion belongs to the occurrence history, so repair any + // completed parent status left by a generic property edit. + if (resetCompletedParentStatus) { + frontmatter[statusField] = this.plugin.settings.defaultTaskStatus; + } }); // Step 2b: Reset checkboxes in task body when completing (if setting enabled) @@ -1888,7 +1918,7 @@ export class TaskService { } // Step 7: Return authoritative data - return updatedTask; + return { task: updatedTask, dateStr, isCompleted: newComplete }; } /** diff --git a/tests/unit/bases/TaskListView.keyboardActions.test.ts b/tests/unit/bases/TaskListView.keyboardActions.test.ts index 5cc7b45a9..f58deeecf 100644 --- a/tests/unit/bases/TaskListView.keyboardActions.test.ts +++ b/tests/unit/bases/TaskListView.keyboardActions.test.ts @@ -44,6 +44,7 @@ describe("TaskListView keyboard actions", () => { ["edit-due", "showTaskActionDateMenu", "due"], ["edit-scheduled", "showTaskActionDateMenu", "scheduled"], ["edit-priority", "showTaskActionPriorityMenu", null], + ["mark-complete", "markTaskActionTargetsComplete", null], ["edit-status", "showTaskActionStatusMenu", null], ["edit-recurrence", "showTaskActionRecurrenceMenu", null], ["add-tags", "addTagsToTaskActionTargets", null], @@ -524,6 +525,31 @@ describe("TaskListView keyboard actions", () => { expect(updateTaskProperty).toHaveBeenNthCalledWith(2, tasks[1], "priority", "high"); }); + it("routes a completed status through recurring-instance completion", async () => { + const recurring = { ...task("recurring.md"), recurrence: "FREQ=WEEKLY" }; + const ordinary = task("ordinary.md"); + const actionDate = new Date("2026-08-02T00:00:00.000Z"); + const toggleRecurringTaskComplete = jest.fn(async () => recurring); + const updateTaskProperty = jest.fn(async () => ordinary); + const view = { + plugin: { + statusManager: { isCompletedStatus: (status: string) => status === "done" }, + toggleRecurringTaskComplete, + updateTaskProperty, + }, + getTaskActionDate: jest.fn(() => actionDate), + }; + + await (TaskListView.prototype as any).updateTaskActionTargetStatuses.call( + view, + [recurring, ordinary], + "done" + ); + + expect(toggleRecurringTaskComplete).toHaveBeenCalledWith(recurring, actionDate); + expect(updateTaskProperty).toHaveBeenCalledWith(ordinary, "status", "done"); + }); + it("selects only tasks visible in the current filtered view", () => { const selectAll = jest.fn(); const enterSelectionMode = jest.fn(); diff --git a/tests/unit/bases/taskListKeyboardActions.test.ts b/tests/unit/bases/taskListKeyboardActions.test.ts index 757c6dd9a..97efd75e7 100644 --- a/tests/unit/bases/taskListKeyboardActions.test.ts +++ b/tests/unit/bases/taskListKeyboardActions.test.ts @@ -52,6 +52,8 @@ describe("resolveDefaultTaskListKeyboardAction", () => { ["s", { shiftKey: true }, "edit-scheduled"], ["p", {}, "edit-priority"], ["!", { shiftKey: true }, "edit-priority"], + ["d", { ctrlKey: true }, "mark-complete"], + ["d", { metaKey: true }, "mark-complete"], ["s", {}, "edit-status"], ["*", { shiftKey: true }, "edit-status"], ["r", {}, "edit-recurrence"], @@ -66,7 +68,6 @@ describe("resolveDefaultTaskListKeyboardAction", () => { it.each([ key("d", { altKey: true }), - key("d", { ctrlKey: true }), key("Process"), key("d", { isComposing: true }), key("q"), diff --git a/tests/unit/issues/issue-1566-group-title-multiple-project-links.test.ts b/tests/unit/issues/issue-1566-group-title-multiple-project-links.test.ts index 899b30e3a..b5eae2b74 100644 --- a/tests/unit/issues/issue-1566-group-title-multiple-project-links.test.ts +++ b/tests/unit/issues/issue-1566-group-title-multiple-project-links.test.ts @@ -52,6 +52,21 @@ describe("Issue #1566: grouped project links", () => { expect(container.textContent).toBe("Project A, Project B"); }); + it("renders comma-delimited Bases file paths as individual links", () => { + const container = document.createElement("div"); + + renderGroupTitle(container, "Project A, Project B", makeServices()); + + const links = Array.from(container.querySelectorAll("a.task-group-link")); + expect(links).toHaveLength(2); + expect(links.map((link) => link.textContent)).toEqual(["Project A", "Project B"]); + expect(links.map((link) => link.getAttribute("data-href"))).toEqual([ + "Project A", + "Project B", + ]); + expect(container.textContent).toBe("Project A, Project B"); + }); + it("keeps mixed link and plain text group titles as plain text", () => { const container = document.createElement("div"); diff --git a/tests/unit/services/task-service-completion.test.ts b/tests/unit/services/task-service-completion.test.ts index ee021c5db..b50de5dc6 100644 --- a/tests/unit/services/task-service-completion.test.ts +++ b/tests/unit/services/task-service-completion.test.ts @@ -84,6 +84,35 @@ describe('TaskService Completion (Issue #160)', () => { }); describe('toggleRecurringTaskComplete', () => { + it('returns the normalized occurrence result and repairs a completed parent status', async () => { + fridayRecurringTask = TaskFactory.createTask({ + ...fridayRecurringTask, + status: 'done', + scheduled: '2024-01-13', + recurrence: 'DTSTART:20240112;FREQ=WEEKLY;BYDAY=FR', + }); + taskService['plugin'].cacheManager.getTaskInfo.mockResolvedValue(fridayRecurringTask); + let persistedFrontmatter: Record = {}; + taskService['plugin'].app.fileManager.processFrontMatter.mockImplementation( + (_file: TFile, update: (frontmatter: Record) => void) => { + persistedFrontmatter = { ...fridayRecurringTask }; + update(persistedFrontmatter); + return Promise.resolve(); + } + ); + + const result = await taskService.toggleRecurringTaskCompleteWithResult( + fridayRecurringTask, + new Date('2024-01-13T12:00:00.000Z') + ); + + expect(result.dateStr).toBe('2024-01-12'); + expect(result.isCompleted).toBe(true); + expect(result.task.status).toBe('open'); + expect(persistedFrontmatter.status).toBe('open'); + expect(persistedFrontmatter.completeInstances).toEqual(['2024-01-12']); + }); + it('should add completion for the correct date (Friday)', async () => { const targetDate = new Date('2024-01-12T12:00:00.000Z'); // Friday await taskService.toggleRecurringTaskComplete(fridayRecurringTask, targetDate); @@ -300,4 +329,4 @@ describe('TaskService Completion (Issue #160)', () => { expect(taskService['plugin'].app.fileManager.processFrontMatter).toHaveBeenCalled(); }); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/services/taskRecurringPlanning.test.ts b/tests/unit/services/taskRecurringPlanning.test.ts index bb70cb41d..0218f93d0 100644 --- a/tests/unit/services/taskRecurringPlanning.test.ts +++ b/tests/unit/services/taskRecurringPlanning.test.ts @@ -84,6 +84,22 @@ describe("taskRecurringPlanning", () => { expect(plan.updatedTask.skipped_instances).toEqual([]); }); + it("reports the owning recurrence date when a shifted target is completed", () => { + const plan = buildRecurringTaskCompletePlan({ + freshTask: createRecurringTask({ + recurrence: "DTSTART:20260802;FREQ=WEEKLY;BYDAY=SU", + scheduled: "2026-08-04", + }), + targetDate: new Date("2026-08-04T12:00:00.000Z"), + currentTimestamp: "2026-08-04T12:00:00.000Z", + maintainDueDateOffsetInRecurring: true, + }); + + expect(plan.dateStr).toBe("2026-08-02"); + expect(plan.newComplete).toBe(true); + expect(plan.updatedTask.complete_instances).toEqual(["2026-08-02"]); + }); + it("updates DTSTART for completion-anchored recurrence plans", () => { const plan = buildRecurringTaskCompletePlan({ freshTask: createRecurringTask({ From 1780f224afaa0e7b7a7839279e9c9f2387dd47af Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Tue, 4 Aug 2026 11:33:36 -0600 Subject: [PATCH 46/55] Group headings with multiple projects render links for each project --- src/bases/groupTitleRenderer.ts | 45 +++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/src/bases/groupTitleRenderer.ts b/src/bases/groupTitleRenderer.ts index aabedfc44..3f617a22b 100644 --- a/src/bases/groupTitleRenderer.ts +++ b/src/bases/groupTitleRenderer.ts @@ -97,6 +97,27 @@ function parseLinkSegment(segment: string): LinkTitleSegment | null { return parseWikiLinkSegment(segment) || parseMarkdownLinkSegment(segment); } +/** + * Converts a bare Bases file-path segment into a link only when Obsidian can resolve it. + * This prevents ordinary comma-delimited text from being mistaken for a project list. + */ +function parseResolvedPathSegment( + segment: string, + linkServices: LinkServices +): LinkTitleSegment | null { + const filePath = segment.trim(); + if (!filePath) return null; + + const sourcePath = linkServices.sourcePath ?? ""; + const normalizedPath = parseLinkToPath(filePath); + const file = + linkServices.metadataCache.getFirstLinkpathDest(normalizedPath, sourcePath) || + linkServices.metadataCache.getFirstLinkpathDest(normalizedPath, ""); + if (!(file instanceof TFile)) return null; + + return { filePath: normalizedPath, displayText: normalizedPath }; +} + function parseLinkAt(title: string, startIndex: number): { segment: LinkTitleSegment; endIndex: number } | null { const remaining = title.slice(startIndex); const wikiMatch = remaining.match(/^\[\[([^\]]+)\]\]/); @@ -122,7 +143,27 @@ function parseLinkAt(title: string, startIndex: number): { segment: LinkTitleSeg return null; } -function parseDelimitedLinkTitle(title: string): GroupTitlePart[] | null { +function parseDelimitedLinkTitle( + title: string, + linkServices: LinkServices +): GroupTitlePart[] | null { + const bareSegments = title.split(","); + const containsExplicitLinkSyntax = bareSegments.some((segment) => + parseLinkSegment(segment.trim()) + ); + if (bareSegments.length > 1 && !containsExplicitLinkSyntax) { + const resolvedPaths = bareSegments.map((segment) => + parseResolvedPathSegment(segment, linkServices) + ); + if (resolvedPaths.every((segment): segment is LinkTitleSegment => segment !== null)) { + // Bases serializes list-valued FileValue groups as comma-separated paths; + // reconstruct links here after confirming that every item is a real note. + return resolvedPaths.flatMap((segment, index) => + index === 0 ? [segment] : [", ", segment] + ); + } + } + const parts: GroupTitlePart[] = []; let index = 0; let linkCount = 0; @@ -186,7 +227,7 @@ export function renderGroupTitle( return; } - const delimitedLinkTitle = parseDelimitedLinkTitle(title); + const delimitedLinkTitle = parseDelimitedLinkTitle(title, linkServices); if (delimitedLinkTitle) { for (const part of delimitedLinkTitle) { if (typeof part === "string") { From 7fa1fdbfde0d4ffc12e278e5a777384dbc131a59 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Wed, 5 Aug 2026 15:05:09 -0600 Subject: [PATCH 47/55] Extract shared hotkey support for all bases task views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Task List keyboard/focus/selection system is now shared across all four Bases task views (kanban, calendar, task list, agenda). What changed: New shared modules (src/bases/): basesTaskCardActions.ts — the view-agnostic action executor, extracted from TaskListView.executeTaskListAction (navigate, select, edit due/priority/status/recurrence, mark complete, archive, tags/context/project, delete, etc.) BasesTaskCardKeyboardController.ts — owns roving card focus, keyboard dispatch, and Obsidian Scope activation for any card-based view; replaces the duplicate focus-controller wiring that had crept into TaskListView alongside BasesViewBase's shared hover tracking Per-view wiring: Task List — refactored onto the shared controller with no intended behavior change (verified via the existing test suite, adapted to the new module boundaries) Kanban — full parity: navigation, multi-select, and all edit actions on real .task-card elements; keyboard nav shares the same virtualized-column limitation mouse selection already had Agenda / Calendar list mode — full parity, same as Kanban, since it renders real task cards Calendar grid modes (Month/Week/Day/Year/Custom) — no roving-focus model since these render FullCalendar's own elements, so hotkeys act on whichever task is currently hovered; navigation/selection/search/create are excluded there --- docs/views/agenda-view.md | 4 + docs/views/calendar-views.md | 9 + docs/views/kanban-view.md | 6 + docs/views/task-list.md | 12 + src/bases/BasesTaskCardKeyboardController.ts | 346 ++++++++ src/bases/BasesViewBase.ts | 67 +- src/bases/CalendarView.ts | 147 +++- src/bases/KanbanView.ts | 48 +- src/bases/TaskListFocusController.ts | 4 +- src/bases/TaskListView.ts | 823 +----------------- src/bases/basesTaskCardActions.ts | 459 ++++++++++ src/bases/embeddedBasesKeyboard.ts | 26 + src/i18n/resources/en.ts | 8 +- .../plugins/tasknotes-views/manifest.json | 11 + .../BasesTaskCardKeyboardController.test.ts | 527 +++++++++++ .../CalendarView.keyboardActions.test.ts | 163 ++++ .../bases/KanbanView.keyboardActions.test.ts | 57 ++ .../bases/TaskListFocusController.test.ts | 23 + .../TaskListView.keyboardActions.test.ts | 664 +------------- .../TaskListView.keyboardSelection.test.ts | 118 +-- tests/unit/bases/basesTaskCardActions.test.ts | 248 ++++++ .../unit/bases/embeddedBasesKeyboard.test.ts | 57 ++ ...ases-recurring-completion-timezone.test.ts | 19 +- 23 files changed, 2344 insertions(+), 1502 deletions(-) create mode 100644 src/bases/BasesTaskCardKeyboardController.ts create mode 100644 src/bases/basesTaskCardActions.ts create mode 100644 src/bases/embeddedBasesKeyboard.ts create mode 100644 tasknotes-e2e-vault/.obsidian/plugins/tasknotes-views/manifest.json create mode 100644 tests/unit/bases/BasesTaskCardKeyboardController.test.ts create mode 100644 tests/unit/bases/CalendarView.keyboardActions.test.ts create mode 100644 tests/unit/bases/KanbanView.keyboardActions.test.ts create mode 100644 tests/unit/bases/basesTaskCardActions.test.ts create mode 100644 tests/unit/bases/embeddedBasesKeyboard.test.ts diff --git a/docs/views/agenda-view.md b/docs/views/agenda-view.md index 19adff39a..7a44f4eab 100644 --- a/docs/views/agenda-view.md +++ b/docs/views/agenda-view.md @@ -46,6 +46,10 @@ Edit the `.base` file to tailor the agenda: Because the view runs inside Bases, any YAML changes are applied immediately after saving the file. +## Keyboard Shortcuts + +Because the Agenda view renders real task cards (the same as Task List), it supports the full set of task-card keyboard shortcuts once a card has keyboard focus or is hovered: move focus between entries, select one or more tasks, edit due/scheduled dates, priority, status, and recurrence, mark complete, archive, add tags/context/project, copy titles, and delete. See **Settings → TaskNotes → Keyboard shortcuts** to view or customize the bindings. + ## Usage Tips - Use the calendar toolbar arrows (Previous/Next) to move the agenda window forward or backward, or simply scroll the list to review upcoming entries diff --git a/docs/views/calendar-views.md b/docs/views/calendar-views.md index e65593e4f..981d0181b 100644 --- a/docs/views/calendar-views.md +++ b/docs/views/calendar-views.md @@ -151,6 +151,15 @@ This option is useful for project planning and visualizing how long tasks are ex These display options are preserved when you save a view, allowing you to create specialized calendar views that show only specific types of events and maintain those preferences across sessions. +### Keyboard Shortcuts + +Keyboard shortcut support depends on the active view mode: + +- **List mode**: Renders the same task cards as Task List, so it supports the full shortcut set once a card has keyboard focus or is hovered — navigation, multi-select, editing dates/priority/status/recurrence, marking complete, archiving, and more. +- **Month, Week, Day, Year, and Custom Days modes**: These render FullCalendar's own event elements rather than task cards, so there is no keyboard-focus or multi-select model to navigate. Shortcuts instead act on whichever task the mouse is currently hovering — edit dates/priority/status/recurrence, mark complete, archive, add tags/context/project, open the context menu, copy the title, or delete. Navigation, selection, search, and create-task shortcuts don't apply in these modes. + +Shortcuts are configured once for all views in **Settings → TaskNotes → Keyboard shortcuts**. + ### OAuth Calendar Integration The Calendar View supports bidirectional synchronization with external calendar services through OAuth authentication: diff --git a/docs/views/kanban-view.md b/docs/views/kanban-view.md index 9c5492142..16f944e01 100644 --- a/docs/views/kanban-view.md +++ b/docs/views/kanban-view.md @@ -88,6 +88,12 @@ For existing `.base` files, add this in YAML manually first; after it is in `ord Click a card to open the task file for editing. Right-click to access the context menu for task actions. Drag cards between columns or swimlane cells to update the task's properties. +## Keyboard Shortcuts + +Kanban cards support the same keyboard shortcuts as Task List once a card has keyboard focus or is hovered: move focus between cards, select one or more, edit dates/priority/status/recurrence, mark complete, archive, add tags/context/project, copy titles, and delete. See **Settings → TaskNotes → Keyboard shortcuts** to view or customize the bindings. + +Keyboard navigation reaches only cards currently rendered by a column's virtual scroller, the same limitation mouse-based selection already has in large virtualized columns. + ## Column Operations ### Reordering Columns diff --git a/docs/views/task-list.md b/docs/views/task-list.md index 321ad1ec7..228306199 100644 --- a/docs/views/task-list.md +++ b/docs/views/task-list.md @@ -275,6 +275,18 @@ The Task List View provides interaction with tasks through clicking and context Context menu availability depends on your TaskNotes settings and task properties. +## Keyboard Shortcuts + +Once a task card has keyboard focus (click a card, tab to it, or hover it), the Task List View supports keyboard shortcuts for the same actions available by mouse, plus navigation and multi-select: + +- Move focus between cards and jump to the first/last card +- Toggle selection on the focused card, select all visible cards, or clear focus and selection +- Edit due/scheduled dates, priority, status, and recurrence +- Mark complete, toggle archive, add tags/context/project +- Copy task titles, open the task note, open the context menu, or delete + +Shortcuts are configurable per action, including per-user-field bindings, in **Settings → TaskNotes → Keyboard shortcuts**. Kanban and Agenda share this same shortcut set on their own task cards, and Calendar's month/week/day/year views support the single-task subset of it against whichever task is currently hovered. + ## Virtual Scrolling The Task List View automatically enables virtual scrolling when displaying 100 or more items (tasks + group headers). Virtual scrolling provides: diff --git a/src/bases/BasesTaskCardKeyboardController.ts b/src/bases/BasesTaskCardKeyboardController.ts new file mode 100644 index 000000000..3d71bce31 --- /dev/null +++ b/src/bases/BasesTaskCardKeyboardController.ts @@ -0,0 +1,346 @@ +import { Component, Scope } from "obsidian"; +import type TaskNotesPlugin from "../main"; +import { TaskListFocusController } from "./TaskListFocusController"; +import { TaskListInputOwnershipController } from "./TaskListInputOwnershipController"; +import { resolveTaskListTargetPaths } from "./taskListTargetResolver"; +import { + resolveTaskListKeyboardAction, + taskListShortcutToScopeBinding, + TASK_LIST_KEYBOARD_ACTIONS, + type TaskListAction, +} from "./taskListKeyboardActions"; +import { executeBasesTaskCardAction, type BasesTaskCardActionContext } from "./basesTaskCardActions"; + +/** + * Everything a view must supply to run task-card actions. The controller fills + * in the focus/overlay-bound fields (`getTargetPaths`, `restoreFocus`, `getAnchor`, + * `onOverlayClosed`) itself from its own focus and input-ownership state. + */ +export type BasesTaskCardActionViewContext = Omit< + BasesTaskCardActionContext, + "getTargetPaths" | "restoreFocus" | "getAnchor" | "onOverlayClosed" +> & { + /** Anchor to use for context menus when no card is currently focused. */ + fallbackAnchor?: HTMLElement | null; +}; + +export interface BasesTaskCardKeyboardOptions { + /** Whether hover may currently claim task-card focus (embedded-note guard). */ + canClaimHover?: () => boolean; + /** Whether keyboard focus should land on a card as soon as the view first renders. */ + autoFocusInitial?: boolean; + /** + * The sub-element that actually renders task cards (e.g. Task List's item + * container), as opposed to chrome like a toolbar or search box that also + * lives under `root`. A root-level keydown whose target falls outside this + * element is allowed to fall back to the remembered focused card; one whose + * target falls inside it defers to whatever `TaskListFocusController` itself + * resolves from the target. Defaults to `root` when omitted. + */ + cardAreaElement?: HTMLElement; + /** + * Whether `action` is currently dispatchable by this view. Unsupported actions + * are left untouched so the key can still reach Obsidian's own commands. Called + * live on every keydown, so a view whose supported set changes at runtime (for + * example Calendar switching between list and grid modes) reacts immediately. + */ + isActionSupported(action: TaskListAction): boolean; + /** Builds the view-supplied half of the action context; called once per dispatch. */ + buildViewContext(): BasesTaskCardActionViewContext; +} + +type LeafLike = { view?: { containerEl?: HTMLElement } } | null; + +const NAVIGATION_DIRECTIONS = { + "navigate-next": "next", + "navigate-previous": "previous", + "jump-first": "first", + "jump-last": "last", +} as const; + +const OVERLAY_ACTIONS: ReadonlySet = new Set([ + "edit-task", + "open-context-menu", + "edit-due", + "edit-scheduled", + "edit-priority", + "mark-complete", + "edit-status", + "edit-recurrence", + "add-tags", + "add-context", + "add-project", + "delete-tasks", +]); + +function opensOverlay(action: TaskListAction): boolean { + return OVERLAY_ACTIONS.has(action) || action.startsWith("edit-user-field:"); +} + +/** + * Owns roving task-card focus, keyboard-shortcut dispatch, and Obsidian Scope + * activation for one Bases task-card view (Task List, Kanban, or Calendar's + * Agenda/list mode). Generalized out of `TaskListView`'s original private + * focus/input-ownership/keydown wiring so every card-based Bases view shares one + * implementation instead of each reimplementing it. + */ +export class BasesTaskCardKeyboardController { + readonly focusController: TaskListFocusController; + private readonly inputOwnershipController: TaskListInputOwnershipController; + private readonly cardAreaElement: HTMLElement; + private leafActive = false; + private shortcutScope: Scope | null = null; + + constructor( + private readonly component: Component, + private readonly root: HTMLElement, + private readonly containerEl: HTMLElement, + private readonly plugin: TaskNotesPlugin, + private readonly options: BasesTaskCardKeyboardOptions + ) { + this.cardAreaElement = options.cardAreaElement ?? root; + this.focusController = new TaskListFocusController( + root, + options.autoFocusInitial ?? false, + options.canClaimHover ?? (() => true) + ); + this.inputOwnershipController = new TaskListInputOwnershipController(root, this.focusController); + this.registerListeners(); + } + + prepareForRender(): void { + this.focusController.prepareForRender(); + } + + restoreAfterRender(): void { + this.focusController.restoreAfterRender(); + } + + /** Shared gate for the Shift+Arrow range-select shortcuts BasesViewBase wires up. */ + canHandleSelectionKeyDown(event: KeyboardEvent): boolean { + return ( + this.inputOwnershipController.canHandleListKeyDown(event) && + event.shiftKey && + ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key) + ); + } + + + /** + * Exposed so a view's own supplementary keydown handling — for example + * Calendar's grid-mode hover-target hotkeys, which have no card to + * anchor a roving focus model to — can share this controller's editable + * target / open-overlay guard instead of building a second one. + */ + canHandleKeyDown(event: KeyboardEvent, allowDocumentBody = false): boolean { + return this.inputOwnershipController.canHandleListKeyDown(event, allowDocumentBody); + } + + destroy(): void { + this.deactivateShortcutScope(); + this.inputOwnershipController.destroy(); + this.focusController.clear(); + } + + private registerListeners(): void { + const { component, root, plugin } = this; + component.registerDomEvent(root, "focusin", (event: FocusEvent) => { + this.focusController.handleFocusIn(event); + }); + component.registerDomEvent(root, "pointerdown", (event: PointerEvent) => { + this.focusController.handlePointerDown(event); + }); + component.registerDomEvent(root, "mousemove", (event: MouseEvent) => { + // Context-menu edits operate on the focused task/selection. While an + // Obsidian menu is open, hovering cards underneath it must not move the + // cursor and change the edit target. + if (root.ownerDocument.querySelector(".menu")) return; + this.focusController.handleMouseMove(event); + }); + // Resolve view-local shortcuts during capture so Obsidian commands such as + // Ctrl+B/Ctrl+D cannot stop propagation before this view sees a configured chord. + component.registerDomEvent( + root, + "keydown", + (event: KeyboardEvent) => this.handleRootKeyDown(event), + true + ); + + const doc = root.ownerDocument; + component.registerDomEvent(doc, "focusin", (event: FocusEvent) => { + this.inputOwnershipController.handleDocumentFocusIn(event); + this.syncShortcutScopeForFocusTarget(event.target); + }); + component.registerDomEvent(doc, "pointerdown", (event: PointerEvent) => { + this.inputOwnershipController.handleOverlayInteraction(event); + }); + component.registerDomEvent( + doc, + "keydown", + (event: KeyboardEvent) => { + if (event.key === "Escape" || event.key === "Backspace") { + this.inputOwnershipController.handleOverlayInteraction(event); + } + }, + true + ); + + component.registerEvent( + plugin.app.workspace.on("active-leaf-change", (leaf) => { + this.syncShortcutScopeForLeaf(leaf); + this.restoreFocusForActivatedLeaf(leaf); + }) + ); + component.registerDomEvent( + doc, + "click", + (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Element) || !target.closest(".workspace-tab-header")) return; + const win = doc.defaultView ?? window; + win.setTimeout(() => { + const leaf = plugin.app.workspace.getMostRecentLeaf(); + this.syncShortcutScopeForLeaf(leaf); + this.restoreFocusForActivatedLeaf(leaf); + }, 0); + }, + true + ); + + this.syncShortcutScopeForLeaf(plugin.app.workspace.getMostRecentLeaf()); + } + +private handleRootKeyDown(event: KeyboardEvent): void { + const startedInCardArea = this.cardAreaElement.contains(event.target as Node); + this.handleKeyDown(event, !startedInCardArea); + } + + private isThisLeaf(leaf: LeafLike): boolean { + return Boolean(leaf?.view?.containerEl?.contains(this.containerEl)); + } + + private restoreFocusForActivatedLeaf(leaf: LeafLike): void { + if (!this.isThisLeaf(leaf)) return; + + const win = this.containerEl.ownerDocument.defaultView ?? window; + win.setTimeout(() => { + if (this.root.isConnected) this.focusController.restoreFocusedElement(); + }, 0); + } + + private syncShortcutScopeForLeaf(leaf: LeafLike): void { + this.leafActive = this.isThisLeaf(leaf); + this.syncShortcutScopeForFocusTarget(this.containerEl.ownerDocument.activeElement); + } + + private syncShortcutScopeForFocusTarget(target: EventTarget | null): void { + if (this.leafActive && this.inputOwnershipController.canOwnKeyboardTarget(target, true)) { + this.activateShortcutScope(); + return; + } + this.deactivateShortcutScope(); + } + + private activateShortcutScope(): void { + if (this.shortcutScope) return; + + // A child Obsidian scope lets view-local configurable chords win over global + // editor commands while this view's leaf is active. + const scope = new Scope(this.plugin.app.scope); + const shortcuts = this.plugin.settings.taskListShortcuts; + const userFieldShortcuts = this.plugin.settings.taskListUserFieldShortcuts ?? {}; + const allShortcuts: string[] = [ + ...TASK_LIST_KEYBOARD_ACTIONS.flatMap((action) => shortcuts[action] ?? []), + ...Object.values(userFieldShortcuts).flat(), + ]; + for (const shortcut of allShortcuts) { + const binding = taskListShortcutToScopeBinding(shortcut); + if (!binding) continue; + scope.register(binding.modifiers, binding.key, (event) => { + if (!this.leafActive) return; + if (!this.handleKeyDown(event, true)) return; + return false; + }); + } + + this.shortcutScope = scope; + this.plugin.app.keymap.pushScope(scope); + } + + private deactivateShortcutScope(): void { + if (!this.shortcutScope) return; + this.plugin.app.keymap.popScope(this.shortcutScope); + this.shortcutScope = null; + } + + private handleKeyDown(event: KeyboardEvent, allowRememberedFocus = false): boolean { + if (!this.inputOwnershipController.canHandleListKeyDown(event, allowRememberedFocus)) { + return false; + } + return this.handleActionKeyDown(event, allowRememberedFocus); + } + + private handleActionKeyDown(event: KeyboardEvent, allowRememberedFocus: boolean): boolean { + const action = resolveTaskListKeyboardAction( + event, + this.plugin.settings.taskListShortcuts, + this.plugin.settings.taskListUserFieldShortcuts + ); + if (!action || !this.options.isActionSupported(action)) return false; + + const focusedPath = this.focusController.getFocusedPathForEvent( + event, + true, + allowRememberedFocus + ); + if (!focusedPath && action !== "clear-focus-and-selection" && action !== "select-all") { + return false; + } + + if (action in NAVIGATION_DIRECTIONS) { + return this.focusController.moveFocus( + event, + NAVIGATION_DIRECTIONS[action as keyof typeof NAVIGATION_DIRECTIONS] + ); + } + + event.preventDefault(); + event.stopPropagation(); + if (opensOverlay(action)) { + // User-field editors are overlays too; recording this before opening lets + // input ownership restore the remembered card when the modal closes. + this.inputOwnershipController.noteOverlayOpening(); + } + void executeBasesTaskCardAction(action, focusedPath ?? null, this.buildActionContext()); + return true; + } + + private buildActionContext(): BasesTaskCardActionContext { + const viewContext = this.options.buildViewContext(); + return { + ...viewContext, + getTargetPaths: () => + resolveTaskListTargetPaths( + viewContext.taskSelectionService, + this.focusController.getFocusedIdentity()?.path + ).filter((path) => viewContext.isPathVisible(path)), + restoreFocus: () => this.focusController.restoreFocusedElement(), + getAnchor: () => + this.focusController.getFocusedElement() ?? viewContext.fallbackAnchor ?? null, + onOverlayClosed: () => this.restoreAfterOverlayClose(), + }; + } + + /** Restores card focus after Obsidian completes its modal selection cleanup. */ + private restoreAfterOverlayClose(): void { + // Obsidian restores the modal's saved selection after onClose; defer card + // focus until that cleanup has finished so keyboard ownership is retained. + const win = this.containerEl.ownerDocument.defaultView ?? window; + win.setTimeout(() => { + this.focusController.restoreFocusedElement(); + this.inputOwnershipController.resumeAfterOverlayClose(); + this.syncShortcutScopeForFocusTarget(this.containerEl.ownerDocument.activeElement); + if (this.leafActive) this.activateShortcutScope(); + }, 0); + } +} diff --git a/src/bases/BasesViewBase.ts b/src/bases/BasesViewBase.ts index 9dc6f70b1..03d098314 100644 --- a/src/bases/BasesViewBase.ts +++ b/src/bases/BasesViewBase.ts @@ -68,6 +68,12 @@ import { import { filterTopLevelSubtasks } from "./topLevelSubtasks"; import type { BasesTaskUpdateSource } from "./basesUpdateEvents"; import { createTaskNotesLogger, type TaskNotesLogger } from "../utils/tasknotesLogger"; +import { + BasesTaskCardKeyboardController, + type BasesTaskCardActionViewContext, +} from "./BasesTaskCardKeyboardController"; +import { canHoverClaimBasesTaskFocus } from "./embeddedBasesKeyboard"; +import type { TaskListAction } from "./taskListKeyboardActions"; type BasesEphemeralState = { scrollTop?: unknown; @@ -96,6 +102,7 @@ export abstract class BasesViewBase extends Component { protected logger: TaskNotesLogger; protected containerEl: HTMLElement; protected rootElement: HTMLElement | null = null; + protected taskCardKeyboardController: BasesTaskCardKeyboardController | null = null; protected taskUpdateListener: EventRef[] | null = null; protected updateDebounceTimer: number | null = null; protected dataUpdateDebounceTimer: number | null = null; @@ -170,12 +177,68 @@ export abstract class BasesViewBase extends Component { */ onload(): void { this.setupContainer(); + this.setupTaskCardKeyboard(); this.setupTaskUpdateListener(); this.setupSelectionHandling(); this.updateRelevantPathsCache(); void this.render(); } + /** + * Installs roving task-card focus for every TaskNotes Bases presentation. + * This gives Agenda, Calendar, Kanban, and Task List the same explicit-focus + * and guarded-hover behavior without changing their view-specific rendering. + */ + private setupTaskCardKeyboard(): void { + if (!this.rootElement || this.taskCardKeyboardController) return; + + const root = this.rootElement; + const readConfig = () => this.getTaskCardActionsConfig(); + this.taskCardKeyboardController = new BasesTaskCardKeyboardController( + this, + root, + this.containerEl, + this.plugin, + { + // Hover is the feature fulcrum: it may move the task cursor only when + // the surrounding project-note editor does not currently own an edit cursor. + canClaimHover: () => canHoverClaimBasesTaskFocus(root), + autoFocusInitial: readConfig()?.autoFocusInitial ?? false, + cardAreaElement: readConfig()?.cardAreaElement, + isActionSupported: (action) => readConfig()?.isActionSupported(action) ?? false, + buildViewContext: () => { + const config = readConfig(); + if (!config) { + throw new Error( + "Task card action dispatched without an actions config" + ); + } + return config.buildViewContext(); + }, + } + ); + this.register(() => { + this.taskCardKeyboardController?.destroy(); + this.taskCardKeyboardController = null; + }); + } + + /** + * Opt-in hook for views that dispatch Task List-style keyboard actions + * (Task List, Kanban, Calendar's Agenda/list mode) through the shared + * `BasesTaskCardKeyboardController`. Views that only need hover/focus + * tracking (or that implement their own separate dispatch, like Calendar's + * grid modes) leave this at its default of `null`. + */ + protected getTaskCardActionsConfig(): { + isActionSupported(action: TaskListAction): boolean; + buildViewContext(): BasesTaskCardActionViewContext; + autoFocusInitial?: boolean; + cardAreaElement?: HTMLElement; + } | null { + return null; + } + /** * BasesView lifecycle: Called when Bases data changes. * Required abstract method implementation. @@ -847,8 +910,8 @@ export abstract class BasesViewBase extends Component { * Lets specialized Bases views defer selection keys while another UI surface * (for example a menu or modal) owns keyboard input. */ - protected canHandleSelectionKeyDown(_event: KeyboardEvent): boolean { - return true; + protected canHandleSelectionKeyDown(event: KeyboardEvent): boolean { + return this.taskCardKeyboardController?.canHandleSelectionKeyDown(event) ?? true; } /** diff --git a/src/bases/CalendarView.ts b/src/bases/CalendarView.ts index a51fc0861..7df6c76f6 100644 --- a/src/bases/CalendarView.ts +++ b/src/bases/CalendarView.ts @@ -57,7 +57,16 @@ import type { EventRef } from "obsidian"; import { format } from "date-fns"; import { TaskContextMenu } from "../components/TaskContextMenu"; import { ICSEventContextMenu } from "../components/ICSEventContextMenu"; -import { parseDateToLocal } from "../utils/dateUtils"; +import { parseDateToLocal, createUTCDateFromLocalCalendarDate } from "../utils/dateUtils"; +import { + resolveTaskListKeyboardAction, + type TaskListAction, +} from "./taskListKeyboardActions"; +import { + executeBasesTaskCardAction, + type BasesTaskCardActionContext, +} from "./basesTaskCardActions"; +import type { BasesTaskCardActionViewContext } from "./BasesTaskCardKeyboardController"; import { CalendarRecreateNavigationState, shouldPreserveVisibleDateOnCalendarRecreate, @@ -429,10 +438,31 @@ export function getTodayColumnWidths( ); } +// Grid modes have no roving-focus or selection model, so navigation, selection, +// search, and create hotkeys are meaningless there; only single-target actions +// against the hovered task are dispatched. +const GRID_HOVER_EXCLUDED_ACTIONS: ReadonlySet = new Set([ + "navigate-next", + "navigate-previous", + "jump-first", + "jump-last", + "clear-focus-and-selection", + "toggle-select", + "select-all", + "focus-search", + "create-task", +]); + export class CalendarView extends BasesViewBase { type = "tasknotesCalendar"; calendar: Calendar | null = null; // Made public for factory access private calendarEl: HTMLElement | null = null; + // Grid modes (Month/Week/Day/Year/Custom) render FullCalendar's own event + // elements, not `.task-card`, so there is no roving-focus model to key + // hotkeys off of. These track the task under the mouse instead; hotkeys in + // grid mode dispatch against whichever task is currently hovered. + private hoveredGridTaskPath: string | null = null; + private hoveredGridTaskElement: HTMLElement | null = null; private currentTasks: TaskInfo[] = []; private basesEntryByPath: Map = new Map(); // Map task path to Bases entry for enrichment private basesSortIndexByPath = new Map(); @@ -1175,6 +1205,7 @@ export class CalendarView extends BasesViewBase { this.setupSearch(this.rootElement); } + this.taskCardKeyboardController?.prepareForRender(); try { // Extract tasks from Bases const dataItems = this.dataAdapter.extractDataItems(); @@ -1210,6 +1241,9 @@ export class CalendarView extends BasesViewBase { this.renderError(error instanceof Error ? error : new Error(String(error))); } finally { this._isRendering = false; + this.taskCardKeyboardController?.restoreAfterRender(); + this.updateSelectionVisuals(); + this.updateSelectionIndicator(this.plugin.taskSelectionService?.getSelectionCount() ?? 0); } // If a render was requested while we were rendering, do it now @@ -2897,9 +2931,120 @@ export class CalendarView extends BasesViewBase { this.rootElement.appendChild(calendarEl); this.calendarEl = calendarEl; this.applyLayoutClasses(); + this.registerGridHoverActionListeners(this.rootElement); } } + + private isCalendarListMode(): boolean { + return this.viewOptions.calendarView.startsWith("list"); + } + + /** + * Task List-style hotkeys reach Calendar's grid modes (Month/Week/Day/Year/ + * Custom) through whichever task is currently under the mouse, since those + * modes render FullCalendar's own event elements rather than `.task-card` + * and have no roving-focus model to key a shortcut off of. Leaving this + * tracking running in list mode too is harmless: it is simply never + * consulted there, since the shared task-card keyboard controller already + * owns that mode. + */ + private registerGridHoverActionListeners(root: HTMLElement): void { + this.registerDomEvent(root, "mousemove", (event: MouseEvent) => { + const target = event.target; + const card = + target instanceof Element + ? target.closest(".fc-task-event[data-task-path]") + : null; + this.hoveredGridTaskElement = card; + this.hoveredGridTaskPath = card?.dataset.taskPath ?? null; + }); + // Resolve view-local shortcuts during capture, matching the shared + // task-card keyboard controller's own registration. + this.registerDomEvent( + root, + "keydown", + (event: KeyboardEvent) => this.handleGridHoverActionKeyDown(event), + true + ); + } + + private handleGridHoverActionKeyDown(event: KeyboardEvent): void { + // The shared task-card keyboard controller already owns list/Agenda mode. + if (this.isCalendarListMode()) return; + if (!this.taskCardKeyboardController?.canHandleKeyDown(event)) return; + + const path = this.hoveredGridTaskPath; + if (!path) return; + + const action = resolveTaskListKeyboardAction( + event, + this.plugin.settings.taskListShortcuts, + this.plugin.settings.taskListUserFieldShortcuts + ); + if (!action || GRID_HOVER_EXCLUDED_ACTIONS.has(action)) return; + + event.preventDefault(); + event.stopPropagation(); + void executeBasesTaskCardAction(action, path, this.buildGridHoverActionContext()); + } + + private buildGridHoverActionContext(): BasesTaskCardActionContext { + return { + plugin: this.plugin, + app: this.app || this.plugin.app, + taskSelectionService: undefined, + getTargetPaths: () => (this.hoveredGridTaskPath ? [this.hoveredGridTaskPath] : []), + getVisibleTaskPaths: () => [], + isPathVisible: () => false, + getAnchor: () => this.hoveredGridTaskElement, + getCurrentTargetDate: () => createUTCDateFromLocalCalendarDate(new Date()), + restoreFocus: () => false, + rootElement: this.rootElement, + showBatchContextMenu: () => { + // No selection model in grid mode; batch actions never apply. + }, + createFileForView: () => this.createFileForView(), + }; + } + + /** + * List/Agenda mode renders real `.task-card` elements + * (`calendarEventMount.ts`), so it gets full Task List parity through the + * shared task-card keyboard controller. Grid modes return `null` here — + * they have no cards for that controller to focus, and are instead + * covered by the hover-target handling above. + */ + protected getTaskCardActionsConfig(): { + isActionSupported(action: TaskListAction): boolean; + buildViewContext(): BasesTaskCardActionViewContext; + autoFocusInitial?: boolean; + cardAreaElement?: HTMLElement; + } | null { + if (!this.isCalendarListMode()) return null; + + return { + cardAreaElement: this.calendarEl ?? undefined, + isActionSupported: () => true, + buildViewContext: () => { + const visiblePaths = this.getVisibleTaskPaths(); + const visiblePathSet = new Set(visiblePaths); + return { + plugin: this.plugin, + app: this.app || this.plugin.app, + taskSelectionService: this.plugin.taskSelectionService, + getVisibleTaskPaths: () => visiblePaths, + isPathVisible: (path) => visiblePathSet.has(path), + getCurrentTargetDate: () => createUTCDateFromLocalCalendarDate(new Date()), + rootElement: this.rootElement, + showBatchContextMenu: (event) => this.showBatchContextMenu(event), + createFileForView: () => this.createFileForView(), + fallbackAnchor: this.calendarEl ?? undefined, + }; + }, + }; + } + protected async handleTaskUpdate( task: TaskInfo, source?: BasesTaskUpdateSource diff --git a/src/bases/KanbanView.ts b/src/bases/KanbanView.ts index 4968b626d..7a7581e56 100644 --- a/src/bases/KanbanView.ts +++ b/src/bases/KanbanView.ts @@ -10,7 +10,9 @@ import { renderGroupTitle } from "./groupTitleRenderer"; import { type LinkServices } from "../ui/renderers/linkRenderer"; import { showConfirmationModal } from "../modals/ConfirmationModal"; import { VirtualScroller } from "../utils/VirtualScroller"; -import { getCurrentTimestamp } from "../utils/dateUtils"; +import { getCurrentTimestamp, createUTCDateFromLocalCalendarDate } from "../utils/dateUtils"; +import type { BasesTaskCardActionViewContext } from "./BasesTaskCardKeyboardController"; +import type { TaskListAction } from "./taskListKeyboardActions"; import { getProjectDisplayName } from "../utils/linkUtils"; import { stringifyUnknown } from "../utils/stringUtils"; import { @@ -568,6 +570,7 @@ export class KanbanView extends BasesViewBase { this.setupSearch(this.rootElement); } + this.taskCardKeyboardController?.prepareForRender(); try { const dataItems = this.dataAdapter.extractDataItems(); @@ -635,6 +638,10 @@ export class KanbanView extends BasesViewBase { error: error, }); this.renderError(error instanceof Error ? error : new Error(String(error))); + } finally { + this.taskCardKeyboardController?.restoreAfterRender(); + this.updateSelectionVisuals(); + this.updateSelectionIndicator(this.plugin.taskSelectionService?.getSelectionCount() ?? 0); } } @@ -4309,6 +4316,45 @@ export class KanbanView extends BasesViewBase { return getKanbanTaskActionDate(task); } + + /** + * Kanban supports the full Task List action set against real, focusable + * `.task-card` elements. Navigation/selection reach only cards currently + * rendered by a column's VirtualScroller, matching the same limitation + * mouse-based selection already has for large virtualized columns. + */ + protected getTaskCardActionsConfig(): { + isActionSupported(action: TaskListAction): boolean; + buildViewContext(): BasesTaskCardActionViewContext; + autoFocusInitial?: boolean; + cardAreaElement?: HTMLElement; + } | null { + return { + cardAreaElement: this.boardEl ?? undefined, + isActionSupported: () => true, + buildViewContext: () => ({ + plugin: this.plugin, + app: this.app || this.plugin.app, + taskSelectionService: this.plugin.taskSelectionService, + getVisibleTaskPaths: () => [...this.currentVisibleTaskPaths], + isPathVisible: (path) => this.currentVisibleTaskPaths.has(path), + getCurrentTargetDate: () => createUTCDateFromLocalCalendarDate(new Date()), + rootElement: this.rootElement, + showBatchContextMenu: (event) => this.showBatchContextMenu(event), + createFileForView: () => this.createFileForView(), + focusSearch: () => { + if (!this.rootElement) return; + if (!this.searchBox) { + this.enableSearch = true; + this.setupSearch(this.rootElement); + } + this.searchBox?.focus(); + }, + fallbackAnchor: this.boardEl ?? undefined, + }), + }; + } + private destroyColumnScrollers(): void { for (const scroller of this.columnScrollers.values()) { scroller.destroy(); diff --git a/src/bases/TaskListFocusController.ts b/src/bases/TaskListFocusController.ts index 1f6c3986b..340ae7a9d 100644 --- a/src/bases/TaskListFocusController.ts +++ b/src/bases/TaskListFocusController.ts @@ -42,7 +42,8 @@ export class TaskListFocusController { constructor( private readonly root: HTMLElement, - autoFocusInitial = false + autoFocusInitial = false, + private readonly canClaimHover: () => boolean = () => true ) { this.initialFocusPending = autoFocusInitial; this.syncCursorSourceClass(); @@ -67,6 +68,7 @@ export class TaskListFocusController { handleMouseMove(event: MouseEvent): boolean { const card = this.getCardFromTarget(event.target); if (!card) return false; + if (!this.canClaimHover()) return false; this.setCursorSource("mouse"); if (card === this.lastMouseCard) return true; diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index 800e62a82..76e6f04cf 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion -- Legacy Bases view rendering narrows DOM references through lifecycle checks. */ -import { Menu, Notice, Platform, Scope, TFile, setIcon } from "obsidian"; +import { Menu, Notice, Platform, TFile, setIcon } from "obsidian"; import type { BasesView, BasesViewFactory } from "obsidian"; import TaskNotesPlugin from "../main"; import { BasesViewBase } from "./BasesViewBase"; @@ -11,23 +11,16 @@ import { type LinkServices } from "../ui/renderers/linkRenderer"; import { DateContextMenu } from "../components/DateContextMenu"; import { PriorityContextMenu } from "../components/PriorityContextMenu"; import { RecurrenceContextMenu } from "../components/RecurrenceContextMenu"; -import { StatusContextMenu } from "../components/StatusContextMenu"; import { showConfirmationModal } from "../modals/ConfirmationModal"; -import { showTextInputModal } from "../modals/TextInputModal"; -import { ProjectSelectModal } from "../modals/ProjectSelectModal"; -import { TagSuggest } from "../modals/taskModalSuggests"; import { ReminderModal } from "../modals/ReminderModal"; -import { UserFieldEditModal } from "../modals/UserFieldEditModal"; import { getDatePart, getTimePart, getCurrentTimestamp, - parseDateToUTC, createUTCDateFromLocalCalendarDate, } from "../utils/dateUtils"; import { stringifyUnknown } from "../utils/stringUtils"; import { generateProjectReference, parseLinkToPath } from "../utils/linkUtils"; -import { formatTasksForClipboard } from "../utils/taskClipboard"; import { VirtualScroller } from "../utils/VirtualScroller"; import { isSortOrderInSortConfig, @@ -74,26 +67,10 @@ import { moveItemsRelativeToTarget, } from "./manualOrderState"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; -import { TaskListFocusController } from "./TaskListFocusController"; -import { - resolveTaskListDragPaths, - resolveTaskListTargetPaths, -} from "./taskListTargetResolver"; -import { - resolveTaskListKeyboardAction, - taskListShortcutToScopeBinding, - TASK_LIST_KEYBOARD_ACTIONS, - type TaskListAction, - type TaskListKeyboardAction, -} from "./taskListKeyboardActions"; -import { addTagsToList, parseTaskTagInput } from "../utils/taskTagList"; -import { addContextToList } from "../components/TaskContextMenu"; -import { - addTaskToProject, - getTaskProjectFiles, - removeTaskFromProject, -} from "../services/taskRelationshipActions"; -import { TaskListInputOwnershipController } from "./TaskListInputOwnershipController"; +import { resolveTaskListDragPaths } from "./taskListTargetResolver"; +import { type TaskListAction } from "./taskListKeyboardActions"; +import { getTaskActionDate } from "./basesTaskCardActions"; +import type { BasesTaskCardActionViewContext } from "./BasesTaskCardKeyboardController"; const tasknotesLogger = createTaskNotesLogger({ tag: "Bases/TaskListView" }); @@ -170,10 +147,6 @@ export class TaskListView extends BasesViewBase { private clickTimeouts = new Map(); private currentTargetDate = createUTCDateFromLocalCalendarDate(new Date()); private containerListenersRegistered = false; - private focusController: TaskListFocusController | null = null; - private inputOwnershipController: TaskListInputOwnershipController | null = null; - private taskListShortcutScope: Scope | null = null; - private taskListLeafActive = false; private virtualScroller: VirtualScroller | null = null; // Can render TaskInfo or group headers private useVirtualScrolling = false; private collapsedGroups = new Set(); // Track collapsed group keys @@ -242,116 +215,12 @@ export class TaskListView extends BasesViewBase { onload(): void { // Read view options now that config is available this.readViewOptions(); - // Call parent onload which sets up container and listeners + // Call parent onload which sets up container, listeners, and the shared + // task-card keyboard controller (focus, hotkeys, and Scope activation). super.onload(); this.registerGroupContextMenuListeners(); - this.registerEvent( - this.plugin.app.workspace.on("active-leaf-change", (leaf) => { - this.syncTaskListShortcutScopeForLeaf(leaf); - this.restoreFocusForActivatedLeaf(leaf); - }) - ); - this.registerDomEvent( - this.containerEl.ownerDocument, - "click", - (event: MouseEvent) => { - const target = event.target; - if ( - target instanceof Element && - target.closest(".workspace-tab-header") - ) { - const win = this.containerEl.ownerDocument.defaultView ?? window; - win.setTimeout(() => { - const leaf = this.plugin.app.workspace.getMostRecentLeaf(); - this.syncTaskListShortcutScopeForLeaf(leaf); - this.restoreFocusForActivatedLeaf(leaf); - }, 0); - } - }, - true - ); - this.syncTaskListShortcutScopeForLeaf( - this.plugin.app.workspace.getMostRecentLeaf() - ); - } - - private isTaskListLeaf( - leaf: { view?: { containerEl?: HTMLElement } } | null - ): boolean { - return Boolean(leaf?.view?.containerEl?.contains(this.containerEl)); } - private restoreFocusForActivatedLeaf( - leaf: { view?: { containerEl?: HTMLElement } } | null - ): void { - if (!this.isTaskListLeaf(leaf)) return; - - const win = this.containerEl.ownerDocument.defaultView ?? window; - win.setTimeout(() => { - if (this.rootElement?.isConnected) { - this.focusController?.restoreFocusedElement(); - } - }, 0); - } - - private syncTaskListShortcutScopeForLeaf( - leaf: { view?: { containerEl?: HTMLElement } } | null - ): void { - this.taskListLeafActive = this.isTaskListLeaf(leaf); - this.syncTaskListShortcutScopeForFocusTarget( - this.containerEl.ownerDocument.activeElement - ); - } - - private syncTaskListShortcutScopeForFocusTarget(target: EventTarget | null): void { - if ( - this.taskListLeafActive && - this.inputOwnershipController?.canOwnKeyboardTarget(target, true) - ) { - this.activateTaskListShortcutScope(); - return; - } - this.deactivateTaskListShortcutScope(); - } - - private activateTaskListShortcutScope(): void { - if (this.taskListShortcutScope) return; - - // A child Obsidian scope lets view-local configurable chords win over - // global editor commands while this Task List leaf is active. - const scope = new Scope(this.plugin.app.scope); - const shortcuts = this.plugin.settings.taskListShortcuts; - const bindings = [ - ...TASK_LIST_KEYBOARD_ACTIONS.flatMap((action) => - (shortcuts[action] ?? []).map((shortcut) => ({ action, shortcut })) - ), - ...Object.entries(this.plugin.settings.taskListUserFieldShortcuts ?? {}).flatMap( - ([fieldId, fieldShortcuts]) => - fieldShortcuts.map((shortcut) => ({ - action: `edit-user-field:${fieldId}` as TaskListAction, - shortcut, - })) - ), - ]; - for (const { shortcut } of bindings) { - const binding = taskListShortcutToScopeBinding(shortcut); - if (!binding) continue; - scope.register(binding.modifiers, binding.key, (event) => { - if (!this.taskListLeafActive) return; - if (!this.handleTaskListKeyDown(event, true)) return; - return false; - }); - } - - this.taskListShortcutScope = scope; - this.plugin.app.keymap.pushScope(scope); - } - - private deactivateTaskListShortcutScope(): void { - if (!this.taskListShortcutScope) return; - this.plugin.app.keymap.popScope(this.taskListShortcutScope); - this.taskListShortcutScope = null; - } /** * Register contextmenu listeners for group collapse actions. @@ -640,11 +509,6 @@ export class TaskListView extends BasesViewBase { itemsContainer.classList.add("tn-static-margin-top-12px-91e0f558"); rootElement.appendChild(itemsContainer); this.itemsContainer = itemsContainer; - this.focusController = new TaskListFocusController(itemsContainer, true); - this.inputOwnershipController = new TaskListInputOwnershipController( - rootElement, - this.focusController - ); this.registerContainerListeners(); this.setupContainerDragHandlers(); } @@ -659,7 +523,7 @@ export class TaskListView extends BasesViewBase { this.pendingRender = true; return; } - this.focusController?.prepareForRender(); + this.taskCardKeyboardController?.prepareForRender(); // Always re-read view options to catch config changes such as // switching expanded relationship filtering modes in Bases. @@ -737,7 +601,7 @@ export class TaskListView extends BasesViewBase { } private restoreInteractionStateAfterRender(): void { - this.focusController?.restoreAfterRender(); + this.taskCardKeyboardController?.restoreAfterRender(); // Rendering replaces card elements, so restore visual state from the // shared selection service after every render—not only when selection // itself changes. @@ -2394,16 +2258,11 @@ export class TaskListView extends BasesViewBase { * Override from Component base class. */ onunload(): void { - // Component.register() calls will be automatically cleaned up (including search cleanup) + // Component.register() calls will be automatically cleaned up (including + // search cleanup and the shared task-card keyboard controller) // We just need to clean up view-specific state - this.taskListLeafActive = false; - this.deactivateTaskListShortcutScope(); this.unregisterContainerListeners(); this.destroyVirtualScroller(); - this.inputOwnershipController?.destroy(); - this.inputOwnershipController = null; - this.focusController?.clear(); - this.focusController = null; this.currentTaskElements.clear(); this.itemsContainer = null; @@ -2566,300 +2425,51 @@ export class TaskListView extends BasesViewBase { if (!this.itemsContainer || this.containerListenersRegistered) return; // Register click listener for group header collapse/expand using Component API - // This automatically cleans up on component unload + // This automatically cleans up on component unload. Focus, hover, and + // keyboard-shortcut handling are wired up by the shared task-card + // keyboard controller installed in `BasesViewBase.onload()`. this.registerDomEvent(this.itemsContainer, "click", this.handleItemClick); - this.registerDomEvent(this.itemsContainer, "focusin", (event: FocusEvent) => { - this.focusController?.handleFocusIn(event); - }); - this.registerDomEvent(this.itemsContainer, "pointerdown", (event: PointerEvent) => { - this.focusController?.handlePointerDown(event); - }); - this.registerDomEvent(this.itemsContainer, "mousemove", (event: MouseEvent) => { - // Context-menu edits operate on the focused task/selection. While an - // Obsidian menu is open, hovering cards underneath it must not move the - // task-list mouse cursor and change the edit target. - if (this.itemsContainer?.ownerDocument.querySelector(".menu")) return; - this.focusController?.handleMouseMove(event); - }); - if (this.rootElement) { - // Resolve view-local shortcuts during capture so Obsidian commands such - // as Ctrl+B/Ctrl+D cannot stop propagation before the task list sees a - // user-configured chord. - this.registerDomEvent( - this.rootElement, - "keydown", - (event: KeyboardEvent) => { - this.handleTaskListRootKeyDown(event); - }, - true - ); - } - const doc = this.itemsContainer.ownerDocument; - this.registerDomEvent(doc, "focusin", (event: FocusEvent) => { - this.inputOwnershipController?.handleDocumentFocusIn(event); - this.syncTaskListShortcutScopeForFocusTarget(event.target); - }); - this.registerDomEvent(doc, "pointerdown", (event: PointerEvent) => { - this.inputOwnershipController?.handleOverlayInteraction(event); - }); - this.registerDomEvent( - doc, - "keydown", - (event: KeyboardEvent) => { - if (event.key === "Escape" || event.key === "Backspace") { - this.inputOwnershipController?.handleOverlayInteraction(event); - } - }, - true - ); this.containerListenersRegistered = true; } - private handleTaskListRootKeyDown(event: KeyboardEvent): void { - const eventStartedInTaskItems = - this.itemsContainer?.contains(event.target as Node) ?? false; - this.handleTaskListKeyDown(event, !eventStartedInTaskItems); - } - - private handleTaskListKeyDown( - event: KeyboardEvent, - allowRememberedFocus = false - ): boolean { - if ( - !this.inputOwnershipController?.canHandleListKeyDown( - event, - allowRememberedFocus - ) - ) { - return false; - } - return this.handleTaskListActionKeyDown(event, allowRememberedFocus); - } - - protected canHandleSelectionKeyDown(event: KeyboardEvent): boolean { - return ( - (this.inputOwnershipController?.canHandleListKeyDown(event) ?? false) && - event.shiftKey && - ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key) - ); - } protected handleSearchDismissed(): void { - this.focusController?.restoreFocusedElement(); + this.taskCardKeyboardController?.focusController.restoreFocusedElement(); } - private handleTaskListActionKeyDown( - event: KeyboardEvent, - allowRememberedFocus = false - ): boolean { - const action = resolveTaskListKeyboardAction( - event, - this.plugin?.settings?.taskListShortcuts, - this.plugin?.settings?.taskListUserFieldShortcuts - ); - if (!action) return false; - const focusedPath = this.focusController?.getFocusedPathForEvent( - event, - true, - allowRememberedFocus - ); - if ( - !focusedPath && - action !== "clear-focus-and-selection" && - action !== "select-all" - ) { - return false; - } - const navigationDirections = { - "navigate-next": "next", - "navigate-previous": "previous", - "jump-first": "first", - "jump-last": "last", - } as const; - if (action in navigationDirections) { - return ( - this.focusController?.moveFocus( - event, - navigationDirections[action as keyof typeof navigationDirections] - ) ?? false - ); - } - - event.preventDefault(); - event.stopPropagation(); - const opensTaskListOverlay = - [ - "edit-task", - "open-context-menu", - "edit-due", - "edit-scheduled", - "edit-priority", - "mark-complete", - "edit-status", - "edit-recurrence", - "add-tags", - "add-context", - "add-project", - "delete-tasks", - ].includes(action as TaskListKeyboardAction) || action.startsWith("edit-user-field:"); - if (opensTaskListOverlay) { - // User-field editors are overlays too; recording this before opening lets - // input ownership restore the remembered card when the modal closes. - this.inputOwnershipController?.noteOverlayOpening(); - } - void this.executeTaskListAction(action, focusedPath ?? null); - return true; - } /** - * Resolve action targets using upstream selection state first, then keyboard focus. - * Task actions added in later keyboard-navigation slices should use this method. + * Task List supports every built-in action plus any configured user-field + * edits, using its search box, item container, and current view date as + * the view-specific pieces the shared executor needs. */ - getTaskActionTargetPaths(): string[] { - return resolveTaskListTargetPaths( - this.plugin.taskSelectionService, - this.focusController?.getFocusedIdentity()?.path - ).filter((path) => this.currentVisibleTaskPaths.has(path)); - } - - private async executeTaskListAction( - action: TaskListAction, - focusedPath: string | null - ): Promise { - switch (action) { - case "navigate-next": - case "navigate-previous": - case "jump-first": - case "jump-last": - return; - case "clear-focus-and-selection": - this.clearTaskListFocusAndSelection(); - return; - case "toggle-select": - this.toggleFocusedTaskSelection(); - return; - case "select-all": - this.selectAllVisibleTasks(); - return; - case "copy-task-titles": - await this.copyTaskActionTargetTitles(); - return; - case "toggle-archive": - await this.toggleTaskActionTargetsArchive(); - return; - case "create-task": - await this.createFileForView(); - return; - case "focus-search": - this.focusTaskListSearch(); - return; - case "edit-task": { - const task = (await this.getTaskActionTargets())[0]; - if (task) await this.plugin.openTaskEditModal(task); - return; - } - case "open-context-menu": { - if (!focusedPath) return; - const anchor = this.getTaskActionAnchor(); - const rect = anchor?.getBoundingClientRect(); - const menuEvent = new MouseEvent("contextmenu", { - bubbles: true, - cancelable: true, - clientX: rect?.right ?? 0, - clientY: rect?.top ?? 0, - }); - const selectionService = this.plugin.taskSelectionService; - if (selectionService && selectionService.getSelectionCount() > 1) { - this.showBatchContextMenu(menuEvent); - return; - } - await showTaskContextMenu( - menuEvent, - focusedPath, - this.plugin, - this.currentTargetDate - ); - return; - } - case "open-task-notes": - await this.openTaskActionTargets(); - return; - case "edit-due": - await this.showTaskActionDateMenu("due"); - return; - case "edit-scheduled": - await this.showTaskActionDateMenu("scheduled"); - return; - case "edit-priority": - await this.showTaskActionPriorityMenu(); - return; - case "mark-complete": - await this.markTaskActionTargetsComplete(); - return; - case "edit-status": - await this.showTaskActionStatusMenu(); - return; - case "edit-recurrence": - await this.showTaskActionRecurrenceMenu(); - return; - case "add-tags": - await this.addTagsToTaskActionTargets(); - return; - case "add-context": - await this.addContextToTaskActionTargets(); - return; - case "add-project": - this.addProjectToTaskActionTargets(); - return; - case "delete-tasks": - await this.deleteTaskActionTargets(); - return; - default: { - // Runtime user-field actions share the same visible-target selection - // path as built-in edits, so filtered-out selected tasks are excluded. - if (!action.startsWith("edit-user-field:")) return; - const fieldId = action.slice("edit-user-field:".length); - const field = this.plugin.settings.userFields?.find((candidate) => candidate.id === fieldId); - if (!field) return; - const tasks = await this.getTaskActionTargets(); - if (tasks.length === 0) return; - new UserFieldEditModal(this.plugin.app, this.plugin, { - field, - tasks, - onApply: async (value, listChange) => { - for (const task of tasks) { - let taskValue = value; - if (field.type === "list") { - const customProperties = (task.customProperties ?? {}) as Record; - const current = Array.isArray(customProperties[field.key]) - ? (customProperties[field.key] as unknown[]).map(String) - : []; - const withoutRemoved = current.filter( - (candidate) => !listChange?.removed?.includes(candidate) - ); - taskValue = listChange?.added - ? [...withoutRemoved, listChange.added].filter( - (candidate, index, list) => list.indexOf(candidate) === index - ) - : withoutRemoved; - } - await this.plugin.updateTaskProperty( - task, - field.key as keyof TaskInfo, - taskValue as TaskInfo[keyof TaskInfo], - { silent: true } - ); - } - }, - onClose: () => { - this.restoreTaskListFocusAfterOverlayClose(); - }, - }).open(); - } - } + protected getTaskCardActionsConfig(): { + isActionSupported(action: TaskListAction): boolean; + buildViewContext(): BasesTaskCardActionViewContext; + autoFocusInitial?: boolean; + cardAreaElement?: HTMLElement; + } | null { + return { + autoFocusInitial: true, + cardAreaElement: this.itemsContainer ?? undefined, + isActionSupported: () => true, + buildViewContext: () => ({ + plugin: this.plugin, + app: this.app || this.plugin.app, + taskSelectionService: this.plugin.taskSelectionService, + getVisibleTaskPaths: () => [...this.currentVisibleTaskPaths], + isPathVisible: (path) => this.currentVisibleTaskPaths.has(path), + getCurrentTargetDate: () => this.currentTargetDate, + rootElement: this.rootElement, + showBatchContextMenu: (event) => this.showBatchContextMenu(event), + createFileForView: () => this.createFileForView(), + focusSearch: () => this.focusTaskListSearch(), + fallbackAnchor: this.itemsContainer, + }), + }; } - private focusTaskListSearch(): void { +private focusTaskListSearch(): void { if (!this.rootElement) return; if (!this.searchBox) { this.searchOpenedByShortcut = true; @@ -2869,336 +2479,6 @@ export class TaskListView extends BasesViewBase { this.searchBox?.focus(); } - /** Restores card focus after Obsidian completes its modal selection cleanup. */ - private restoreTaskListFocusAfterOverlayClose(): void { - // Obsidian restores the modal's saved selection after onClose; defer card - // focus until that cleanup has finished so keyboard ownership is retained. - setTimeout(() => { - this.focusController?.restoreFocusedElement(); - this.inputOwnershipController?.resumeAfterOverlayClose(); - this.syncTaskListShortcutScopeForFocusTarget( - this.containerEl.ownerDocument.activeElement - ); - if (this.taskListLeafActive) this.activateTaskListShortcutScope(); - }, 0); - } - - private async getTaskActionTargets(): Promise { - const tasks: TaskInfo[] = []; - for (const path of this.getTaskActionTargetPaths()) { - const task = await this.plugin.cacheManager.getTaskInfo(path); - if (task) tasks.push(task); - } - return tasks; - } - - private clearTaskListFocusAndSelection(): void { - const selectionService = this.plugin.taskSelectionService; - selectionService?.clearSelection(); - selectionService?.exitSelectionMode(); - if (!this.focusController?.restoreFocusedElement()) { - this.rootElement?.focus({ preventScroll: true }); - } - } - - private toggleFocusedTaskSelection(): void { - const path = this.focusController?.getFocusedIdentity()?.path; - if (!path || !this.currentVisibleTaskPaths.has(path)) return; - this.plugin.taskSelectionService?.toggleSelection(path); - } - - private selectAllVisibleTasks(): void { - const selectionService = this.plugin.taskSelectionService; - if (!selectionService) return; - selectionService.selectAll([...this.currentVisibleTaskPaths]); - if (this.currentVisibleTaskPaths.size > 0) { - selectionService.enterSelectionMode(); - } - } - - private async copyTaskActionTargetTitles(): Promise { - const tasks = await this.getTaskActionTargets(); - if (tasks.length === 0) return; - - try { - await navigator.clipboard.writeText(formatTasksForClipboard(tasks, "titles")); - new Notice(`Copied ${tasks.length} task title${tasks.length === 1 ? "" : "s"}`); - } catch (error) { - tasknotesLogger.error("[TaskNotes][TaskListView] Failed to copy task titles", { - category: "provider", - operation: "copy-task-titles", - error, - }); - new Notice("Failed to copy task titles"); - } - } - - private async toggleTaskActionTargetsArchive(): Promise { - const tasks = await this.getTaskActionTargets(); - if (tasks.length === 0) return; - - const archived = tasks[0].archived === true; - if (!tasks.every((task) => (task.archived === true) === archived)) { - new Notice("Select tasks with the same archive state"); - return; - } - - for (const task of tasks) { - await this.plugin.taskService.toggleArchive(task); - } - new Notice( - `${archived ? "Unarchived" : "Archived"} ${tasks.length} task${ - tasks.length === 1 ? "" : "s" - }` - ); - } - - private getTaskActionAnchor(): HTMLElement | null { - return this.focusController?.getFocusedElement() ?? this.itemsContainer; - } - - private async updateTaskActionTargets( - tasks: readonly TaskInfo[], - property: keyof TaskInfo, - value: unknown - ): Promise { - for (const task of tasks) { - await this.plugin.updateTaskProperty(task, property, value); - } - } - - /** - * Completes the focused/selected visible tasks through their semantic completion paths. - * Recurring parents record the scheduled instance; ordinary tasks receive a completed status. - */ - private async markTaskActionTargetsComplete(): Promise { - const tasks = await this.getTaskActionTargets(); - for (const task of tasks) { - if (task.recurrence) { - await this.plugin.toggleRecurringTaskComplete(task, this.getTaskActionDate(task)); - continue; - } - - const completedStatus = this.plugin.statusManager.getCompletedStatuses()[0] || "done"; - if (!this.plugin.statusManager.isCompletedStatus(task.status)) { - await this.plugin.updateTaskProperty(task, "status", completedStatus); - } - } - } - - /** - * Applies a status selected from the Task List while preserving recurring-instance semantics. - * Selecting a completed status completes the current occurrence instead of completing its parent. - */ - private async updateTaskActionTargetStatuses( - tasks: readonly TaskInfo[], - status: string - ): Promise { - for (const task of tasks) { - if (task.recurrence && this.plugin.statusManager.isCompletedStatus(status)) { - // A recurring parent's completed state lives in complete_instances; writing - // status directly would make the whole series appear terminal. - await this.plugin.toggleRecurringTaskComplete(task, this.getTaskActionDate(task)); - } else { - await this.plugin.updateTaskProperty(task, "status", status); - } - } - } - - private async openTaskActionTargets(): Promise { - const app = this.app || this.plugin.app; - for (const task of await this.getTaskActionTargets()) { - const file = app.vault.getAbstractFileByPath(task.path); - if (file instanceof TFile) { - await app.workspace.getLeaf("tab").openFile(file); - } - } - } - - private async showTaskActionDateMenu(dateType: "due" | "scheduled"): Promise { - const tasks = await this.getTaskActionTargets(); - const anchor = this.getTaskActionAnchor(); - if (tasks.length === 0 || !anchor) return; - - const currentValue = dateType === "due" ? tasks[0].due : tasks[0].scheduled; - const menu = new DateContextMenu({ - currentValue: getDatePart(currentValue || ""), - currentTime: getTimePart(currentValue || ""), - onSelect: (dateValue, timeValue) => { - const value = dateValue - ? timeValue - ? `${dateValue}T${timeValue}` - : dateValue - : undefined; - void this.updateTaskActionTargets(tasks, dateType, value); - }, - dateRole: dateType, - plugin: this.plugin, - app: this.app || this.plugin.app, - }); - menu.showAtElement(anchor); - } - - private async showTaskActionPriorityMenu(): Promise { - const tasks = await this.getTaskActionTargets(); - const anchor = this.getTaskActionAnchor(); - if (tasks.length === 0 || !anchor) return; - - new PriorityContextMenu({ - currentValue: tasks[0].priority, - onSelect: (value) => void this.updateTaskActionTargets(tasks, "priority", value), - plugin: this.plugin, - }).showAtElement(anchor); - } - - private async showTaskActionStatusMenu(): Promise { - const tasks = await this.getTaskActionTargets(); - const anchor = this.getTaskActionAnchor(); - if (tasks.length === 0 || !anchor) return; - - new StatusContextMenu({ - currentValue: tasks[0].status, - onSelect: (value) => void this.updateTaskActionTargetStatuses(tasks, value), - plugin: this.plugin, - }).showAtElement(anchor); - } - - private async showTaskActionRecurrenceMenu(): Promise { - const tasks = await this.getTaskActionTargets(); - const anchor = this.getTaskActionAnchor(); - if (tasks.length === 0 || !anchor) return; - - new RecurrenceContextMenu({ - currentValue: - typeof tasks[0].recurrence === "string" ? tasks[0].recurrence : undefined, - currentAnchor: tasks[0].recurrence_anchor || "scheduled", - scheduledDate: tasks[0].scheduled, - onSelect: (value, recurrenceAnchor) => { - void (async () => { - await this.updateTaskActionTargets( - tasks, - "recurrence", - value || undefined - ); - if (recurrenceAnchor !== undefined) { - await this.updateTaskActionTargets( - tasks, - "recurrence_anchor", - recurrenceAnchor - ); - } - })(); - }, - app: this.plugin.app, - plugin: this.plugin, - }).showAtElement(anchor); - } - - private async addTagsToTaskActionTargets(): Promise { - const tasks = await this.getTaskActionTargets(); - if (tasks.length === 0) return; - - const input = await showTextInputModal(this.plugin.app, { - title: this.plugin.i18n.translate("contextMenus.task.addTag"), - placeholder: this.plugin.i18n.translate("contextMenus.task.tagPlaceholder"), - confirmText: this.plugin.i18n.translate("common.confirm"), - cancelText: this.plugin.i18n.translate("common.cancel"), - onInputReady: (inputEl) => { - new TagSuggest(this.plugin.app, inputEl, this.plugin); - }, - }); - const tags = parseTaskTagInput(input); - if (tags.length === 0) return; - - for (const task of tasks) { - await this.plugin.updateTaskProperty(task, "tags", addTagsToList(task.tags, tags)); - } - } - - private async addContextToTaskActionTargets(): Promise { - const tasks = await this.getTaskActionTargets(); - if (tasks.length === 0) return; - - const context = await showTextInputModal(this.plugin.app, { - title: this.plugin.i18n.translate( - "contextMenus.task.organization.addContext" - ), - placeholder: this.plugin.i18n.translate( - "contextMenus.task.organization.contextPlaceholder" - ), - confirmText: this.plugin.i18n.translate("common.confirm"), - cancelText: this.plugin.i18n.translate("common.cancel"), - }); - if (!context?.trim()) return; - - for (const task of tasks) { - await this.plugin.updateTaskProperty( - task, - "contexts", - addContextToList(task.contexts, context) - ); - } - } - - private addProjectToTaskActionTargets(): void { - const paths = this.getTaskActionTargetPaths(); - if (paths.length === 0) return; - - void (async () => { - const tasks = ( - await Promise.all( - paths.map((path) => this.plugin.cacheManager.getTaskInfo(path)) - ) - ).filter((task): task is TaskInfo => task !== null); - new ProjectSelectModal( - this.plugin.app, - this.plugin, - (projectFile) => { - if (!(projectFile instanceof TFile)) return; - void (async () => { - for (const path of paths) { - const task = await this.plugin.cacheManager.getTaskInfo(path); - if (task) await addTaskToProject(this.plugin, task, projectFile); - } - })(); - }, - { - selectedProjects: getTaskProjectFiles(this.plugin, tasks), - onRemove: async (projectFile) => { - for (const path of paths) { - const task = await this.plugin.cacheManager.getTaskInfo(path); - if (task) { - await removeTaskFromProject(this.plugin, task, projectFile); - } - } - }, - } - ).open(); - })(); - } - - private async deleteTaskActionTargets(): Promise { - const tasks = await this.getTaskActionTargets(); - if (tasks.length === 0) return; - - const confirmed = await showConfirmationModal(this.plugin.app, { - title: tasks.length === 1 ? "Delete task" : "Delete tasks", - message: - tasks.length === 1 - ? `Are you sure you want to delete "${tasks[0].title}"? This action cannot be undone.` - : `Are you sure you want to delete ${tasks.length} tasks? This action cannot be undone.`, - confirmText: "Delete", - cancelText: this.plugin.i18n.translate("common.cancel"), - isDestructive: true, - }); - if (!confirmed) return; - - for (const task of tasks) { - await this.plugin.taskService.deleteTask(task); - } - this.plugin.taskSelectionService?.clearSelection(); - } - private unregisterContainerListeners(): void { // No manual cleanup needed - Component.registerDomEvent handles it automatically this.containerListenersRegistered = false; @@ -3439,7 +2719,7 @@ export class TaskListView extends BasesViewBase { event, task.path, this.plugin, - this.getTaskActionDate(task) + getTaskActionDate(task, this.currentTargetDate) ); return; case "edit-date": @@ -3463,7 +2743,7 @@ export class TaskListView extends BasesViewBase { private async handleToggleStatus(task: TaskInfo, event: MouseEvent): Promise { try { if (task.recurrence) { - const actionDate = this.getTaskActionDate(task); + const actionDate = getTaskActionDate(task, this.currentTargetDate); await this.plugin.toggleRecurringTaskComplete(task, actionDate); } else { await this.plugin.toggleTaskStatus(task); @@ -3480,19 +2760,6 @@ export class TaskListView extends BasesViewBase { } } - /** - * Determine the date to use when completing a recurring task from Bases. - * Prefers the task's scheduled (or due) date to avoid marking the wrong instance. - */ - private getTaskActionDate(task: TaskInfo): Date { - const dateStr = getDatePart(task.scheduled || task.due || ""); - if (dateStr) { - return parseDateToUTC(dateStr); - } - - return this.currentTargetDate; - } - private showPriorityMenu(task: TaskInfo, event: MouseEvent): void { const menu = new PriorityContextMenu({ currentValue: task.priority, diff --git a/src/bases/basesTaskCardActions.ts b/src/bases/basesTaskCardActions.ts new file mode 100644 index 000000000..38d35c4bd --- /dev/null +++ b/src/bases/basesTaskCardActions.ts @@ -0,0 +1,459 @@ +import { Notice, TFile, type App } from "obsidian"; +import TaskNotesPlugin from "../main"; +import { TaskInfo } from "../types"; +import { showTaskContextMenu } from "../ui/TaskCard"; +import { DateContextMenu } from "../components/DateContextMenu"; +import { PriorityContextMenu } from "../components/PriorityContextMenu"; +import { StatusContextMenu } from "../components/StatusContextMenu"; +import { RecurrenceContextMenu } from "../components/RecurrenceContextMenu"; +import { addContextToList } from "../components/TaskContextMenu"; +import { showConfirmationModal } from "../modals/ConfirmationModal"; +import { showTextInputModal } from "../modals/TextInputModal"; +import { ProjectSelectModal } from "../modals/ProjectSelectModal"; +import { TagSuggest } from "../modals/taskModalSuggests"; +import { UserFieldEditModal } from "../modals/UserFieldEditModal"; +import { getDatePart, getTimePart, parseDateToUTC } from "../utils/dateUtils"; +import { addTagsToList, parseTaskTagInput } from "../utils/taskTagList"; +import { formatTasksForClipboard } from "../utils/taskClipboard"; +import { + addTaskToProject, + getTaskProjectFiles, + removeTaskFromProject, +} from "../services/taskRelationshipActions"; +import type { TaskSelectionService } from "../services/TaskSelectionService"; +import type { TaskListAction } from "./taskListKeyboardActions"; + +/** + * View-supplied hooks that let `executeBasesTaskCardAction` stay view-agnostic. + * Task List, Kanban, and Calendar (Agenda/list mode) each provide one of these, + * wired up by `BasesTaskCardKeyboardController`. + */ +export interface BasesTaskCardActionContext { + plugin: TaskNotesPlugin; + app: App; + taskSelectionService: TaskSelectionService | undefined; + /** Resolves the paths a non-focus-specific action should target (selection, else focused card). */ + getTargetPaths(): string[]; + /** All currently visible task paths, for select-all. */ + getVisibleTaskPaths(): string[]; + /** Whether a path is currently visible/renderable, used to guard focus-only actions. */ + isPathVisible(path: string): boolean; + /** Anchor element for context menus (usually the focused/hovered card). */ + getAnchor(): HTMLElement | null; + /** + * The view's current date context: used directly for the keyboard-opened task + * context menu, and as a fallback when a task has no scheduled/due date to + * derive a completion-target date from. + */ + getCurrentTargetDate(): Date; + /** Restores DOM focus to the remembered card; returns true if something was restored. */ + restoreFocus(): boolean; + rootElement: HTMLElement | null; + showBatchContextMenu(event: MouseEvent): void; + createFileForView(): Promise; + /** Only Task List currently has an inline search box. */ + focusSearch?: () => void; + /** Runs after a modal/menu opened by an action closes, so callers can resettle focus. */ + onOverlayClosed?: () => void; +} + +/** + * Determine the date to use when completing a recurring task from Bases. + * Prefers the task's scheduled (or due) date to avoid marking the wrong instance. + */ +export function getTaskActionDate(task: TaskInfo, fallback: Date): Date { + const dateStr = getDatePart(task.scheduled || task.due || ""); + return dateStr ? parseDateToUTC(dateStr) : fallback; +} + +async function getTasksForPaths( + context: BasesTaskCardActionContext, + paths: string[] +): Promise { + const tasks: TaskInfo[] = []; + for (const path of paths) { + const task = await context.plugin.cacheManager.getTaskInfo(path); + if (task) tasks.push(task); + } + return tasks; +} + +async function getActionTargets(context: BasesTaskCardActionContext): Promise { + return getTasksForPaths(context, context.getTargetPaths()); +} + +export async function updateTasksProperty( + context: BasesTaskCardActionContext, + tasks: readonly TaskInfo[], + property: keyof TaskInfo, + value: unknown +): Promise { + for (const task of tasks) { + await context.plugin.updateTaskProperty(task, property, value); + } +} + +export async function updateTasksStatus( + context: BasesTaskCardActionContext, + tasks: readonly TaskInfo[], + status: string +): Promise { + for (const task of tasks) { + if (task.recurrence && context.plugin.statusManager.isCompletedStatus(status)) { + await context.plugin.toggleRecurringTaskComplete( + task, + getTaskActionDate(task, context.getCurrentTargetDate()) + ); + } else { + await context.plugin.updateTaskProperty(task, "status", status); + } + } +} + +/** + * Executes a resolved Task List keyboard action against whichever tasks a view + * currently targets. Extracted from `TaskListView.executeTaskListAction` so + * Kanban and Calendar (Agenda/list mode) can reuse the same edit/menu/modal + * behavior instead of reimplementing it. `navigate-*`/`jump-*` are intentionally + * no-ops here: the keyboard controller resolves those against its own focus + * controller before an action ever reaches this dispatcher. + */ +export async function executeBasesTaskCardAction( + action: TaskListAction, + focusedPath: string | null, + context: BasesTaskCardActionContext +): Promise { + switch (action) { + case "navigate-next": + case "navigate-previous": + case "jump-first": + case "jump-last": + return; + case "clear-focus-and-selection": { + context.taskSelectionService?.clearSelection(); + context.taskSelectionService?.exitSelectionMode(); + if (!context.restoreFocus()) { + context.rootElement?.focus({ preventScroll: true }); + } + return; + } + case "toggle-select": { + if (!focusedPath || !context.isPathVisible(focusedPath)) return; + context.taskSelectionService?.toggleSelection(focusedPath); + return; + } + case "select-all": { + const selectionService = context.taskSelectionService; + if (!selectionService) return; + const paths = context.getVisibleTaskPaths(); + selectionService.selectAll(paths); + if (paths.length > 0) selectionService.enterSelectionMode(); + return; + } + case "copy-task-titles": { + const tasks = await getActionTargets(context); + if (tasks.length === 0) return; + try { + await navigator.clipboard.writeText(formatTasksForClipboard(tasks, "titles")); + new Notice(`Copied ${tasks.length} task title${tasks.length === 1 ? "" : "s"}`); + } catch { + new Notice("Failed to copy task titles"); + } + return; + } + case "toggle-archive": { + const tasks = await getActionTargets(context); + if (tasks.length === 0) return; + const archived = tasks[0].archived === true; + if (!tasks.every((task) => (task.archived === true) === archived)) { + new Notice("Select tasks with the same archive state"); + return; + } + for (const task of tasks) { + await context.plugin.taskService.toggleArchive(task); + } + new Notice( + `${archived ? "Unarchived" : "Archived"} ${tasks.length} task${ + tasks.length === 1 ? "" : "s" + }` + ); + return; + } + case "create-task": + await context.createFileForView(); + return; + case "focus-search": + context.focusSearch?.(); + return; + case "edit-task": { + const tasks = await getActionTargets(context); + if (tasks[0]) await context.plugin.openTaskEditModal(tasks[0]); + return; + } + case "open-context-menu": { + if (!focusedPath) return; + const anchor = context.getAnchor(); + const rect = anchor?.getBoundingClientRect(); + const menuEvent = new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: rect?.right ?? 0, + clientY: rect?.top ?? 0, + }); + const selectionService = context.taskSelectionService; + if (selectionService && selectionService.getSelectionCount() > 1) { + context.showBatchContextMenu(menuEvent); + return; + } + await showTaskContextMenu( + menuEvent, + focusedPath, + context.plugin, + context.getCurrentTargetDate() + ); + return; + } + case "open-task-notes": { + for (const task of await getActionTargets(context)) { + const file = context.app.vault.getAbstractFileByPath(task.path); + if (file instanceof TFile) { + await context.app.workspace.getLeaf("tab").openFile(file); + } + } + return; + } + case "edit-due": + case "edit-scheduled": { + const dateType = action === "edit-due" ? "due" : "scheduled"; + const tasks = await getActionTargets(context); + const anchor = context.getAnchor(); + if (tasks.length === 0 || !anchor) return; + + const currentValue = dateType === "due" ? tasks[0].due : tasks[0].scheduled; + new DateContextMenu({ + currentValue: getDatePart(currentValue || ""), + currentTime: getTimePart(currentValue || ""), + onSelect: (dateValue, timeValue) => { + const value = dateValue + ? timeValue + ? `${dateValue}T${timeValue}` + : dateValue + : undefined; + void updateTasksProperty(context, tasks, dateType, value); + }, + dateRole: dateType, + plugin: context.plugin, + app: context.app, + }).showAtElement(anchor); + return; + } + case "edit-priority": { + const tasks = await getActionTargets(context); + const anchor = context.getAnchor(); + if (tasks.length === 0 || !anchor) return; + + new PriorityContextMenu({ + currentValue: tasks[0].priority, + onSelect: (value) => void updateTasksProperty(context, tasks, "priority", value), + plugin: context.plugin, + }).showAtElement(anchor); + return; + } + case "mark-complete": { + const tasks = await getActionTargets(context); + for (const task of tasks) { + if (task.recurrence) { + await context.plugin.toggleRecurringTaskComplete( + task, + getTaskActionDate(task, context.getCurrentTargetDate()) + ); + continue; + } + + const completedStatus = context.plugin.statusManager.getCompletedStatuses()[0] || "done"; + if (!context.plugin.statusManager.isCompletedStatus(task.status)) { + await context.plugin.updateTaskProperty(task, "status", completedStatus); + } + } + return; + } + case "edit-status": { + const tasks = await getActionTargets(context); + const anchor = context.getAnchor(); + if (tasks.length === 0 || !anchor) return; + + new StatusContextMenu({ + currentValue: tasks[0].status, + onSelect: (value) => void updateTasksStatus(context, tasks, value), + plugin: context.plugin, + }).showAtElement(anchor); + return; + } + case "edit-recurrence": { + const tasks = await getActionTargets(context); + const anchor = context.getAnchor(); + if (tasks.length === 0 || !anchor) return; + + new RecurrenceContextMenu({ + currentValue: typeof tasks[0].recurrence === "string" ? tasks[0].recurrence : undefined, + currentAnchor: tasks[0].recurrence_anchor || "scheduled", + scheduledDate: tasks[0].scheduled, + onSelect: (value, recurrenceAnchor) => { + void (async () => { + await updateTasksProperty(context, tasks, "recurrence", value || undefined); + if (recurrenceAnchor !== undefined) { + await updateTasksProperty(context, tasks, "recurrence_anchor", recurrenceAnchor); + } + })(); + }, + app: context.plugin.app, + plugin: context.plugin, + }).showAtElement(anchor); + return; + } + case "add-tags": { + const tasks = await getActionTargets(context); + if (tasks.length === 0) return; + + const input = await showTextInputModal(context.plugin.app, { + title: context.plugin.i18n.translate("contextMenus.task.addTag"), + placeholder: context.plugin.i18n.translate("contextMenus.task.tagPlaceholder"), + confirmText: context.plugin.i18n.translate("common.confirm"), + cancelText: context.plugin.i18n.translate("common.cancel"), + onInputReady: (inputEl) => { + new TagSuggest(context.plugin.app, inputEl, context.plugin); + }, + }); + const tags = parseTaskTagInput(input); + if (tags.length === 0) return; + + for (const task of tasks) { + await context.plugin.updateTaskProperty(task, "tags", addTagsToList(task.tags, tags)); + } + return; + } + case "add-context": { + const tasks = await getActionTargets(context); + if (tasks.length === 0) return; + + const contextValue = await showTextInputModal(context.plugin.app, { + title: context.plugin.i18n.translate("contextMenus.task.organization.addContext"), + placeholder: context.plugin.i18n.translate( + "contextMenus.task.organization.contextPlaceholder" + ), + confirmText: context.plugin.i18n.translate("common.confirm"), + cancelText: context.plugin.i18n.translate("common.cancel"), + }); + if (!contextValue?.trim()) return; + + for (const task of tasks) { + await context.plugin.updateTaskProperty( + task, + "contexts", + addContextToList(task.contexts, contextValue) + ); + } + return; + } + case "add-project": { + const paths = context.getTargetPaths(); + if (paths.length === 0) return; + + const tasks = ( + await Promise.all(paths.map((path) => context.plugin.cacheManager.getTaskInfo(path))) + ).filter((task): task is TaskInfo => task !== null); + new ProjectSelectModal( + context.plugin.app, + context.plugin, + (projectFile) => { + if (!(projectFile instanceof TFile)) return; + void (async () => { + for (const path of paths) { + const task = await context.plugin.cacheManager.getTaskInfo(path); + if (task) await addTaskToProject(context.plugin, task, projectFile); + } + })(); + }, + { + selectedProjects: getTaskProjectFiles(context.plugin, tasks), + onRemove: async (projectFile) => { + for (const path of paths) { + const task = await context.plugin.cacheManager.getTaskInfo(path); + if (task) { + await removeTaskFromProject(context.plugin, task, projectFile); + } + } + }, + } + ).open(); + return; + } + case "delete-tasks": { + const tasks = await getActionTargets(context); + if (tasks.length === 0) return; + + const confirmed = await showConfirmationModal(context.plugin.app, { + title: tasks.length === 1 ? "Delete task" : "Delete tasks", + message: + tasks.length === 1 + ? `Are you sure you want to delete "${tasks[0].title}"? This action cannot be undone.` + : `Are you sure you want to delete ${tasks.length} tasks? This action cannot be undone.`, + confirmText: "Delete", + cancelText: context.plugin.i18n.translate("common.cancel"), + isDestructive: true, + }); + if (!confirmed) return; + + for (const task of tasks) { + await context.plugin.taskService.deleteTask(task); + } + context.taskSelectionService?.clearSelection(); + return; + } + default: { + // Runtime user-field actions share the same visible-target selection + // path as built-in edits, so filtered-out selected tasks are excluded. + if (!action.startsWith("edit-user-field:")) return; + const fieldId = action.slice("edit-user-field:".length); + const field = context.plugin.settings.userFields?.find( + (candidate) => candidate.id === fieldId + ); + if (!field) return; + const tasks = await getActionTargets(context); + if (tasks.length === 0) return; + + new UserFieldEditModal(context.plugin.app, context.plugin, { + field, + tasks, + onApply: async (value, listChange) => { + for (const task of tasks) { + let taskValue = value; + if (field.type === "list") { + const customProperties = task.customProperties ?? {}; + const current = Array.isArray(customProperties[field.key]) + ? (customProperties[field.key] as unknown[]).map(String) + : []; + const withoutRemoved = current.filter( + (candidate) => !listChange?.removed?.includes(candidate) + ); + taskValue = listChange?.added + ? [...withoutRemoved, listChange.added].filter( + (candidate, index, list) => list.indexOf(candidate) === index + ) + : withoutRemoved; + } + await context.plugin.updateTaskProperty( + task, + field.key as keyof TaskInfo, + taskValue, + { silent: true } + ); + } + }, + onClose: () => { + context.onOverlayClosed?.(); + }, + }).open(); + } + } +} diff --git a/src/bases/embeddedBasesKeyboard.ts b/src/bases/embeddedBasesKeyboard.ts new file mode 100644 index 000000000..720e94f74 --- /dev/null +++ b/src/bases/embeddedBasesKeyboard.ts @@ -0,0 +1,26 @@ +const RELATIONSHIPS_WIDGET_SELECTOR = ".tasknotes-relationships-widget"; +const MARKDOWN_SOURCE_VIEW_SELECTOR = ".markdown-source-view"; +const EDITOR_FOCUS_SELECTOR = '.cm-content[contenteditable="true"], .cm-editor .cm-content'; + +/** + * Reports whether mouse hover may claim task-card focus for a Bases view. + * + * Standalone and reading-mode views may claim focus. An auto-injected widget in + * Live Preview must yield while its containing CodeMirror editor owns the edit + * cursor; explicit focus events inside the widget are handled separately. + */ +export function canHoverClaimBasesTaskFocus(root: HTMLElement): boolean { + const widget = root.closest(RELATIONSHIPS_WIDGET_SELECTOR); + if (!widget) return true; + + const sourceView = widget.closest(MARKDOWN_SOURCE_VIEW_SELECTOR); + if (!sourceView) return true; + + const activeElement = root.ownerDocument.activeElement; + return !( + activeElement instanceof Element && + sourceView.contains(activeElement) && + Boolean(activeElement.closest(EDITOR_FOCUS_SELECTOR)) && + !widget.contains(activeElement) + ); +} diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 93044f490..b127d3a42 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -514,9 +514,9 @@ export const en: TranslationTree = { integrations: "Integrations", }, keyboardShortcuts: { - header: "Task-list keyboard shortcuts", + header: "Task card keyboard shortcuts", description: - "Configure view-local shortcuts. They only run while a task card has keyboard focus.", + "Configure shortcuts for task cards in the task list, Kanban, and agenda views, and for the task currently hovered in calendar grid views. They only run while a task has keyboard focus or is hovered.", actionDescription: "Click a shortcut to remove it, or add another binding.", add: "Add shortcut", remove: "Remove shortcut", @@ -527,8 +527,8 @@ export const en: TranslationTree = { confirm: "Add", resetAction: "Reset this action", resetAll: "Reset all shortcuts", - resetAllDescription: "Restore every task-list shortcut to its default binding.", - conflict: "Conflict: {shortcuts} is also assigned to another task-list action.", + resetAllDescription: "Restore every task card shortcut to its default binding.", + conflict: "Conflict: {shortcuts} is also assigned to another task card action.", duplicateTitle: "Shortcut already assigned", duplicateMessage: "{shortcut} is assigned to {actions}. Replace the existing assignment?", diff --git a/tasknotes-e2e-vault/.obsidian/plugins/tasknotes-views/manifest.json b/tasknotes-e2e-vault/.obsidian/plugins/tasknotes-views/manifest.json new file mode 100644 index 000000000..676b57cf0 --- /dev/null +++ b/tasknotes-e2e-vault/.obsidian/plugins/tasknotes-views/manifest.json @@ -0,0 +1,11 @@ +{ + "id": "tasknotes-views", + "name": "TaskNotes Views", + "version": "1.0.0", + "minAppVersion": "1.13.0", + "description": "Adds project progress views to TaskNotes project notes.", + "author": "Dave Smith", + "authorUrl": "https://github.com/thisisthedave", + "fundingUrl": "https://buymeacoffee.com/thisisthedave", + "isDesktopOnly": false +} \ No newline at end of file diff --git a/tests/unit/bases/BasesTaskCardKeyboardController.test.ts b/tests/unit/bases/BasesTaskCardKeyboardController.test.ts new file mode 100644 index 000000000..380999320 --- /dev/null +++ b/tests/unit/bases/BasesTaskCardKeyboardController.test.ts @@ -0,0 +1,527 @@ +import { Scope } from "obsidian"; +import { BasesTaskCardKeyboardController } from "../../../src/bases/BasesTaskCardKeyboardController"; +import { normalizeTaskListShortcutMap } from "../../../src/bases/taskListKeyboardActions"; +import { executeBasesTaskCardAction } from "../../../src/bases/basesTaskCardActions"; + +jest.mock( + "tasknotes-nlp-core", + () => ({ + NaturalLanguageParserCore: class {}, + }), + { virtual: true } +); +jest.mock("../../../src/bases/basesTaskCardActions", () => ({ + executeBasesTaskCardAction: jest.fn().mockResolvedValue(undefined), +})); + +const mockedExecuteAction = executeBasesTaskCardAction as jest.MockedFunction< + typeof executeBasesTaskCardAction +>; + +const proto = BasesTaskCardKeyboardController.prototype as any; + +function createMockPlugin(activeLeafChangeHandlers: Array<(leaf: unknown) => void> = []) { + return { + app: { + workspace: { + on: (event: string, cb: (leaf: unknown) => void) => { + if (event === "active-leaf-change") activeLeafChangeHandlers.push(cb); + return {}; + }, + getMostRecentLeaf: () => null, + }, + keymap: { pushScope: jest.fn(), popScope: jest.fn() }, + scope: {}, + }, + settings: { taskListShortcuts: {}, taskListUserFieldShortcuts: {} }, + }; +} + +function createMockComponent() { + return { + registerDomEvent: ( + el: HTMLElement | Document, + type: string, + cb: EventListener, + capture?: boolean + ) => { + el.addEventListener(type, cb, capture); + }, + registerEvent: () => {}, + register: () => {}, + }; +} + +describe("BasesTaskCardKeyboardController construction", () => { + it("restores remembered card focus when its workspace leaf is activated", () => { + jest.useFakeTimers(); + const activeLeafChangeHandlers: Array<(leaf: unknown) => void> = []; + const plugin = createMockPlugin(activeLeafChangeHandlers); + const component = createMockComponent(); + + const leafContainer = document.createElement("div"); + const containerEl = document.createElement("div"); + const rootElement = document.createElement("div"); + leafContainer.append(containerEl); + containerEl.append(rootElement); + document.body.appendChild(leafContainer); + + const controller = new BasesTaskCardKeyboardController( + component as any, + rootElement, + containerEl, + plugin as any, + { + isActionSupported: () => false, + buildViewContext: () => { + throw new Error("not used in this test"); + }, + } + ); + const restoreFocusedElement = jest.spyOn( + controller.focusController, + "restoreFocusedElement" + ); + + activeLeafChangeHandlers[0]?.({ view: { containerEl: leafContainer } }); + jest.runAllTimers(); + + expect(restoreFocusedElement).toHaveBeenCalled(); + jest.useRealTimers(); + }); + + it("gates Shift+Arrow range-select behind input ownership, ignoring plain arrow keys", () => { + const plugin = createMockPlugin([]); + const component = createMockComponent(); + const containerEl = document.createElement("div"); + const rootElement = document.createElement("div"); + const card = document.createElement("div"); + card.className = "task-card"; + card.dataset.taskPath = "a.md"; + rootElement.append(card); + containerEl.append(rootElement); + document.body.appendChild(containerEl); + + const controller = new BasesTaskCardKeyboardController( + component as any, + rootElement, + containerEl, + plugin as any, + { + isActionSupported: () => false, + buildViewContext: () => { + throw new Error("not used in this test"); + }, + } + ); + + const shiftArrow = new KeyboardEvent("keydown", { + key: "ArrowDown", + shiftKey: true, + bubbles: true, + }); + Object.defineProperty(shiftArrow, "target", { value: card }); + expect(controller.canHandleSelectionKeyDown(shiftArrow)).toBe(true); + + const plainArrow = new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }); + Object.defineProperty(plainArrow, "target", { value: card }); + expect(controller.canHandleSelectionKeyDown(plainArrow)).toBe(false); + }); + + it("registers keydown routing on the root during the capture phase", () => { + const plugin = createMockPlugin([]); + const containerEl = document.createElement("div"); + const rootElement = document.createElement("div"); + containerEl.append(rootElement); + document.body.appendChild(containerEl); + const registerDomEvent = jest.fn((el: HTMLElement | Document, type: string, cb: EventListener, capture?: boolean) => { + el.addEventListener(type, cb, capture); + }); + const component = { registerDomEvent, registerEvent: () => {}, register: () => {} }; + + new BasesTaskCardKeyboardController(component as any, rootElement, containerEl, plugin as any, { + isActionSupported: () => false, + buildViewContext: () => ({} as any), + }); + + expect(registerDomEvent).toHaveBeenCalledWith(rootElement, "keydown", expect.any(Function), true); + expect(registerDomEvent).toHaveBeenCalledWith(rootElement, "mousemove", expect.any(Function)); + }); +}); + +describe("BasesTaskCardKeyboardController.handleActionKeyDown", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("claims a recognized shortcut when the task card owns focus", () => { + const mockThis = { + plugin: { settings: {} }, + options: { isActionSupported: () => true, buildViewContext: () => ({} as any) }, + focusController: { getFocusedPathForEvent: jest.fn(() => "focused.md") }, + inputOwnershipController: { noteOverlayOpening: jest.fn() }, + buildActionContext: () => ({} as any), + }; + const event = new KeyboardEvent("keydown", { key: "d", cancelable: true }); + const stopPropagation = jest.spyOn(event, "stopPropagation"); + + proto.handleActionKeyDown.call(mockThis, event, false); + + expect(event.defaultPrevented).toBe(true); + expect(stopPropagation).toHaveBeenCalled(); + expect(mockedExecuteAction).toHaveBeenCalledWith("edit-due", "focused.md", expect.anything()); + }); + + it("routes configured Gmail-style navigation through the focus controller", () => { + const moveFocus = jest.fn(); + const event = new KeyboardEvent("keydown", { key: "j", cancelable: true }); + const mockThis = { + plugin: { + settings: { + taskListShortcuts: { + "navigate-next": ["j"], + "navigate-previous": ["k"], + }, + }, + }, + options: { isActionSupported: () => true, buildViewContext: () => ({} as any) }, + focusController: { + getFocusedPathForEvent: jest.fn(() => "focused.md"), + moveFocus, + }, + }; + + proto.handleActionKeyDown.call(mockThis, event, false); + + expect(moveFocus).toHaveBeenCalledWith(event, "next"); + expect(mockedExecuteAction).not.toHaveBeenCalled(); + }); + + it("records an overlay before opening a user-field editor so focus can be restored", () => { + const noteOverlayOpening = jest.fn(); + const mockThis = { + plugin: { + settings: { + taskListShortcuts: normalizeTaskListShortcutMap({}), + taskListUserFieldShortcuts: { effort: ["q"] }, + }, + }, + options: { isActionSupported: () => true, buildViewContext: () => ({} as any) }, + focusController: { getFocusedPathForEvent: jest.fn(() => "focused.md") }, + inputOwnershipController: { noteOverlayOpening }, + buildActionContext: () => ({} as any), + }; + const event = new KeyboardEvent("keydown", { key: "q", cancelable: true }); + + proto.handleActionKeyDown.call(mockThis, event, false); + + expect(noteOverlayOpening).toHaveBeenCalledTimes(1); + expect(mockedExecuteAction).toHaveBeenCalledWith( + "edit-user-field:effort", + "focused.md", + expect.anything() + ); + }); + + it.each([ + ["g", "jump-first", "first"], + ["G", "jump-last", "last"], + ] as const)( + "routes configured boundary key %s through the focus controller", + (key, action, direction) => { + const moveFocus = jest.fn(); + const event = new KeyboardEvent("keydown", { key, cancelable: true }); + const mockThis = { + plugin: { + settings: { + taskListShortcuts: { + [action]: [key.toLowerCase()], + }, + }, + }, + options: { isActionSupported: () => true, buildViewContext: () => ({} as any) }, + focusController: { + getFocusedPathForEvent: jest.fn(() => "focused.md"), + moveFocus, + }, + }; + + proto.handleActionKeyDown.call(mockThis, event, false); + + expect(moveFocus).toHaveBeenCalledWith(event, direction); + } + ); + + it.each([ + ["Enter", { shiftKey: true }, "open-task-notes"], + ["s", { shiftKey: true }, "edit-scheduled"], + ["#", { shiftKey: true }, "add-tags"], + ["@", { shiftKey: true }, "add-context"], + ["+", { shiftKey: true }, "add-project"], + ["Delete", { ctrlKey: true }, "delete-tasks"], + ["Delete", { metaKey: true }, "delete-tasks"], + ] as const)("allows the modifier chord %s after action recognition", (keyValue, modifiers, action) => { + const getFocusedPathForEvent = jest.fn(() => "focused.md"); + const mockThis = { + plugin: { settings: {} }, + options: { isActionSupported: () => true, buildViewContext: () => ({} as any) }, + focusController: { getFocusedPathForEvent }, + inputOwnershipController: { noteOverlayOpening: jest.fn() }, + buildActionContext: () => ({} as any), + }; + const event = new KeyboardEvent("keydown", { + key: keyValue, + cancelable: true, + ...modifiers, + }); + + proto.handleActionKeyDown.call(mockThis, event, false); + + expect(getFocusedPathForEvent).toHaveBeenCalledWith(event, true, false); + expect(event.defaultPrevented).toBe(true); + expect(mockedExecuteAction).toHaveBeenCalledWith(action, "focused.md", expect.anything()); + }); + + it("does not claim shortcuts when an interactive control owns focus", () => { + const mockThis = { + plugin: { settings: {} }, + options: { isActionSupported: () => true, buildViewContext: () => ({} as any) }, + focusController: { getFocusedPathForEvent: jest.fn(() => null) }, + }; + const event = new KeyboardEvent("keydown", { key: "d", cancelable: true }); + + proto.handleActionKeyDown.call(mockThis, event, false); + + expect(event.defaultPrevented).toBe(false); + expect(mockedExecuteAction).not.toHaveBeenCalled(); + }); + + it("routes a shortcut from the active view shell through remembered task focus", () => { + const getFocusedPathForEvent = jest.fn(() => "remembered.md"); + const event = new KeyboardEvent("keydown", { key: "d", cancelable: true }); + const mockThis = { + plugin: { settings: {} }, + options: { isActionSupported: () => true, buildViewContext: () => ({} as any) }, + focusController: { getFocusedPathForEvent }, + inputOwnershipController: { noteOverlayOpening: jest.fn() }, + buildActionContext: () => ({} as any), + }; + + proto.handleActionKeyDown.call(mockThis, event, true); + + expect(getFocusedPathForEvent).toHaveBeenCalledWith(event, true, true); + expect(mockedExecuteAction).toHaveBeenCalledWith("edit-due", "remembered.md", expect.anything()); + }); + + it("routes a prevented modifier chord from the active view shell", () => { + const event = new KeyboardEvent("keydown", { key: "a", ctrlKey: true, cancelable: true }); + event.preventDefault(); + const mockThis = { + plugin: { + settings: { + taskListShortcuts: { "select-all": ["mod+a"] }, + }, + }, + options: { isActionSupported: () => true, buildViewContext: () => ({} as any) }, + focusController: { getFocusedPathForEvent: jest.fn(() => null) }, + inputOwnershipController: { noteOverlayOpening: jest.fn() }, + buildActionContext: () => ({} as any), + }; + + proto.handleActionKeyDown.call(mockThis, event, true); + + expect(mockedExecuteAction).toHaveBeenCalledWith("select-all", null, expect.anything()); + }); + + it("leaves an unsupported action untouched so the key can reach Obsidian's own commands", () => { + const mockThis = { + plugin: { settings: {} }, + options: { isActionSupported: () => false, buildViewContext: () => ({} as any) }, + focusController: { getFocusedPathForEvent: jest.fn(() => "focused.md") }, + }; + const event = new KeyboardEvent("keydown", { key: "d", cancelable: true }); + + const handled = proto.handleActionKeyDown.call(mockThis, event, false); + + expect(handled).toBe(false); + expect(event.defaultPrevented).toBe(false); + expect(mockedExecuteAction).not.toHaveBeenCalled(); + }); +}); + +describe("BasesTaskCardKeyboardController.handleKeyDown / handleRootKeyDown", () => { + it("routes a body-targeted shortcut through remembered task focus after a rerender", () => { + const canHandleListKeyDown = jest.fn(() => true); + const getFocusedPathForEvent = jest.fn(() => "remembered.md"); + const event = new KeyboardEvent("keydown", { key: " ", cancelable: true }); + Object.defineProperty(event, "target", { value: document.body }); + const mockThis = { + inputOwnershipController: { canHandleListKeyDown }, + options: { isActionSupported: () => true, buildViewContext: () => ({} as any) }, + plugin: { settings: {} }, + focusController: { getFocusedPathForEvent }, + buildActionContext: () => ({} as any), + handleActionKeyDown: proto.handleActionKeyDown, + }; + + proto.handleKeyDown.call(mockThis, event, true); + + expect(canHandleListKeyDown).toHaveBeenCalledWith(event, true); + expect(getFocusedPathForEvent).toHaveBeenCalledWith(event, true, true); + expect(mockedExecuteAction).toHaveBeenCalledWith("toggle-select", "remembered.md", expect.anything()); + }); + + it("does not discard a prevented chord before shell shortcut routing", () => { + const root = document.createElement("div"); + const cardAreaElement = document.createElement("div"); + root.appendChild(cardAreaElement); + const event = new KeyboardEvent("keydown", { key: "c", metaKey: true, cancelable: true }); + Object.defineProperty(event, "target", { value: root }); + event.preventDefault(); + const handleKeyDown = jest.fn(); + const mockThis = { cardAreaElement, handleKeyDown }; + + proto.handleRootKeyDown.call(mockThis, event); + + expect(handleKeyDown).toHaveBeenCalledWith(event, true); + }); + + it("routes card chords from the root without using remembered-focus fallback", () => { + const root = document.createElement("div"); + const cardAreaElement = document.createElement("div"); + const card = document.createElement("div"); + cardAreaElement.appendChild(card); + root.appendChild(cardAreaElement); + const event = new KeyboardEvent("keydown", { key: "b", ctrlKey: true }); + Object.defineProperty(event, "target", { value: card }); + const handleKeyDown = jest.fn(); + const mockThis = { cardAreaElement, handleKeyDown }; + + proto.handleRootKeyDown.call(mockThis, event); + + expect(handleKeyDown).toHaveBeenCalledWith(event, false); + }); +}); + +describe("BasesTaskCardKeyboardController overlay + Scope lifecycle", () => { + it("defers task-card focus until after Obsidian modal cleanup", () => { + jest.useFakeTimers(); + try { + const restoreFocusedElement = jest.fn(); + const resumeAfterOverlayClose = jest.fn(); + const syncShortcutScopeForFocusTarget = jest.fn(); + const mockThis = { + focusController: { restoreFocusedElement }, + inputOwnershipController: { resumeAfterOverlayClose }, + containerEl: document.createElement("div"), + syncShortcutScopeForFocusTarget, + leafActive: true, + activateShortcutScope: jest.fn(), + }; + + proto.restoreAfterOverlayClose.call(mockThis); + + expect(restoreFocusedElement).not.toHaveBeenCalled(); + jest.runAllTimers(); + expect(restoreFocusedElement).toHaveBeenCalledTimes(1); + expect(resumeAfterOverlayClose).toHaveBeenCalledTimes(1); + expect(syncShortcutScopeForFocusTarget).toHaveBeenCalledWith( + mockThis.containerEl.ownerDocument.activeElement + ); + expect(mockThis.activateShortcutScope).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + } + }); + + it("keeps the active view shortcut scope when focus falls back to the body", () => { + const canOwnKeyboardTarget = jest.fn(() => true); + const activateShortcutScope = jest.fn(); + const deactivateShortcutScope = jest.fn(); + const mockThis = { + leafActive: true, + inputOwnershipController: { canOwnKeyboardTarget }, + activateShortcutScope, + deactivateShortcutScope, + }; + + proto.syncShortcutScopeForFocusTarget.call(mockThis, document.body); + + expect(canOwnKeyboardTarget).toHaveBeenCalledWith(document.body, true); + expect(activateShortcutScope).toHaveBeenCalled(); + expect(deactivateShortcutScope).not.toHaveBeenCalled(); + }); + + it("pushes an Obsidian child scope for configured view-local chords", () => { + const registerSpy = jest.spyOn(Scope.prototype, "register"); + const pushScope = jest.fn(); + const popScope = jest.fn(); + const handleKeyDown = jest.fn(() => true); + const mockThis = { + shortcutScope: null as Scope | null, + leafActive: true, + plugin: { + settings: { + taskListShortcuts: normalizeTaskListShortcutMap({ + "select-all": ["Ctrl+B"], + "copy-task-titles": ["Ctrl+D"], + }), + }, + app: { + scope: {}, + keymap: { pushScope, popScope }, + }, + }, + handleKeyDown, + }; + + proto.activateShortcutScope.call(mockThis); + + expect(pushScope).toHaveBeenCalledTimes(1); + const scope = mockThis.shortcutScope; + expect(registerSpy).toHaveBeenCalledWith(["Mod"], "b", expect.any(Function)); + expect(registerSpy).toHaveBeenCalledWith(["Mod"], "d", expect.any(Function)); + + const ctrlBHandler = registerSpy.mock.calls.find( + ([modifiers, key]) => modifiers[0] === "Mod" && key === "b" + )?.[2] as (event: KeyboardEvent) => unknown; + const event = new KeyboardEvent("keydown", { key: "b", ctrlKey: true }); + expect(ctrlBHandler(event)).toBe(false); + expect(handleKeyDown).toHaveBeenCalledWith(event, true); + + proto.deactivateShortcutScope.call(mockThis); + expect(popScope).toHaveBeenCalledTimes(1); + expect(popScope.mock.calls[0][0]).toBe(scope); + expect(mockThis.shortcutScope).toBeNull(); + registerSpy.mockRestore(); + }); + + it("does not claim a scoped chord after the task-list leaf is deactivated", () => { + const registerSpy = jest.spyOn(Scope.prototype, "register"); + const mockThis = { + shortcutScope: null as Scope | null, + leafActive: true, + plugin: { + settings: { + taskListShortcuts: normalizeTaskListShortcutMap({ + "select-all": ["Ctrl+B"], + }), + }, + app: { + scope: {}, + keymap: { pushScope: jest.fn(), popScope: jest.fn() }, + }, + }, + handleKeyDown: jest.fn(() => true), + }; + proto.activateShortcutScope.call(mockThis); + const handler = registerSpy.mock.calls.find( + ([modifiers, key]) => modifiers[0] === "Mod" && key === "b" + )?.[2] as (event: KeyboardEvent) => unknown; + mockThis.leafActive = false; + + expect(handler(new KeyboardEvent("keydown", { key: "b", ctrlKey: true }))).toBeUndefined(); + expect(mockThis.handleKeyDown).not.toHaveBeenCalled(); + registerSpy.mockRestore(); + }); +}); diff --git a/tests/unit/bases/CalendarView.keyboardActions.test.ts b/tests/unit/bases/CalendarView.keyboardActions.test.ts new file mode 100644 index 000000000..fa17802b5 --- /dev/null +++ b/tests/unit/bases/CalendarView.keyboardActions.test.ts @@ -0,0 +1,163 @@ +import { CalendarView } from "../../../src/bases/CalendarView"; +import { executeBasesTaskCardAction } from "../../../src/bases/basesTaskCardActions"; + +jest.mock( + "tasknotes-nlp-core", + () => ({ + NaturalLanguageParserCore: class {}, + }), + { virtual: true } +); +jest.mock("../../../src/bases/basesTaskCardActions", () => ({ + executeBasesTaskCardAction: jest.fn().mockResolvedValue(undefined), +})); + +const mockedExecuteAction = executeBasesTaskCardAction as jest.MockedFunction< + typeof executeBasesTaskCardAction +>; + +const proto = CalendarView.prototype as any; + +describe("CalendarView.getTaskCardActionsConfig", () => { + it("returns null in grid modes, which have no card-focus model", () => { + const mockThis = { isCalendarListMode: () => false }; + + expect(proto.getTaskCardActionsConfig.call(mockThis)).toBeNull(); + }); + + it("wires the shared keyboard controller to Agenda/list-mode state", () => { + const showBatchContextMenu = jest.fn(); + const createFileForView = jest.fn(); + const calendarEl = document.createElement("div"); + const rootElement = document.createElement("div"); + const mockThis = { + isCalendarListMode: () => true, + plugin: { taskSelectionService: {} }, + app: undefined, + calendarEl, + rootElement, + getVisibleTaskPaths: jest.fn(() => ["a.md"]), + showBatchContextMenu, + createFileForView, + }; + + const config = proto.getTaskCardActionsConfig.call(mockThis); + expect(config).not.toBeNull(); + expect(config.cardAreaElement).toBe(calendarEl); + expect(config.isActionSupported("delete-tasks")).toBe(true); + + const viewContext = config.buildViewContext(); + expect(viewContext.plugin).toBe(mockThis.plugin); + expect(viewContext.taskSelectionService).toBe(mockThis.plugin.taskSelectionService); + expect(viewContext.rootElement).toBe(rootElement); + expect(viewContext.fallbackAnchor).toBe(calendarEl); + expect(viewContext.getVisibleTaskPaths()).toEqual(["a.md"]); + expect(viewContext.isPathVisible("a.md")).toBe(true); + expect(viewContext.isPathVisible("b.md")).toBe(false); + + viewContext.showBatchContextMenu({} as any); + expect(showBatchContextMenu).toHaveBeenCalled(); + viewContext.createFileForView(); + expect(createFileForView).toHaveBeenCalled(); + }); +}); + +describe("CalendarView grid-mode hover hotkeys", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + function baseMockThis(overrides: Record = {}) { + return { + isCalendarListMode: () => false, + plugin: { settings: {} }, + taskCardKeyboardController: { canHandleKeyDown: jest.fn(() => true) }, + hoveredGridTaskPath: "hovered.md", + hoveredGridTaskElement: document.createElement("div"), + buildGridHoverActionContext: () => ({} as any), + ...overrides, + }; + } + + it("dispatches a single-target hotkey against the hovered task", () => { + const mockThis = baseMockThis(); + const event = new KeyboardEvent("keydown", { key: "d", cancelable: true }); + + proto.handleGridHoverActionKeyDown.call(mockThis, event); + + expect(event.defaultPrevented).toBe(true); + expect(mockedExecuteAction).toHaveBeenCalledWith("edit-due", "hovered.md", expect.anything()); + }); + + it("ignores navigation/selection actions that have no meaning without a card model", () => { + const mockThis = baseMockThis(); + const event = new KeyboardEvent("keydown", { key: "j", cancelable: true }); + + proto.handleGridHoverActionKeyDown.call(mockThis, event); + + expect(event.defaultPrevented).toBe(false); + expect(mockedExecuteAction).not.toHaveBeenCalled(); + }); + + it("does nothing when no task is currently hovered", () => { + const mockThis = baseMockThis({ hoveredGridTaskPath: null }); + const event = new KeyboardEvent("keydown", { key: "d", cancelable: true }); + + proto.handleGridHoverActionKeyDown.call(mockThis, event); + + expect(mockedExecuteAction).not.toHaveBeenCalled(); + }); + + it("defers to the shared task-card keyboard controller in list mode", () => { + const mockThis = baseMockThis({ isCalendarListMode: () => true }); + const event = new KeyboardEvent("keydown", { key: "d", cancelable: true }); + + proto.handleGridHoverActionKeyDown.call(mockThis, event); + + expect(mockedExecuteAction).not.toHaveBeenCalled(); + }); + + it("respects the shared editable-target/open-overlay guard", () => { + const mockThis = baseMockThis({ + taskCardKeyboardController: { canHandleKeyDown: jest.fn(() => false) }, + }); + const event = new KeyboardEvent("keydown", { key: "d", cancelable: true }); + + proto.handleGridHoverActionKeyDown.call(mockThis, event); + + expect(mockedExecuteAction).not.toHaveBeenCalled(); + }); + + it("tracks the hovered task element and path as the mouse moves over the grid", () => { + const root = document.createElement("div"); + const fcEvent = document.createElement("div"); + fcEvent.className = "fc-task-event"; + fcEvent.dataset.taskPath = "grid-task.md"; + root.appendChild(fcEvent); + document.body.appendChild(root); + + const mockThis: any = { + hoveredGridTaskPath: null, + hoveredGridTaskElement: null, + registerDomEvent: ( + el: HTMLElement, + type: string, + cb: EventListener, + capture?: boolean + ) => el.addEventListener(type, cb, capture), + }; + + proto.registerGridHoverActionListeners.call(mockThis, root); + + const overEvent = new MouseEvent("mousemove", { bubbles: true }); + Object.defineProperty(overEvent, "target", { value: fcEvent }); + root.dispatchEvent(overEvent); + expect(mockThis.hoveredGridTaskPath).toBe("grid-task.md"); + expect(mockThis.hoveredGridTaskElement).toBe(fcEvent); + + const offEvent = new MouseEvent("mousemove", { bubbles: true }); + Object.defineProperty(offEvent, "target", { value: root }); + root.dispatchEvent(offEvent); + expect(mockThis.hoveredGridTaskPath).toBeNull(); + }); +}); diff --git a/tests/unit/bases/KanbanView.keyboardActions.test.ts b/tests/unit/bases/KanbanView.keyboardActions.test.ts new file mode 100644 index 000000000..29d163064 --- /dev/null +++ b/tests/unit/bases/KanbanView.keyboardActions.test.ts @@ -0,0 +1,57 @@ +import { KanbanView } from "../../../src/bases/KanbanView"; + +jest.mock( + "tasknotes-nlp-core", + () => ({ + NaturalLanguageParserCore: class {}, + }), + { virtual: true } +); + +describe("KanbanView keyboard actions", () => { + it("wires the shared keyboard controller to Kanban board state and callbacks", () => { + const showBatchContextMenu = jest.fn(); + const createFileForView = jest.fn(); + const boardEl = document.createElement("div"); + const rootElement = document.createElement("div"); + const mockThis = { + plugin: { taskSelectionService: {} }, + app: undefined, + boardEl, + rootElement, + currentVisibleTaskPaths: new Set(["a.md"]), + searchBox: null, + enableSearch: false, + setupSearch: jest.fn(function (this: { searchBox: { focus: () => void } | null }) { + this.searchBox = { focus: jest.fn() }; + }), + showBatchContextMenu, + createFileForView, + }; + + const config = (KanbanView.prototype as any).getTaskCardActionsConfig.call(mockThis); + expect(config.isActionSupported("delete-tasks")).toBe(true); + expect(config.cardAreaElement).toBe(boardEl); + + const viewContext = config.buildViewContext(); + expect(viewContext.plugin).toBe(mockThis.plugin); + expect(viewContext.taskSelectionService).toBe(mockThis.plugin.taskSelectionService); + expect(viewContext.rootElement).toBe(rootElement); + expect(viewContext.fallbackAnchor).toBe(boardEl); + expect(viewContext.isPathVisible("a.md")).toBe(true); + expect(viewContext.isPathVisible("b.md")).toBe(false); + expect(viewContext.getVisibleTaskPaths()).toEqual(["a.md"]); + + const targetDate = viewContext.getCurrentTargetDate(); + expect(targetDate.getUTCHours()).toBe(0); + + viewContext.showBatchContextMenu({} as any); + expect(showBatchContextMenu).toHaveBeenCalled(); + viewContext.createFileForView(); + expect(createFileForView).toHaveBeenCalled(); + + viewContext.focusSearch?.(); + expect(mockThis.setupSearch).toHaveBeenCalledWith(rootElement); + expect(mockThis.enableSearch).toBe(true); + }); +}); diff --git a/tests/unit/bases/TaskListFocusController.test.ts b/tests/unit/bases/TaskListFocusController.test.ts index e039be853..ae9f3697f 100644 --- a/tests/unit/bases/TaskListFocusController.test.ts +++ b/tests/unit/bases/TaskListFocusController.test.ts @@ -31,6 +31,29 @@ describe("TaskListFocusController", () => { HTMLElement.prototype.scrollIntoView = jest.fn(); }); + it("does not move task focus from hover when the hover guard denies ownership", () => { + const guardedRoot = document.createElement("div"); + const first = createCard("Tasks/First.md"); + const second = createCard("Tasks/Second.md"); + guardedRoot.append(first, second); + document.body.appendChild(guardedRoot); + const guardedController = new TaskListFocusController( + guardedRoot, + false, + () => false + ); + + first.focus(); + const focusEvent = new FocusEvent("focusin", { bubbles: true }); + Object.defineProperty(focusEvent, "target", { value: first }); + guardedController.handleFocusIn(focusEvent); + const hover = new MouseEvent("mousemove", { bubbles: true }); + Object.defineProperty(hover, "target", { value: second }); + + expect(guardedController.handleMouseMove(hover)).toBe(false); + expect(guardedController.getFocusedIdentity()?.path).toBe("Tasks/First.md"); + }); + afterEach(() => { document.body.innerHTML = ""; jest.restoreAllMocks(); diff --git a/tests/unit/bases/TaskListView.keyboardActions.test.ts b/tests/unit/bases/TaskListView.keyboardActions.test.ts index f58deeecf..66aba4c04 100644 --- a/tests/unit/bases/TaskListView.keyboardActions.test.ts +++ b/tests/unit/bases/TaskListView.keyboardActions.test.ts @@ -1,8 +1,4 @@ -import { showConfirmationModal } from "../../../src/modals/ConfirmationModal"; import { TaskListView } from "../../../src/bases/TaskListView"; -import type { TaskInfo } from "../../../src/types"; -import { normalizeTaskListShortcutMap } from "../../../src/bases/taskListKeyboardActions"; -import { Scope } from "obsidian"; jest.mock( "tasknotes-nlp-core", @@ -11,469 +7,8 @@ jest.mock( }), { virtual: true } ); -jest.mock("../../../src/modals/ConfirmationModal", () => ({ - showConfirmationModal: jest.fn(), -})); - -const mockedConfirmation = showConfirmationModal as jest.MockedFunction< - typeof showConfirmationModal ->; - -function task(path: string): TaskInfo { - return { - path, - title: path, - status: "open", - priority: "normal", - archived: false, - } as TaskInfo; -} describe("TaskListView keyboard actions", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it.each([ - ["clear-focus-and-selection", "clearTaskListFocusAndSelection", null], - ["toggle-select", "toggleFocusedTaskSelection", null], - ["select-all", "selectAllVisibleTasks", null], - ["copy-task-titles", "copyTaskActionTargetTitles", null], - ["toggle-archive", "toggleTaskActionTargetsArchive", null], - ["open-task-notes", "openTaskActionTargets", null], - ["edit-due", "showTaskActionDateMenu", "due"], - ["edit-scheduled", "showTaskActionDateMenu", "scheduled"], - ["edit-priority", "showTaskActionPriorityMenu", null], - ["mark-complete", "markTaskActionTargetsComplete", null], - ["edit-status", "showTaskActionStatusMenu", null], - ["edit-recurrence", "showTaskActionRecurrenceMenu", null], - ["add-tags", "addTagsToTaskActionTargets", null], - ["add-context", "addContextToTaskActionTargets", null], - ["add-project", "addProjectToTaskActionTargets", null], - ["delete-tasks", "deleteTaskActionTargets", null], - ] as const)("routes %s to its semantic handler", async (action, method, argument) => { - const handler = jest.fn(); - const view = { [method]: handler }; - - await (TaskListView.prototype as any).executeTaskListAction.call(view, action); - - expect(handler).toHaveBeenCalledWith(...(argument ? [argument] : [])); - }); - - it("claims a recognized shortcut when the task card owns focus", () => { - const executeTaskListAction = jest.fn(); - const view = { - focusController: { - getFocusedPathForEvent: jest.fn(() => "focused.md"), - }, - executeTaskListAction, - }; - const event = new KeyboardEvent("keydown", { - key: "d", - cancelable: true, - }); - const stopPropagation = jest.spyOn(event, "stopPropagation"); - - (TaskListView.prototype as any).handleTaskListActionKeyDown.call(view, event); - - expect(event.defaultPrevented).toBe(true); - expect(stopPropagation).toHaveBeenCalled(); - expect(executeTaskListAction).toHaveBeenCalledWith("edit-due", "focused.md"); - }); - - it("routes configured Gmail-style navigation through the focus controller", () => { - const moveFocus = jest.fn(); - const event = new KeyboardEvent("keydown", { key: "j", cancelable: true }); - const view = { - plugin: { - settings: { - taskListShortcuts: { - "navigate-next": ["j"], - "navigate-previous": ["k"], - }, - }, - }, - focusController: { - getFocusedPathForEvent: jest.fn(() => "focused.md"), - moveFocus, - }, - }; - - (TaskListView.prototype as any).handleTaskListActionKeyDown.call(view, event); - - expect(moveFocus).toHaveBeenCalledWith(event, "next"); - }); - - it("records an overlay before opening a user-field editor so focus can be restored", () => { - const noteOverlayOpening = jest.fn(); - const executeTaskListAction = jest.fn(); - const view = { - plugin: { - settings: { - taskListShortcuts: normalizeTaskListShortcutMap({}), - taskListUserFieldShortcuts: { effort: ["q"] }, - }, - }, - focusController: { - getFocusedPathForEvent: jest.fn(() => "focused.md"), - }, - inputOwnershipController: { noteOverlayOpening }, - executeTaskListAction, - }; - const event = new KeyboardEvent("keydown", { key: "q", cancelable: true }); - - (TaskListView.prototype as any).handleTaskListActionKeyDown.call(view, event); - - expect(noteOverlayOpening).toHaveBeenCalledTimes(1); - expect(executeTaskListAction).toHaveBeenCalledWith("edit-user-field:effort", "focused.md"); - }); - - it("defers task-card focus until after Obsidian modal cleanup", () => { - jest.useFakeTimers(); - try { - const restoreFocusedElement = jest.fn(); - const resumeAfterOverlayClose = jest.fn(); - const syncTaskListShortcutScopeForFocusTarget = jest.fn(); - const view = { - focusController: { restoreFocusedElement }, - inputOwnershipController: { resumeAfterOverlayClose }, - containerEl: document.createElement("div"), - syncTaskListShortcutScopeForFocusTarget, - taskListLeafActive: true, - activateTaskListShortcutScope: jest.fn(), - }; - - (TaskListView.prototype as any).restoreTaskListFocusAfterOverlayClose.call(view); - - expect(restoreFocusedElement).not.toHaveBeenCalled(); - jest.runAllTimers(); - expect(restoreFocusedElement).toHaveBeenCalledTimes(1); - expect(resumeAfterOverlayClose).toHaveBeenCalledTimes(1); - expect(syncTaskListShortcutScopeForFocusTarget).toHaveBeenCalledWith( - view.containerEl.ownerDocument.activeElement - ); - expect(view.activateTaskListShortcutScope).toHaveBeenCalledTimes(1); - } finally { - jest.useRealTimers(); - } - }); - - it.each([ - ["g", "jump-first", "first"], - ["G", "jump-last", "last"], - ] as const)("routes configured boundary key %s through the focus controller", (key, action, direction) => { - const moveFocus = jest.fn(); - const event = new KeyboardEvent("keydown", { key, cancelable: true }); - const view = { - plugin: { - settings: { - taskListShortcuts: { - [action]: [key.toLowerCase()], - }, - }, - }, - focusController: { - getFocusedPathForEvent: jest.fn(() => "focused.md"), - moveFocus, - }, - }; - - (TaskListView.prototype as any).handleTaskListActionKeyDown.call(view, event); - - expect(moveFocus).toHaveBeenCalledWith(event, direction); - }); - - it.each([ - ["Enter", { shiftKey: true }, "open-task-notes"], - ["s", { shiftKey: true }, "edit-scheduled"], - ["#", { shiftKey: true }, "add-tags"], - ["@", { shiftKey: true }, "add-context"], - ["+", { shiftKey: true }, "add-project"], - ["Delete", { ctrlKey: true }, "delete-tasks"], - ["Delete", { metaKey: true }, "delete-tasks"], - ] as const)("allows the modifier chord %s after action recognition", (keyValue, modifiers, action) => { - const executeTaskListAction = jest.fn(); - const getFocusedPathForEvent = jest.fn(() => "focused.md"); - const view = { - focusController: { getFocusedPathForEvent }, - executeTaskListAction, - }; - const event = new KeyboardEvent("keydown", { - key: keyValue, - cancelable: true, - ...modifiers, - }); - - (TaskListView.prototype as any).handleTaskListActionKeyDown.call(view, event); - - expect(getFocusedPathForEvent).toHaveBeenCalledWith(event, true, false); - expect(event.defaultPrevented).toBe(true); - expect(executeTaskListAction).toHaveBeenCalledWith(action, "focused.md"); - }); - - it("does not claim shortcuts when an interactive control owns focus", () => { - const executeTaskListAction = jest.fn(); - const view = { - focusController: { - getFocusedPathForEvent: jest.fn(() => null), - }, - executeTaskListAction, - }; - const event = new KeyboardEvent("keydown", { - key: "d", - cancelable: true, - }); - - (TaskListView.prototype as any).handleTaskListActionKeyDown.call(view, event); - - expect(event.defaultPrevented).toBe(false); - expect(executeTaskListAction).not.toHaveBeenCalled(); - }); - - it("routes a shortcut from the active view shell through remembered task focus", () => { - const executeTaskListAction = jest.fn(); - const getFocusedPathForEvent = jest.fn(() => "remembered.md"); - const event = new KeyboardEvent("keydown", { key: "d", cancelable: true }); - const view = { - focusController: { getFocusedPathForEvent }, - executeTaskListAction, - }; - - (TaskListView.prototype as any).handleTaskListActionKeyDown.call( - view, - event, - true - ); - - expect(getFocusedPathForEvent).toHaveBeenCalledWith(event, true, true); - expect(executeTaskListAction).toHaveBeenCalledWith("edit-due", "remembered.md"); - }); - - it("routes a body-targeted shortcut through remembered task focus after a rerender", () => { - const executeTaskListAction = jest.fn(); - const canHandleListKeyDown = jest.fn(() => true); - const getFocusedPathForEvent = jest.fn(() => "remembered.md"); - const event = new KeyboardEvent("keydown", { - key: " ", - cancelable: true, - }); - Object.defineProperty(event, "target", { value: document.body }); - const view = { - inputOwnershipController: { canHandleListKeyDown }, - focusController: { getFocusedPathForEvent }, - handleTaskListActionKeyDown: - (TaskListView.prototype as any).handleTaskListActionKeyDown, - executeTaskListAction, - }; - - (TaskListView.prototype as any).handleTaskListKeyDown.call(view, event, true); - - expect(canHandleListKeyDown).toHaveBeenCalledWith(event, true); - expect(getFocusedPathForEvent).toHaveBeenCalledWith(event, true, true); - expect(executeTaskListAction).toHaveBeenCalledWith("toggle-select", "remembered.md"); - }); - - it("routes a prevented modifier chord from the active view shell", () => { - const executeTaskListAction = jest.fn(); - const event = new KeyboardEvent("keydown", { - key: "a", - ctrlKey: true, - cancelable: true, - }); - event.preventDefault(); - const view = { - plugin: { - settings: { - taskListShortcuts: { - "select-all": ["mod+a"], - }, - }, - }, - focusController: { - getFocusedPathForEvent: jest.fn(() => null), - }, - executeTaskListAction, - }; - - (TaskListView.prototype as any).handleTaskListActionKeyDown.call( - view, - event, - true - ); - - expect(executeTaskListAction).toHaveBeenCalledWith("select-all", null); - }); - - it("does not discard a prevented chord before shell shortcut routing", () => { - const root = document.createElement("div"); - const itemsContainer = document.createElement("div"); - root.appendChild(itemsContainer); - const event = new KeyboardEvent("keydown", { - key: "c", - metaKey: true, - cancelable: true, - }); - Object.defineProperty(event, "target", { value: root }); - event.preventDefault(); - const handleTaskListKeyDown = jest.fn(); - const view = { itemsContainer, handleTaskListKeyDown }; - - (TaskListView.prototype as any).handleTaskListRootKeyDown.call(view, event); - - expect(handleTaskListKeyDown).toHaveBeenCalledWith(event, true); - }); - - it("routes card chords from the root without using remembered-focus fallback", () => { - const root = document.createElement("div"); - const itemsContainer = document.createElement("div"); - const card = document.createElement("div"); - itemsContainer.appendChild(card); - root.appendChild(itemsContainer); - const event = new KeyboardEvent("keydown", { - key: "b", - ctrlKey: true, - }); - Object.defineProperty(event, "target", { value: card }); - const handleTaskListKeyDown = jest.fn(); - const view = { itemsContainer, handleTaskListKeyDown }; - - (TaskListView.prototype as any).handleTaskListRootKeyDown.call(view, event); - - expect(handleTaskListKeyDown).toHaveBeenCalledWith(event, false); - }); - - it("registers task-list shortcut routing in the capture phase", () => { - const rootElement = document.createElement("div"); - const itemsContainer = document.createElement("div"); - rootElement.appendChild(itemsContainer); - const registerDomEvent = jest.fn(); - const view = { - rootElement, - itemsContainer, - containerListenersRegistered: false, - handleItemClick: jest.fn(), - focusController: null, - inputOwnershipController: null, - registerDomEvent, - }; - - (TaskListView.prototype as any).registerContainerListeners.call(view); - - expect(registerDomEvent).toHaveBeenCalledWith( - rootElement, - "keydown", - expect.any(Function), - true - ); - expect(registerDomEvent).toHaveBeenCalledWith( - itemsContainer, - "mousemove", - expect.any(Function) - ); - }); - - it("keeps the active view shortcut scope when focus falls back to the body", () => { - const canOwnKeyboardTarget = jest.fn(() => true); - const activateTaskListShortcutScope = jest.fn(); - const deactivateTaskListShortcutScope = jest.fn(); - const view = { - taskListLeafActive: true, - inputOwnershipController: { canOwnKeyboardTarget }, - activateTaskListShortcutScope, - deactivateTaskListShortcutScope, - }; - - (TaskListView.prototype as any).syncTaskListShortcutScopeForFocusTarget.call( - view, - document.body - ); - - expect(canOwnKeyboardTarget).toHaveBeenCalledWith(document.body, true); - expect(activateTaskListShortcutScope).toHaveBeenCalled(); - expect(deactivateTaskListShortcutScope).not.toHaveBeenCalled(); - }); - - it("pushes an Obsidian child scope for configured view-local chords", () => { - const registerSpy = jest.spyOn(Scope.prototype, "register"); - const pushScope = jest.fn(); - const popScope = jest.fn(); - const handleTaskListKeyDown = jest.fn(() => true); - const view = { - taskListShortcutScope: null, - taskListLeafActive: true, - plugin: { - settings: { - taskListShortcuts: normalizeTaskListShortcutMap({ - "select-all": ["Ctrl+B"], - "copy-task-titles": ["Ctrl+D"], - }), - }, - app: { - scope: {}, - keymap: { pushScope, popScope }, - }, - }, - handleTaskListKeyDown, - }; - - (TaskListView.prototype as any).activateTaskListShortcutScope.call(view); - - expect(pushScope).toHaveBeenCalledTimes(1); - const scope = view.taskListShortcutScope; - expect(registerSpy).toHaveBeenCalledWith( - ["Mod"], - "b", - expect.any(Function) - ); - expect(registerSpy).toHaveBeenCalledWith( - ["Mod"], - "d", - expect.any(Function) - ); - - const ctrlBHandler = registerSpy.mock.calls.find( - ([modifiers, key]) => modifiers[0] === "Mod" && key === "b" - )?.[2] as (event: KeyboardEvent) => unknown; - const event = new KeyboardEvent("keydown", { key: "b", ctrlKey: true }); - expect(ctrlBHandler(event)).toBe(false); - expect(handleTaskListKeyDown).toHaveBeenCalledWith(event, true); - - (TaskListView.prototype as any).deactivateTaskListShortcutScope.call(view); - expect(popScope).toHaveBeenCalledTimes(1); - expect(popScope.mock.calls[0][0]).toBe(scope); - expect(view.taskListShortcutScope).toBeNull(); - registerSpy.mockRestore(); - }); - - it("does not claim a scoped chord after the task-list leaf is deactivated", () => { - const registerSpy = jest.spyOn(Scope.prototype, "register"); - const view = { - taskListShortcutScope: null, - taskListLeafActive: true, - plugin: { - settings: { - taskListShortcuts: normalizeTaskListShortcutMap({ - "select-all": ["Ctrl+B"], - }), - }, - app: { - scope: {}, - keymap: { pushScope: jest.fn(), popScope: jest.fn() }, - }, - }, - handleTaskListKeyDown: jest.fn(() => true), - }; - (TaskListView.prototype as any).activateTaskListShortcutScope.call(view); - const handler = registerSpy.mock.calls.find( - ([modifiers, key]) => modifiers[0] === "Mod" && key === "b" - )?.[2] as (event: KeyboardEvent) => unknown; - view.taskListLeafActive = false; - - expect(handler(new KeyboardEvent("keydown", { key: "b", ctrlKey: true }))).toBeUndefined(); - expect(view.handleTaskListKeyDown).not.toHaveBeenCalled(); - registerSpy.mockRestore(); - }); - it("creates search controls on demand before focusing them", () => { const rootElement = document.createElement("div"); const focus = jest.fn(); @@ -495,165 +30,44 @@ describe("TaskListView keyboard actions", () => { expect(focus).toHaveBeenCalled(); }); - it("uses the first resolved target for the single-task edit modal", async () => { - const first = task("first.md"); - const second = task("second.md"); - const openTaskEditModal = jest.fn(); - const view = { - getTaskActionTargets: jest.fn(async () => [first, second]), - plugin: { openTaskEditModal }, - }; - - await (TaskListView.prototype as any).executeTaskListAction.call(view, "edit-task"); - - expect(openTaskEditModal).toHaveBeenCalledWith(first); - }); - - it("updates every resolved target through the current property API", async () => { - const tasks = [task("first.md"), task("second.md")]; - const updateTaskProperty = jest.fn(async () => undefined); - const view = { plugin: { updateTaskProperty } }; - - await (TaskListView.prototype as any).updateTaskActionTargets.call( - view, - tasks, - "priority", - "high" - ); - - expect(updateTaskProperty).toHaveBeenNthCalledWith(1, tasks[0], "priority", "high"); - expect(updateTaskProperty).toHaveBeenNthCalledWith(2, tasks[1], "priority", "high"); - }); - - it("routes a completed status through recurring-instance completion", async () => { - const recurring = { ...task("recurring.md"), recurrence: "FREQ=WEEKLY" }; - const ordinary = task("ordinary.md"); - const actionDate = new Date("2026-08-02T00:00:00.000Z"); - const toggleRecurringTaskComplete = jest.fn(async () => recurring); - const updateTaskProperty = jest.fn(async () => ordinary); - const view = { - plugin: { - statusManager: { isCompletedStatus: (status: string) => status === "done" }, - toggleRecurringTaskComplete, - updateTaskProperty, - }, - getTaskActionDate: jest.fn(() => actionDate), - }; - - await (TaskListView.prototype as any).updateTaskActionTargetStatuses.call( - view, - [recurring, ordinary], - "done" - ); - - expect(toggleRecurringTaskComplete).toHaveBeenCalledWith(recurring, actionDate); - expect(updateTaskProperty).toHaveBeenCalledWith(ordinary, "status", "done"); - }); - - it("selects only tasks visible in the current filtered view", () => { - const selectAll = jest.fn(); - const enterSelectionMode = jest.fn(); - const view = { - currentVisibleTaskPaths: new Set(["visible-a.md", "visible-b.md"]), - plugin: { - taskSelectionService: { selectAll, enterSelectionMode }, - }, - }; - - (TaskListView.prototype as any).selectAllVisibleTasks.call(view); - - expect(selectAll).toHaveBeenCalledWith(["visible-a.md", "visible-b.md"]); - expect(enterSelectionMode).toHaveBeenCalled(); - }); - - it("copies resolved visible target titles as newline-delimited text", async () => { - const tasks = [ - { ...task("first.md"), title: "First title" }, - { ...task("second.md"), title: "Second title" }, - ]; - const writeText = jest.fn().mockResolvedValue(undefined); - Object.defineProperty(navigator, "clipboard", { - configurable: true, - value: { writeText }, - }); - const view = { - getTaskActionTargets: jest.fn(async () => tasks), - }; - - await (TaskListView.prototype as any).copyTaskActionTargetTitles.call(view); - - expect(writeText).toHaveBeenCalledWith("First title\nSecond title"); - }); - - it("toggles archive only when all resolved targets share the same state", async () => { - const tasks = [task("first.md"), task("second.md")]; - const toggleArchive = jest.fn().mockResolvedValue(undefined); - const view = { - getTaskActionTargets: jest.fn(async () => tasks), - plugin: { taskService: { toggleArchive } }, - }; - - await (TaskListView.prototype as any).toggleTaskActionTargetsArchive.call(view); - - expect(toggleArchive).toHaveBeenNthCalledWith(1, tasks[0]); - expect(toggleArchive).toHaveBeenNthCalledWith(2, tasks[1]); - }); - - it("does not toggle a mixed archive selection", async () => { - const tasks = [ - task("open.md"), - { ...task("archived.md"), archived: true }, - ]; - const toggleArchive = jest.fn(); - const view = { - getTaskActionTargets: jest.fn(async () => tasks), - plugin: { taskService: { toggleArchive } }, - }; - - await (TaskListView.prototype as any).toggleTaskActionTargetsArchive.call(view); - - expect(toggleArchive).not.toHaveBeenCalled(); - }); - - it("does not delete when destructive confirmation is cancelled", async () => { - const tasks = [task("first.md"), task("second.md")]; - const deleteTask = jest.fn(); - mockedConfirmation.mockResolvedValue(false); - const view = { - getTaskActionTargets: jest.fn(async () => tasks), - plugin: { - app: {}, - i18n: { translate: jest.fn(() => "Cancel") }, - taskService: { deleteTask }, - taskSelectionService: { clearSelection: jest.fn() }, - }, - }; - - await (TaskListView.prototype as any).deleteTaskActionTargets.call(view); - - expect(mockedConfirmation).toHaveBeenCalled(); - expect(deleteTask).not.toHaveBeenCalled(); - }); - - it("deletes every target after one confirmation and clears selection", async () => { - const tasks = [task("first.md"), task("second.md")]; - const deleteTask = jest.fn(async () => undefined); - const clearSelection = jest.fn(); - mockedConfirmation.mockResolvedValue(true); - const view = { - getTaskActionTargets: jest.fn(async () => tasks), - plugin: { - app: {}, - i18n: { translate: jest.fn(() => "Cancel") }, - taskService: { deleteTask }, - taskSelectionService: { clearSelection }, - }, - }; - - await (TaskListView.prototype as any).deleteTaskActionTargets.call(view); - - expect(mockedConfirmation).toHaveBeenCalledTimes(1); - expect(deleteTask).toHaveBeenCalledTimes(2); - expect(clearSelection).toHaveBeenCalled(); + it("wires the shared keyboard controller to Task List state and callbacks", () => { + const showBatchContextMenu = jest.fn(); + const createFileForView = jest.fn(); + const focusTaskListSearch = jest.fn(); + const itemsContainer = document.createElement("div"); + const rootElement = document.createElement("div"); + const currentTargetDate = new Date("2026-08-02T00:00:00.000Z"); + const mockThis = { + plugin: { taskSelectionService: {} }, + app: undefined, + currentTargetDate, + rootElement, + itemsContainer, + currentVisibleTaskPaths: new Set(["a.md"]), + showBatchContextMenu, + createFileForView, + focusTaskListSearch, + }; + + const config = (TaskListView.prototype as any).getTaskCardActionsConfig.call(mockThis); + expect(config.autoFocusInitial).toBe(true); + expect(config.isActionSupported("delete-tasks")).toBe(true); + + const viewContext = config.buildViewContext(); + expect(viewContext.plugin).toBe(mockThis.plugin); + expect(viewContext.taskSelectionService).toBe(mockThis.plugin.taskSelectionService); + expect(viewContext.rootElement).toBe(rootElement); + expect(viewContext.fallbackAnchor).toBe(itemsContainer); + expect(viewContext.getCurrentTargetDate()).toBe(currentTargetDate); + expect(viewContext.isPathVisible("a.md")).toBe(true); + expect(viewContext.isPathVisible("b.md")).toBe(false); + expect(viewContext.getVisibleTaskPaths()).toEqual(["a.md"]); + + viewContext.showBatchContextMenu({} as any); + expect(showBatchContextMenu).toHaveBeenCalled(); + viewContext.createFileForView(); + expect(createFileForView).toHaveBeenCalled(); + viewContext.focusSearch?.(); + expect(focusTaskListSearch).toHaveBeenCalled(); }); }); diff --git a/tests/unit/bases/TaskListView.keyboardSelection.test.ts b/tests/unit/bases/TaskListView.keyboardSelection.test.ts index 6b22905cf..b28fea191 100644 --- a/tests/unit/bases/TaskListView.keyboardSelection.test.ts +++ b/tests/unit/bases/TaskListView.keyboardSelection.test.ts @@ -1,5 +1,6 @@ import { TaskListView } from "../../../src/bases/TaskListView"; import { TaskListFocusController } from "../../../src/bases/TaskListFocusController"; +import { executeBasesTaskCardAction } from "../../../src/bases/basesTaskCardActions"; jest.mock( "tasknotes-nlp-core", @@ -10,24 +11,19 @@ jest.mock( ); describe("TaskListView keyboard selection", () => { - it("toggles the focused task through the existing selection service", () => { + it("toggles the focused task through the existing selection service", async () => { const toggleSelection = jest.fn(); - const view = { - focusController: { - getFocusedIdentity: jest.fn(() => ({ path: "focused.md", occurrence: 0 })), - }, - currentVisibleTaskPaths: new Set(["focused.md"]), - plugin: { - taskSelectionService: { toggleSelection }, - }, + const context = { + taskSelectionService: { toggleSelection }, + isPathVisible: (path: string) => path === "focused.md", }; - (TaskListView.prototype as any).toggleFocusedTaskSelection.call(view); + await executeBasesTaskCardAction("toggle-select", "focused.md", context as any); expect(toggleSelection).toHaveBeenCalledWith("focused.md"); }); - it("toggles the mouse-focused task after hover moves DOM focus to it", () => { + it("toggles the mouse-focused task after hover moves DOM focus to it", async () => { const items = document.createElement("div"); const first = document.createElement("div"); first.className = "task-card"; @@ -44,77 +40,67 @@ describe("TaskListView keyboard selection", () => { const mouseEvent = new MouseEvent("mousemove"); Object.defineProperty(mouseEvent, "target", { value: hovered }); focusController.handleMouseMove(mouseEvent); + const toggleSelection = jest.fn(); - const view = { - currentVisibleTaskPaths: new Set(["first.md", "hovered.md"]), - focusController, - plugin: { - taskSelectionService: { toggleSelection }, - }, + const focusedPath = focusController.getFocusedIdentity()?.path ?? null; + const context = { + taskSelectionService: { toggleSelection }, + isPathVisible: (path: string) => ["first.md", "hovered.md"].includes(path), }; - (TaskListView.prototype as any).toggleFocusedTaskSelection.call(view); + await executeBasesTaskCardAction("toggle-select", focusedPath, context as any); expect(document.activeElement).toBe(hovered); expect(toggleSelection).toHaveBeenCalledWith("hovered.md"); }); - it("does not toggle a remembered task that is filtered out", () => { + it("does not toggle a remembered task that is filtered out", async () => { const toggleSelection = jest.fn(); - const view = { - focusController: { - getFocusedIdentity: jest.fn(() => ({ path: "hidden.md", occurrence: 0 })), - }, - currentVisibleTaskPaths: new Set(["visible.md"]), - plugin: { - taskSelectionService: { toggleSelection }, - }, + const context = { + taskSelectionService: { toggleSelection }, + isPathVisible: (path: string) => path === "visible.md", }; - (TaskListView.prototype as any).toggleFocusedTaskSelection.call(view); + await executeBasesTaskCardAction("toggle-select", "hidden.md", context as any); expect(toggleSelection).not.toHaveBeenCalled(); }); - it("clears selection while preserving task focus for subsequent shortcuts", () => { + it("clears selection while preserving task focus for subsequent shortcuts", async () => { const clearSelection = jest.fn(); const exitSelectionMode = jest.fn(); - const restoreFocusedElement = jest.fn(() => true); + const restoreFocus = jest.fn(() => true); const rootElement = document.createElement("div"); rootElement.tabIndex = -1; document.body.appendChild(rootElement); - const view = { + const context = { + taskSelectionService: { clearSelection, exitSelectionMode }, + restoreFocus, rootElement, - focusController: { restoreFocusedElement }, - plugin: { - taskSelectionService: { clearSelection, exitSelectionMode }, - }, }; - (TaskListView.prototype as any).clearTaskListFocusAndSelection.call(view); + await executeBasesTaskCardAction("clear-focus-and-selection", null, context as any); expect(clearSelection).toHaveBeenCalled(); expect(exitSelectionMode).toHaveBeenCalled(); - expect(restoreFocusedElement).toHaveBeenCalled(); + expect(restoreFocus).toHaveBeenCalled(); expect(document.activeElement).not.toBe(rootElement); }); - it("focuses the task-list root when no task focus can be restored after clearing selection", () => { + it("focuses the task-list root when no task focus can be restored after clearing selection", async () => { const rootElement = document.createElement("div"); rootElement.tabIndex = -1; document.body.appendChild(rootElement); - const view = { - rootElement, - focusController: { restoreFocusedElement: jest.fn(() => false) }, - plugin: { - taskSelectionService: { - clearSelection: jest.fn(), - exitSelectionMode: jest.fn(), - }, + const context = { + taskSelectionService: { + clearSelection: jest.fn(), + exitSelectionMode: jest.fn(), }, + restoreFocus: jest.fn(() => false), + rootElement, }; - (TaskListView.prototype as any).clearTaskListFocusAndSelection.call(view); + await executeBasesTaskCardAction("clear-focus-and-selection", null, context as any); expect(document.activeElement).toBe(rootElement); }); @@ -136,11 +122,10 @@ describe("TaskListView keyboard selection", () => { onSelectionModeChange: jest.fn(() => jest.fn()), }, }, - inputOwnershipController: { - canHandleListKeyDown: jest.fn(() => false), + taskCardKeyboardController: { + canHandleSelectionKeyDown: jest.fn(() => false), }, - canHandleSelectionKeyDown: (TaskListView.prototype as any) - .canHandleSelectionKeyDown, + canHandleSelectionKeyDown: (TaskListView.prototype as any).canHandleSelectionKeyDown, getVisibleTaskPaths: jest.fn(() => ["focused.md"]), updateSelectionModeUI: jest.fn(), updateSelectionVisuals: jest.fn(), @@ -159,41 +144,18 @@ describe("TaskListView keyboard selection", () => { card.dispatchEvent(event); - expect(view.inputOwnershipController.canHandleListKeyDown).toHaveBeenCalledWith(event); + expect(view.taskCardKeyboardController.canHandleSelectionKeyDown).toHaveBeenCalledWith( + event + ); expect(exitSelectionMode).not.toHaveBeenCalled(); }); - it("restores remembered card focus when its workspace leaf is activated", () => { - jest.useFakeTimers(); - const leafContainer = document.createElement("div"); - const containerEl = document.createElement("div"); - const rootElement = document.createElement("div"); - leafContainer.append(containerEl); - containerEl.append(rootElement); - document.body.appendChild(leafContainer); - const restoreFocusedElement = jest.fn(); - const view = { - containerEl, - rootElement, - focusController: { restoreFocusedElement }, - isTaskListLeaf: (TaskListView.prototype as any).isTaskListLeaf, - }; - - (TaskListView.prototype as any).restoreFocusForActivatedLeaf.call(view, { - view: { containerEl: leafContainer }, - }); - jest.runAllTimers(); - - expect(restoreFocusedElement).toHaveBeenCalled(); - jest.useRealTimers(); - }); - it("rehydrates selection visuals after every card render", () => { const restoreAfterRender = jest.fn(); const updateSelectionVisuals = jest.fn(); const updateSelectionIndicator = jest.fn(); const view = { - focusController: { restoreAfterRender }, + taskCardKeyboardController: { restoreAfterRender }, plugin: { taskSelectionService: { getSelectionCount: jest.fn(() => 3), diff --git a/tests/unit/bases/basesTaskCardActions.test.ts b/tests/unit/bases/basesTaskCardActions.test.ts new file mode 100644 index 000000000..98d9be94b --- /dev/null +++ b/tests/unit/bases/basesTaskCardActions.test.ts @@ -0,0 +1,248 @@ +import { TFile } from "obsidian"; +import { showConfirmationModal } from "../../../src/modals/ConfirmationModal"; +import { + executeBasesTaskCardAction, + updateTasksProperty, + updateTasksStatus, + type BasesTaskCardActionContext, +} from "../../../src/bases/basesTaskCardActions"; +import type { TaskInfo } from "../../../src/types"; + +jest.mock( + "tasknotes-nlp-core", + () => ({ + NaturalLanguageParserCore: class {}, + }), + { virtual: true } +); +jest.mock("../../../src/modals/ConfirmationModal", () => ({ + showConfirmationModal: jest.fn(), +})); + +const mockedConfirmation = showConfirmationModal as jest.MockedFunction< + typeof showConfirmationModal +>; + +function task(path: string, overrides: Partial = {}): TaskInfo { + return { + path, + title: path, + status: "open", + priority: "normal", + archived: false, + ...overrides, + } as TaskInfo; +} + +function createContext( + tasks: TaskInfo[], + overrides: Partial = {} +): BasesTaskCardActionContext { + const byPath = new Map(tasks.map((t) => [t.path, t])); + return { + plugin: { + cacheManager: { getTaskInfo: jest.fn(async (path: string) => byPath.get(path) ?? null) }, + } as any, + app: {} as any, + taskSelectionService: undefined, + getTargetPaths: () => tasks.map((t) => t.path), + getVisibleTaskPaths: () => tasks.map((t) => t.path), + isPathVisible: (path: string) => byPath.has(path), + getAnchor: () => null, + getCurrentTargetDate: () => new Date("2026-08-02T00:00:00.000Z"), + restoreFocus: () => false, + rootElement: null, + showBatchContextMenu: jest.fn(), + createFileForView: jest.fn(async () => undefined), + ...overrides, + }; +} + +describe("executeBasesTaskCardAction", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("navigate/jump actions are no-ops (handled by the keyboard controller before dispatch)", async () => { + const context = createContext([]); + await expect( + executeBasesTaskCardAction("navigate-next", null, context) + ).resolves.toBeUndefined(); + }); + + it("uses the first resolved target for the single-task edit modal", async () => { + const first = task("first.md"); + const second = task("second.md"); + const openTaskEditModal = jest.fn(); + const context = createContext([first, second], { + plugin: { + cacheManager: { + getTaskInfo: jest.fn(async (path: string) => + [first, second].find((t) => t.path === path) + ), + }, + openTaskEditModal, + } as any, + }); + + await executeBasesTaskCardAction("edit-task", null, context); + + expect(openTaskEditModal).toHaveBeenCalledWith(first); + }); + + it("selects only tasks visible in the current filtered view", async () => { + const selectAll = jest.fn(); + const enterSelectionMode = jest.fn(); + const context = createContext([task("visible-a.md"), task("visible-b.md")], { + taskSelectionService: { selectAll, enterSelectionMode } as any, + }); + + await executeBasesTaskCardAction("select-all", null, context); + + expect(selectAll).toHaveBeenCalledWith(["visible-a.md", "visible-b.md"]); + expect(enterSelectionMode).toHaveBeenCalled(); + }); + + it("copies resolved visible target titles as newline-delimited text", async () => { + const tasks = [task("first.md", { title: "First title" }), task("second.md", { title: "Second title" })]; + const writeText = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + const context = createContext(tasks); + + await executeBasesTaskCardAction("copy-task-titles", null, context); + + expect(writeText).toHaveBeenCalledWith("First title\nSecond title"); + }); + + it("toggles archive only when all resolved targets share the same state", async () => { + const tasks = [task("first.md"), task("second.md")]; + const toggleArchive = jest.fn().mockResolvedValue(undefined); + const context = createContext(tasks, { + plugin: { + cacheManager: { getTaskInfo: jest.fn(async (path: string) => tasks.find((t) => t.path === path)) }, + taskService: { toggleArchive }, + } as any, + }); + + await executeBasesTaskCardAction("toggle-archive", null, context); + + expect(toggleArchive).toHaveBeenNthCalledWith(1, tasks[0]); + expect(toggleArchive).toHaveBeenNthCalledWith(2, tasks[1]); + }); + + it("does not toggle a mixed archive selection", async () => { + const tasks = [task("open.md"), task("archived.md", { archived: true })]; + const toggleArchive = jest.fn(); + const context = createContext(tasks, { + plugin: { + cacheManager: { getTaskInfo: jest.fn(async (path: string) => tasks.find((t) => t.path === path)) }, + taskService: { toggleArchive }, + } as any, + }); + + await executeBasesTaskCardAction("toggle-archive", null, context); + + expect(toggleArchive).not.toHaveBeenCalled(); + }); + + it("opens every resolved target's note in a new tab", async () => { + const tasks = [task("first.md"), task("second.md")]; + const files = new Map(tasks.map((t) => [t.path, new TFile()])); + const openFile = jest.fn(); + const context = createContext(tasks, { + app: { + vault: { getAbstractFileByPath: jest.fn((path: string) => files.get(path) ?? null) }, + workspace: { getLeaf: jest.fn(() => ({ openFile })) }, + } as any, + }); + + await executeBasesTaskCardAction("open-task-notes", null, context); + + expect(openFile).toHaveBeenCalledTimes(2); + expect(openFile).toHaveBeenNthCalledWith(1, files.get("first.md")); + expect(openFile).toHaveBeenNthCalledWith(2, files.get("second.md")); + }); + + it("does not delete when destructive confirmation is cancelled", async () => { + const tasks = [task("first.md"), task("second.md")]; + const deleteTask = jest.fn(); + mockedConfirmation.mockResolvedValue(false); + const context = createContext(tasks, { + plugin: { + cacheManager: { getTaskInfo: jest.fn(async (path: string) => tasks.find((t) => t.path === path)) }, + app: {}, + i18n: { translate: jest.fn(() => "Cancel") }, + taskService: { deleteTask }, + } as any, + taskSelectionService: { clearSelection: jest.fn() } as any, + }); + + await executeBasesTaskCardAction("delete-tasks", null, context); + + expect(mockedConfirmation).toHaveBeenCalled(); + expect(deleteTask).not.toHaveBeenCalled(); + }); + + it("deletes every target after one confirmation and clears selection", async () => { + const tasks = [task("first.md"), task("second.md")]; + const deleteTask = jest.fn(async () => undefined); + const clearSelection = jest.fn(); + mockedConfirmation.mockResolvedValue(true); + const context = createContext(tasks, { + plugin: { + cacheManager: { getTaskInfo: jest.fn(async (path: string) => tasks.find((t) => t.path === path)) }, + app: {}, + i18n: { translate: jest.fn(() => "Cancel") }, + taskService: { deleteTask }, + } as any, + taskSelectionService: { clearSelection } as any, + }); + + await executeBasesTaskCardAction("delete-tasks", null, context); + + expect(mockedConfirmation).toHaveBeenCalledTimes(1); + expect(deleteTask).toHaveBeenCalledTimes(2); + expect(clearSelection).toHaveBeenCalled(); + }); +}); + +describe("updateTasksProperty", () => { + it("updates every resolved target through the current property API", async () => { + const tasks = [task("first.md"), task("second.md")]; + const updateTaskProperty = jest.fn(async () => undefined); + const context = createContext(tasks, { + plugin: { updateTaskProperty } as any, + }); + + await updateTasksProperty(context, tasks, "priority", "high"); + + expect(updateTaskProperty).toHaveBeenNthCalledWith(1, tasks[0], "priority", "high"); + expect(updateTaskProperty).toHaveBeenNthCalledWith(2, tasks[1], "priority", "high"); + }); +}); + +describe("updateTasksStatus", () => { + it("routes a completed status through recurring-instance completion", async () => { + const recurring = task("recurring.md", { recurrence: "FREQ=WEEKLY" }); + const ordinary = task("ordinary.md"); + const actionDate = new Date("2026-08-02T00:00:00.000Z"); + const toggleRecurringTaskComplete = jest.fn(async () => recurring); + const updateTaskProperty = jest.fn(async () => ordinary); + const context = createContext([recurring, ordinary], { + plugin: { + statusManager: { isCompletedStatus: (status: string) => status === "done" }, + toggleRecurringTaskComplete, + updateTaskProperty, + } as any, + getCurrentTargetDate: () => actionDate, + }); + + await updateTasksStatus(context, [recurring, ordinary], "done"); + + expect(toggleRecurringTaskComplete).toHaveBeenCalledWith(recurring, actionDate); + expect(updateTaskProperty).toHaveBeenCalledWith(ordinary, "status", "done"); + }); +}); diff --git a/tests/unit/bases/embeddedBasesKeyboard.test.ts b/tests/unit/bases/embeddedBasesKeyboard.test.ts new file mode 100644 index 000000000..dab78ae53 --- /dev/null +++ b/tests/unit/bases/embeddedBasesKeyboard.test.ts @@ -0,0 +1,57 @@ +import { canHoverClaimBasesTaskFocus } from "../../../src/bases/embeddedBasesKeyboard"; + +describe("embedded Bases keyboard ownership", () => { + afterEach(() => { + document.body.empty(); + }); + + function createInjectedView(mode: "source" | "reading"): { + root: HTMLElement; + editor: HTMLElement | null; + } { + const view = document.createElement("div"); + view.className = mode === "source" ? "markdown-source-view" : "markdown-preview-view"; + const editor = mode === "source" ? document.createElement("div") : null; + if (editor) { + editor.className = "cm-content"; + editor.setAttribute("contenteditable", "true"); + editor.tabIndex = 0; + view.appendChild(editor); + } + const widget = document.createElement("div"); + widget.className = "tasknotes-relationships-widget"; + const root = document.createElement("div"); + widget.appendChild(root); + view.appendChild(widget); + document.body.appendChild(view); + return { root, editor }; + } + + it("blocks hover while the containing Live Preview editor owns the cursor", () => { + const { root, editor } = createInjectedView("source"); + editor?.focus(); + + expect(canHoverClaimBasesTaskFocus(root)).toBe(false); + }); + + it("allows hover in reading mode", () => { + const { root } = createInjectedView("reading"); + + expect(canHoverClaimBasesTaskFocus(root)).toBe(true); + }); + + it("allows explicit widget focus even in Live Preview", () => { + const { root } = createInjectedView("source"); + root.tabIndex = 0; + root.focus(); + + expect(canHoverClaimBasesTaskFocus(root)).toBe(true); + }); + + it("does not restrict standalone Agenda, Calendar, or Kanban Bases views", () => { + const root = document.createElement("div"); + document.body.appendChild(root); + + expect(canHoverClaimBasesTaskFocus(root)).toBe(true); + }); +}); diff --git a/tests/unit/issues/issue-1026-1177-bases-recurring-completion-timezone.test.ts b/tests/unit/issues/issue-1026-1177-bases-recurring-completion-timezone.test.ts index 8befff5bd..2107cf8df 100644 --- a/tests/unit/issues/issue-1026-1177-bases-recurring-completion-timezone.test.ts +++ b/tests/unit/issues/issue-1026-1177-bases-recurring-completion-timezone.test.ts @@ -24,6 +24,7 @@ import { formatDateForStorage, createUTCDateFromLocalCalendarDate } from "../../../src/utils/dateUtils"; import { TaskListView } from "../../../src/bases/TaskListView"; import { KanbanView } from "../../../src/bases/KanbanView"; +import { getTaskActionDate } from "../../../src/bases/basesTaskCardActions"; describe("Issue #1026 & #1177: Bases recurring completion timezone bug", () => { /** @@ -49,11 +50,6 @@ describe("Issue #1026 & #1177: Bases recurring completion timezone bug", () => { }); it("getTaskActionDate should return UTC-anchored date from scheduled date", () => { - const mockPlugin = { - fieldMapper: {}, - }; - const view = new TaskListView({}, document.createElement("div"), mockPlugin as any); - // Task with scheduled date const taskWithScheduled = { title: "Test task", @@ -63,7 +59,10 @@ describe("Issue #1026 & #1177: Bases recurring completion timezone bug", () => { scheduled: "2025-11-19", }; - const actionDate = (view as any).getTaskActionDate(taskWithScheduled) as Date; + const actionDate = getTaskActionDate( + taskWithScheduled as any, + createUTCDateFromLocalCalendarDate(new Date()) + ); // Should be UTC-anchored at midnight UTC for Nov 19 expect(actionDate.toISOString()).toBe("2025-11-19T00:00:00.000Z"); @@ -71,11 +70,6 @@ describe("Issue #1026 & #1177: Bases recurring completion timezone bug", () => { }); it("getTaskActionDate fallback should return UTC-anchored date", () => { - const mockPlugin = { - fieldMapper: {}, - }; - const view = new TaskListView({}, document.createElement("div"), mockPlugin as any); - // Task without scheduled or due date (triggers fallback) const taskWithoutDates = { title: "Test task", @@ -84,7 +78,8 @@ describe("Issue #1026 & #1177: Bases recurring completion timezone bug", () => { recurrence: "RRULE:FREQ=DAILY", }; - const actionDate = (view as any).getTaskActionDate(taskWithoutDates) as Date; + const fallback = createUTCDateFromLocalCalendarDate(new Date()); + const actionDate = getTaskActionDate(taskWithoutDates as any, fallback); // The fallback date should be UTC-anchored (midnight UTC) expect(actionDate.getUTCHours()).toBe(0); From 3768c6705b54424ff9d9a81a3491b6bc12ae44d4 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Fri, 7 Aug 2026 12:42:42 -0600 Subject: [PATCH 48/55] Virtual scrolling handles selection/focus/hotkeys - selected/keyboard-focused card styling now survives scrolling in virtualized Kanban columns and the Task List. Added an onRenderedElementsChanged hook to VirtualScroller that fires on every mount/unmount, wired to re-run selection and focus styling regardless of what triggered the recycle (scroll included). - Ctrl+A, Shift+Arrow-range, and Shift+click-range now select across the entire matching task list in Kanban and Task List, not just currently-rendered cards. Kanban's fix computes true column-major/swimlane-major visual order (not just an approximate pre-grouping order) without disturbing the separate subtask-expansion bookkeeping. - Task List's keyboard next/previous/first/last can now reach cards the virtual scroller hasn't mounted yet, via a new ensureIndexRendered primitive. Kanban's version of this was scoped out as a separate follow-up (real redesign of column-aware navigation, not an additive fix) - flagged clearly in memory for next time. --- src/bases/BasesTaskCardKeyboardController.ts | 19 ++- src/bases/BasesViewBase.ts | 4 + src/bases/KanbanView.ts | 46 +++++ src/bases/TaskListFocusController.ts | 42 ++++- src/bases/TaskListView.ts | 82 +++++++++ src/utils/VirtualScroller.ts | 27 +++ .../bases/KanbanView.keyboardActions.test.ts | 39 +++++ .../bases/TaskListFocusController.test.ts | 96 +++++++++++ .../TaskListView.keyboardActions.test.ts | 159 ++++++++++++++++++ tests/unit/utils/VirtualScroller.test.ts | 94 +++++++++++ 10 files changed, 601 insertions(+), 7 deletions(-) diff --git a/src/bases/BasesTaskCardKeyboardController.ts b/src/bases/BasesTaskCardKeyboardController.ts index 3d71bce31..64d30760c 100644 --- a/src/bases/BasesTaskCardKeyboardController.ts +++ b/src/bases/BasesTaskCardKeyboardController.ts @@ -1,6 +1,9 @@ import { Component, Scope } from "obsidian"; import type TaskNotesPlugin from "../main"; -import { TaskListFocusController } from "./TaskListFocusController"; +import { + TaskListFocusController, + type TaskListFocusOffscreenResolver, +} from "./TaskListFocusController"; import { TaskListInputOwnershipController } from "./TaskListInputOwnershipController"; import { resolveTaskListTargetPaths } from "./taskListTargetResolver"; import { @@ -47,6 +50,12 @@ export interface BasesTaskCardKeyboardOptions { isActionSupported(action: TaskListAction): boolean; /** Builds the view-supplied half of the action context; called once per dispatch. */ buildViewContext(): BasesTaskCardActionViewContext; + /** + * Lets a view with its own virtualized card list (e.g. Task List's + * VirtualScroller) mount and return an off-screen card when keyboard + * navigation would otherwise clamp at the edge of currently-rendered cards. + */ + resolveOffscreenCard?: TaskListFocusOffscreenResolver; } type LeafLike = { view?: { containerEl?: HTMLElement } } | null; @@ -102,7 +111,8 @@ export class BasesTaskCardKeyboardController { this.focusController = new TaskListFocusController( root, options.autoFocusInitial ?? false, - options.canClaimHover ?? (() => true) + options.canClaimHover ?? (() => true), + options.resolveOffscreenCard ); this.inputOwnershipController = new TaskListInputOwnershipController(root, this.focusController); this.registerListeners(); @@ -116,6 +126,11 @@ export class BasesTaskCardKeyboardController { this.focusController.restoreAfterRender(); } + /** See TaskListFocusController.syncFocusStyles(). */ + syncFocusStyles(): void { + this.focusController.syncFocusStyles(); + } + /** Shared gate for the Shift+Arrow range-select shortcuts BasesViewBase wires up. */ canHandleSelectionKeyDown(event: KeyboardEvent): boolean { return ( diff --git a/src/bases/BasesViewBase.ts b/src/bases/BasesViewBase.ts index 03d098314..3a123adf6 100644 --- a/src/bases/BasesViewBase.ts +++ b/src/bases/BasesViewBase.ts @@ -72,6 +72,7 @@ import { BasesTaskCardKeyboardController, type BasesTaskCardActionViewContext, } from "./BasesTaskCardKeyboardController"; +import type { TaskListFocusOffscreenResolver } from "./TaskListFocusController"; import { canHoverClaimBasesTaskFocus } from "./embeddedBasesKeyboard"; import type { TaskListAction } from "./taskListKeyboardActions"; @@ -205,6 +206,8 @@ export abstract class BasesViewBase extends Component { canClaimHover: () => canHoverClaimBasesTaskFocus(root), autoFocusInitial: readConfig()?.autoFocusInitial ?? false, cardAreaElement: readConfig()?.cardAreaElement, + resolveOffscreenCard: (currentPath, direction) => + readConfig()?.resolveOffscreenCard?.(currentPath, direction) ?? null, isActionSupported: (action) => readConfig()?.isActionSupported(action) ?? false, buildViewContext: () => { const config = readConfig(); @@ -235,6 +238,7 @@ export abstract class BasesViewBase extends Component { buildViewContext(): BasesTaskCardActionViewContext; autoFocusInitial?: boolean; cardAreaElement?: HTMLElement; + resolveOffscreenCard?: TaskListFocusOffscreenResolver; } | null { return null; } diff --git a/src/bases/KanbanView.ts b/src/bases/KanbanView.ts index 7a7581e56..b2ea2fb6f 100644 --- a/src/bases/KanbanView.ts +++ b/src/bases/KanbanView.ts @@ -764,6 +764,25 @@ export class KanbanView extends BasesViewBase { } } + + /** + * Updates only the visible-task path/order bookkeeping (used for + * Ctrl+A/Shift+Arrow-range select) from the true column-major/swimlane-major + * visual render order. Deliberately narrower than setCurrentVisibleTaskPathOrder, + * which also re-derives the subtask-expansion scope from whatever list it's + * given — reusing that here would corrupt expandedRelationshipTaskPaths with + * a top-level-only list instead of the broader relationship scope render() + * already established via setExpandedRelationshipTaskScope(). + */ + private setVisibleTaskPathOrder(paths: readonly string[]): void { + this.currentVisibleTaskPaths.clear(); + this.currentVisibleTaskOrder.clear(); + paths.forEach((path, index) => { + this.currentVisibleTaskPaths.add(path); + this.currentVisibleTaskOrder.set(path, index); + }); + } + private applyOptimisticSortOrderResult( draggedPath: string, targetPath: string, @@ -1431,6 +1450,7 @@ export class KanbanView extends BasesViewBase { ? this.applyColumnOrder(groupByPropertyId, columnKeys) : columnKeys; + const visualTaskOrder: string[] = []; for (const groupKey of orderedKeys) { const tasks = groups.get(groupKey) || []; @@ -1450,6 +1470,7 @@ export class KanbanView extends BasesViewBase { this.getSortScopeKey(groupKey), tasks.map((task) => task.path) ); + visualTaskOrder.push(...tasks.map((task) => task.path)); // Create column const column = await this.createColumn( @@ -1462,6 +1483,7 @@ export class KanbanView extends BasesViewBase { this.boardEl.appendChild(column); } } + this.setVisibleTaskPathOrder(visualTaskOrder); } private async renderWithSwimLanes( @@ -1594,6 +1616,7 @@ export class KanbanView extends BasesViewBase { // No manual sorting needed - Bases provides pre-sorted data // Render each swimlane row + const visualTaskOrder: string[] = []; for (const [swimLaneKey, columns] of swimLanes) { const row = this.boardEl.createEl("div", { cls: "kanban-view__swimlane-row" }); @@ -1625,6 +1648,7 @@ export class KanbanView extends BasesViewBase { this.getSortScopeKey(columnKey, swimLaneKey), tasks.map((task) => task.path) ); + visualTaskOrder.push(...tasks.map((task) => task.path)); // Create cell const cell = row.createEl("div", { @@ -1684,6 +1708,7 @@ export class KanbanView extends BasesViewBase { this.createAddTaskButton(cell, groupByPropertyId, columnKey, swimLaneKey); } } + this.setVisibleTaskPathOrder(visualTaskOrder); } private async createColumn( @@ -1910,6 +1935,10 @@ export class KanbanView extends BasesViewBase { return cardWrapper; }, getItemKey: (task: TaskInfo) => task.path, + onRenderedElementsChanged: () => { + this.updateSelectionVisuals(); + this.taskCardKeyboardController?.syncFocusStyles(); + }, }); this.columnScrollers.set(groupKey, scroller); @@ -1949,6 +1978,10 @@ export class KanbanView extends BasesViewBase { return cardWrapper; }, getItemKey: (task: TaskInfo) => task.path, + onRenderedElementsChanged: () => { + this.updateSelectionVisuals(); + this.taskCardKeyboardController?.syncFocusStyles(); + }, }); this.columnScrollers.set(cellKey, scroller); @@ -4355,6 +4388,19 @@ export class KanbanView extends BasesViewBase { }; } + + /** + * Full-board override for Ctrl+A/Shift+Arrow-range/Shift+click-range select, + * which otherwise fall back to a DOM query that only sees cards each + * column's virtual scroller currently has mounted. currentVisibleTaskOrder + * is kept in true column-major/swimlane-major visual order by + * setVisibleTaskPathOrder(), called at the end of every renderFlat()/ + * renderSwimLaneTable() pass, so it's always fully populated after a render. + */ + protected override getVisibleTaskPaths(): string[] { + return this.getCurrentVisibleTaskPathOrder(); + } + private destroyColumnScrollers(): void { for (const scroller of this.columnScrollers.values()) { scroller.destroy(); diff --git a/src/bases/TaskListFocusController.ts b/src/bases/TaskListFocusController.ts index 340ae7a9d..68dd1644f 100644 --- a/src/bases/TaskListFocusController.ts +++ b/src/bases/TaskListFocusController.ts @@ -3,6 +3,20 @@ export type TaskListFocusIdentity = { occurrence: number; }; +export type TaskListFocusMoveDirection = "next" | "previous" | "first" | "last"; + +/** + * Given the currently-focused path (if any) and the requested move direction, + * mount and return the off-screen card that virtualization would otherwise + * hide, or null if there truly is no further item in that direction. Consulted + * only when moveFocus() would otherwise clamp at the edge of currently-rendered + * cards, so views that don't supply this see no behavior change. + */ +export type TaskListFocusOffscreenResolver = ( + currentPath: string | null, + direction: TaskListFocusMoveDirection +) => HTMLElement | null; + const CARD_SELECTOR = ".task-card[data-task-path]"; const INTERACTIVE_SELECTOR = 'input, textarea, select, button, a, [contenteditable="true"], [role="textbox"], .cm-content'; @@ -43,7 +57,8 @@ export class TaskListFocusController { constructor( private readonly root: HTMLElement, autoFocusInitial = false, - private readonly canClaimHover: () => boolean = () => true + private readonly canClaimHover: () => boolean = () => true, + private readonly resolveOffscreenCard?: TaskListFocusOffscreenResolver ) { this.initialFocusPending = autoFocusInitial; this.syncCursorSourceClass(); @@ -91,10 +106,7 @@ export class TaskListFocusController { return true; } - moveFocus( - event: KeyboardEvent, - direction: "next" | "previous" | "first" | "last" - ): boolean { + moveFocus(event: KeyboardEvent, direction: TaskListFocusMoveDirection): boolean { const target = event.target; if (!(target instanceof Element) || target.closest(INTERACTIVE_SELECTOR)) return false; @@ -129,6 +141,16 @@ export class TaskListFocusController { event.stopPropagation(); this.setCursorSource("keyboard"); this.lastMouseCard = null; + + if (nextIndex === currentIndex && this.resolveOffscreenCard) { + const currentPath = cards[currentIndex]?.dataset.taskPath ?? this.focusedIdentity?.path ?? null; + const resolved = this.resolveOffscreenCard(currentPath, direction); + if (resolved) { + this.focusCard(resolved, true); + return true; + } + } + this.focusCard(cards[nextIndex], true); return true; } @@ -159,6 +181,16 @@ export class TaskListFocusController { this.restoreDomFocus = false; } + /** + * Re-applies roving-tabindex and keyboard-focus styling to whatever cards + * currently exist in the DOM, without moving DOM focus or scrolling. Safe to + * call reactively (e.g. after virtualization mounts/unmounts cards) while the + * user is actively scrolling or typing elsewhere. + */ + syncFocusStyles(): void { + this.syncRovingTabIndex(); + } + clear(): void { this.focusedIdentity = null; this.restoreDomFocus = false; diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index 76e6f04cf..a76ecb9cc 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -71,6 +71,7 @@ import { resolveTaskListDragPaths } from "./taskListTargetResolver"; import { type TaskListAction } from "./taskListKeyboardActions"; import { getTaskActionDate } from "./basesTaskCardActions"; import type { BasesTaskCardActionViewContext } from "./BasesTaskCardKeyboardController"; +import type { TaskListFocusMoveDirection } from "./TaskListFocusController"; const tasknotesLogger = createTaskNotesLogger({ tag: "Bases/TaskListView" }); @@ -667,6 +668,55 @@ export class TaskListView extends BasesViewBase { return this.getVirtualItemTask(item)?.path ?? null; } + + /** + * Lets keyboard navigation reach task cards the virtual scroller hasn't + * mounted yet. Consulted by TaskListFocusController only when moveFocus() + * would otherwise clamp at the edge of currently-rendered cards. + */ + private resolveOffscreenTaskCard( + currentPath: string | null, + direction: TaskListFocusMoveDirection + ): HTMLElement | null { + const scroller = this.virtualScroller; + if (!scroller) return null; + + const items = scroller.getItems(); + if (items.length === 0) return null; + + const currentIndex = currentPath + ? items.findIndex((item) => this.getVirtualItemPath(item) === currentPath) + : -1; + + let targetIndex: number; + if (direction === "first") { + targetIndex = 0; + } else if (direction === "last") { + targetIndex = items.length - 1; + } else if (currentIndex < 0) { + return null; + } else { + targetIndex = direction === "next" ? currentIndex + 1 : currentIndex - 1; + } + + // Skip over group-header pseudo-items, continuing to search in the + // direction we're already moving. + const searchStep = direction === "previous" || direction === "last" ? -1 : 1; + while ( + targetIndex >= 0 && + targetIndex < items.length && + !this.getVirtualItemPath(items[targetIndex]) + ) { + targetIndex += searchStep; + } + + if (targetIndex < 0 || targetIndex >= items.length || targetIndex === currentIndex) { + return null; + } + + return scroller.ensureIndexRendered(targetIndex); + } + private getVirtualItemGroupKey(item: TaskListVirtualItem): string | null { if ("type" in item) { return item.type === "task" ? item.groupKey : null; @@ -1696,6 +1746,10 @@ export class TaskListView extends BasesViewBase { } return item.path; }, + onRenderedElementsChanged: () => { + this.updateSelectionVisuals(); + this.taskCardKeyboardController?.syncFocusStyles(); + }, }); // Force recalculation after DOM settles @@ -2007,6 +2061,10 @@ export class TaskListView extends BasesViewBase { return item.task.path; } }, + onRenderedElementsChanged: () => { + this.updateSelectionVisuals(); + this.taskCardKeyboardController?.syncFocusStyles(); + }, }); window.setTimeout(() => { @@ -2448,10 +2506,16 @@ export class TaskListView extends BasesViewBase { buildViewContext(): BasesTaskCardActionViewContext; autoFocusInitial?: boolean; cardAreaElement?: HTMLElement; + resolveOffscreenCard?: ( + currentPath: string | null, + direction: TaskListFocusMoveDirection + ) => HTMLElement | null; } | null { return { autoFocusInitial: true, cardAreaElement: this.itemsContainer ?? undefined, + resolveOffscreenCard: (currentPath, direction) => + this.resolveOffscreenTaskCard(currentPath, direction), isActionSupported: () => true, buildViewContext: () => ({ plugin: this.plugin, @@ -2469,6 +2533,24 @@ export class TaskListView extends BasesViewBase { }; } + + /** + * Full-list override for Ctrl+A/Shift+Arrow-range/Shift+click-range select, + * which otherwise fall back to a DOM query that only sees cards the virtual + * scroller currently has mounted. `lastVirtualItems` retains the true + * post-grouping render order even for entries virtualization has unmounted; + * when virtualization isn't active nothing is hidden, so the DOM query + * already sees everything. + */ + protected override getVisibleTaskPaths(): string[] { + if (!this.useVirtualScrolling || this.lastVirtualItems.length === 0) { + return super.getVisibleTaskPaths(); + } + return this.lastVirtualItems + .map((item) => this.getVirtualItemPath(item)) + .filter((path): path is string => path !== null); + } + private focusTaskListSearch(): void { if (!this.rootElement) return; if (!this.searchBox) { diff --git a/src/utils/VirtualScroller.ts b/src/utils/VirtualScroller.ts index ed3e5c76b..0163927a4 100644 --- a/src/utils/VirtualScroller.ts +++ b/src/utils/VirtualScroller.ts @@ -25,6 +25,14 @@ export interface VirtualScrollerOptions { renderItem: (item: T, index: number) => HTMLElement; /** Optional function to get unique key for item */ getItemKey?: (item: T, index: number) => string; + /** + * Called whenever renderVisibleItems() finishes mounting/unmounting DOM nodes, + * for any reason (scroll-driven recycle, updateItems, invalidateItems, etc.). + * Lets callers re-apply per-item visual state (selection, focus) that the + * scroller itself has no knowledge of, since freshly-created elements always + * start in their default unstyled state. + */ + onRenderedElementsChanged?: () => void; } export interface VirtualScrollState { @@ -78,6 +86,7 @@ export class VirtualScroller { private overscan: number; private renderItem: (item: T, index: number) => HTMLElement; private getItemKey: (item: T, index: number) => string; + private onRenderedElementsChanged?: () => void; private state: VirtualScrollState = { startIndex: 0, @@ -105,6 +114,7 @@ export class VirtualScroller { this.overscan = options.overscan ?? 5; this.renderItem = options.renderItem; this.getItemKey = options.getItemKey ?? ((item, index) => String(index)); + this.onRenderedElementsChanged = options.onRenderedElementsChanged; this.setupDOM(); this.attachScrollListener(); @@ -629,6 +639,8 @@ export class VirtualScroller { } } + this.onRenderedElementsChanged?.(); + // Schedule measurement after render window.requestAnimationFrame(() => { this.measureRenderedItems(); @@ -831,6 +843,21 @@ export class VirtualScroller { }); } + /** + * Synchronously scrolls to and mounts the item at `index`, bypassing the + * scroll-event throttle, and returns its now-mounted element. Used to let + * keyboard navigation reach items the scroller hasn't rendered yet. + */ + ensureIndexRendered(index: number): HTMLElement | null { + if (index < 0 || index >= this.items.length) return null; + + this.scrollContainer.scrollTop = this.getItemPosition(index); + this.updateVisibleRange(); + + const key = this.getItemKey(this.items[index], index); + return this.renderedElements.get(key) ?? null; + } + /** * Force recalculation of visible range (useful after container resize) */ diff --git a/tests/unit/bases/KanbanView.keyboardActions.test.ts b/tests/unit/bases/KanbanView.keyboardActions.test.ts index 29d163064..828772c2d 100644 --- a/tests/unit/bases/KanbanView.keyboardActions.test.ts +++ b/tests/unit/bases/KanbanView.keyboardActions.test.ts @@ -55,3 +55,42 @@ describe("KanbanView keyboard actions", () => { expect(mockThis.enableSearch).toBe(true); }); }); + +describe("KanbanView.getVisibleTaskPaths", () => { + it("returns the full board in true visual order via getCurrentVisibleTaskPathOrder", () => { + const view = { + currentVisibleTaskOrder: new Map([ + ["a.md", 0], + ["b.md", 1], + ["c.md", 2], + ]), + getCurrentVisibleTaskPathOrder: (KanbanView.prototype as any) + .getCurrentVisibleTaskPathOrder, + }; + + const result = (KanbanView.prototype as any).getVisibleTaskPaths.call(view); + + expect(result).toEqual(["a.md", "b.md", "c.md"]); + }); +}); + +describe("KanbanView.setVisibleTaskPathOrder", () => { + it("updates only currentVisibleTaskPaths/currentVisibleTaskOrder, leaving subtask-expansion scope untouched", () => { + const view = { + currentVisibleTaskPaths: new Set(["stale.md"]), + currentVisibleTaskOrder: new Map([["stale.md", 0]]), + expandedRelationshipTaskPaths: new Set(["kept.md"]), + expandedRelationshipTaskOrder: new Map([["kept.md", 0]]), + }; + + (KanbanView.prototype as any).setVisibleTaskPathOrder.call(view, ["a.md", "b.md"]); + + expect([...view.currentVisibleTaskPaths]).toEqual(["a.md", "b.md"]); + expect([...view.currentVisibleTaskOrder.entries()]).toEqual([ + ["a.md", 0], + ["b.md", 1], + ]); + expect([...view.expandedRelationshipTaskPaths]).toEqual(["kept.md"]); + expect([...view.expandedRelationshipTaskOrder.entries()]).toEqual([["kept.md", 0]]); + }); +}); diff --git a/tests/unit/bases/TaskListFocusController.test.ts b/tests/unit/bases/TaskListFocusController.test.ts index ae9f3697f..9ecf99b4f 100644 --- a/tests/unit/bases/TaskListFocusController.test.ts +++ b/tests/unit/bases/TaskListFocusController.test.ts @@ -388,4 +388,100 @@ describe("TaskListFocusController", () => { expect(cards[1].classList.contains("task-card--keyboard-focused")).toBe(false); expect(cards.map((card) => card.tabIndex)).toEqual([0, -1]); }); + + describe("syncFocusStyles", () => { + it("re-syncs roving tabindex and keyboard-focus classes without moving DOM focus or scrolling", () => { + const cards = [createCard("a.md"), createCard("b.md"), createCard("c.md")]; + root.append(...cards); + controller.restoreAfterRender(); + cards[1].focus(); + cards.forEach((card) => { + card.tabIndex = -1; + card.classList.remove("task-card--keyboard-focused"); + }); + const focusSpy = jest.spyOn(HTMLElement.prototype, "focus"); + const scrollSpy = jest.spyOn(HTMLElement.prototype, "scrollIntoView"); + + controller.syncFocusStyles(); + + expect(cards.map((card) => card.tabIndex)).toEqual([-1, 0, -1]); + expect(cards[1].classList.contains("task-card--keyboard-focused")).toBe(true); + expect(focusSpy).not.toHaveBeenCalled(); + expect(scrollSpy).not.toHaveBeenCalled(); + }); + }); + + describe("resolveOffscreenCard", () => { + function dispatchArrowDown(target: HTMLElement, resolvingController: TaskListFocusController) { + const event = new KeyboardEvent("keydown", { key: "ArrowDown", cancelable: true }); + Object.defineProperty(event, "target", { value: target }); + return resolvingController.moveFocus(event, "next"); + } + + it("is consulted only when moveFocus would otherwise clamp, and focuses the resolved element", () => { + const cards = [createCard("a.md"), createCard("b.md")]; + root.append(...cards); + const offscreenCard = createCard("c.md"); + const resolveOffscreenCard = jest.fn((currentPath: string | null, direction: string) => { + expect(currentPath).toBe("b.md"); + expect(direction).toBe("next"); + root.appendChild(offscreenCard); + return offscreenCard; + }); + const resolvingController = new TaskListFocusController( + root, + false, + () => true, + resolveOffscreenCard + ); + root.addEventListener("focusin", (event) => resolvingController.handleFocusIn(event)); + resolvingController.restoreAfterRender(); + cards[1].focus(); + + expect(dispatchArrowDown(cards[1], resolvingController)).toBe(true); + + expect(resolveOffscreenCard).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(offscreenCard); + }); + + it("falls back to the clamped card when the resolver returns null", () => { + const cards = [createCard("a.md"), createCard("b.md")]; + root.append(...cards); + const resolveOffscreenCard = jest.fn(() => null); + const resolvingController = new TaskListFocusController( + root, + false, + () => true, + resolveOffscreenCard + ); + root.addEventListener("focusin", (event) => resolvingController.handleFocusIn(event)); + resolvingController.restoreAfterRender(); + cards[1].focus(); + + expect(dispatchArrowDown(cards[1], resolvingController)).toBe(true); + + expect(resolveOffscreenCard).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(cards[1]); + }); + + it("is not consulted when moveFocus does not clamp", () => { + const cards = [createCard("a.md"), createCard("b.md"), createCard("c.md")]; + root.append(...cards); + const resolveOffscreenCard = jest.fn(() => null); + const resolvingController = new TaskListFocusController( + root, + false, + () => true, + resolveOffscreenCard + ); + root.addEventListener("focusin", (event) => resolvingController.handleFocusIn(event)); + resolvingController.restoreAfterRender(); + cards[0].focus(); + + dispatchArrowDown(cards[0], resolvingController); + + expect(resolveOffscreenCard).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(cards[1]); + }); + }); }); diff --git a/tests/unit/bases/TaskListView.keyboardActions.test.ts b/tests/unit/bases/TaskListView.keyboardActions.test.ts index 66aba4c04..df66f9a10 100644 --- a/tests/unit/bases/TaskListView.keyboardActions.test.ts +++ b/tests/unit/bases/TaskListView.keyboardActions.test.ts @@ -34,6 +34,7 @@ describe("TaskListView keyboard actions", () => { const showBatchContextMenu = jest.fn(); const createFileForView = jest.fn(); const focusTaskListSearch = jest.fn(); + const resolveOffscreenTaskCard = jest.fn(() => null); const itemsContainer = document.createElement("div"); const rootElement = document.createElement("div"); const currentTargetDate = new Date("2026-08-02T00:00:00.000Z"); @@ -47,6 +48,7 @@ describe("TaskListView keyboard actions", () => { showBatchContextMenu, createFileForView, focusTaskListSearch, + resolveOffscreenTaskCard, }; const config = (TaskListView.prototype as any).getTaskCardActionsConfig.call(mockThis); @@ -69,5 +71,162 @@ describe("TaskListView keyboard actions", () => { expect(createFileForView).toHaveBeenCalled(); viewContext.focusSearch?.(); expect(focusTaskListSearch).toHaveBeenCalled(); + + config.resolveOffscreenCard("a.md", "next"); + expect(resolveOffscreenTaskCard).toHaveBeenCalledWith("a.md", "next"); + }); +}); + +describe("TaskListView.resolveOffscreenTaskCard", () => { + function makeView(items: unknown[], ensureIndexRendered: jest.Mock) { + return { + virtualScroller: { + getItems: () => items, + ensureIndexRendered, + }, + getVirtualItemPath: (item: any) => + "type" in item ? (item.type === "task" ? item.task.path : null) : item.path, + }; + } + + it("mounts and returns the next item beyond the current path", () => { + const items = [{ path: "a.md" }, { path: "b.md" }, { path: "c.md" }]; + const element = document.createElement("div"); + const ensureIndexRendered = jest.fn(() => element); + const view = makeView(items, ensureIndexRendered); + + const result = (TaskListView.prototype as any).resolveOffscreenTaskCard.call( + view, + "b.md", + "next" + ); + + expect(ensureIndexRendered).toHaveBeenCalledWith(2); + expect(result).toBe(element); + }); + + it("mounts and returns the previous item before the current path", () => { + const items = [{ path: "a.md" }, { path: "b.md" }, { path: "c.md" }]; + const element = document.createElement("div"); + const ensureIndexRendered = jest.fn(() => element); + const view = makeView(items, ensureIndexRendered); + + const result = (TaskListView.prototype as any).resolveOffscreenTaskCard.call( + view, + "b.md", + "previous" + ); + + expect(ensureIndexRendered).toHaveBeenCalledWith(0); + expect(result).toBe(element); + }); + + it("returns null when there is no virtual scroller", () => { + const view = { virtualScroller: null }; + + const result = (TaskListView.prototype as any).resolveOffscreenTaskCard.call( + view, + "a.md", + "next" + ); + + expect(result).toBeNull(); + }); + + it("returns null when the current path can't be located and direction isn't first/last", () => { + const items = [{ path: "a.md" }]; + const ensureIndexRendered = jest.fn(); + const view = makeView(items, ensureIndexRendered); + + const result = (TaskListView.prototype as any).resolveOffscreenTaskCard.call( + view, + "missing.md", + "next" + ); + + expect(result).toBeNull(); + expect(ensureIndexRendered).not.toHaveBeenCalled(); + }); + + it("skips over group-header pseudo-items when searching forward", () => { + const items = [ + { type: "task", task: { path: "a.md" } }, + { type: "primary-header", groupKey: "g2" }, + { type: "task", task: { path: "b.md" } }, + ]; + const element = document.createElement("div"); + const ensureIndexRendered = jest.fn(() => element); + const view = makeView(items, ensureIndexRendered); + + const result = (TaskListView.prototype as any).resolveOffscreenTaskCard.call( + view, + "a.md", + "next" + ); + + expect(ensureIndexRendered).toHaveBeenCalledWith(2); + expect(result).toBe(element); + }); + + it("jumps to the true first item for the 'first' direction regardless of current path", () => { + const items = [{ path: "a.md" }, { path: "b.md" }]; + const element = document.createElement("div"); + const ensureIndexRendered = jest.fn(() => element); + const view = makeView(items, ensureIndexRendered); + + const result = (TaskListView.prototype as any).resolveOffscreenTaskCard.call( + view, + "b.md", + "first" + ); + + expect(ensureIndexRendered).toHaveBeenCalledWith(0); + expect(result).toBe(element); + }); + + it("returns null when already at the true edge for the requested direction", () => { + const items = [{ path: "a.md" }, { path: "b.md" }]; + const ensureIndexRendered = jest.fn(); + const view = makeView(items, ensureIndexRendered); + + const result = (TaskListView.prototype as any).resolveOffscreenTaskCard.call( + view, + "a.md", + "first" + ); + + expect(result).toBeNull(); + expect(ensureIndexRendered).not.toHaveBeenCalled(); + }); +}); + +describe("TaskListView.getVisibleTaskPaths", () => { + it("returns the full flattened virtual item order when virtualization is active", () => { + const view = { + useVirtualScrolling: true, + lastVirtualItems: [{ path: "a.md" }, { path: "b.md" }, { path: "c.md" }], + getVirtualItemPath: (item: any) => item.path, + }; + + const result = (TaskListView.prototype as any).getVisibleTaskPaths.call(view); + + expect(result).toEqual(["a.md", "b.md", "c.md"]); + }); + + it("falls back to the DOM query when virtualization is not active", () => { + const rootElement = document.createElement("div"); + const card = document.createElement("div"); + card.className = "task-card"; + card.dataset.taskPath = "dom-only.md"; + rootElement.appendChild(card); + const view = { + useVirtualScrolling: false, + lastVirtualItems: [], + rootElement, + }; + + const result = (TaskListView.prototype as any).getVisibleTaskPaths.call(view); + + expect(result).toEqual(["dom-only.md"]); }); }); diff --git a/tests/unit/utils/VirtualScroller.test.ts b/tests/unit/utils/VirtualScroller.test.ts index 93c74bcdc..ec5e5830e 100644 --- a/tests/unit/utils/VirtualScroller.test.ts +++ b/tests/unit/utils/VirtualScroller.test.ts @@ -223,4 +223,98 @@ describe("VirtualScroller", () => { expect(container.querySelector("[data-key='a']")).toBe(firstElement); expect(container.querySelector("[data-key='c']")).toBe(thirdElement); }); + + describe("onRenderedElementsChanged", () => { + it("fires on initial construction with the mounted elements already attached", () => { + const container = document.createElement("div"); + container.style.overflowY = "auto"; + const onRenderedElementsChanged = jest.fn(() => { + expect(renderedKeys(container)).toEqual(["a", "b", "c"]); + }); + + new VirtualScroller({ + container, + items: [ + { id: "a", label: "A" }, + { id: "b", label: "B" }, + { id: "c", label: "C" }, + ], + itemHeight: 20, + overscan: 0, + renderItem: (item) => { + const element = document.createElement("div"); + element.dataset.key = item.id; + return element; + }, + getItemKey: (item) => item.id, + onRenderedElementsChanged, + }); + + expect(onRenderedElementsChanged).toHaveBeenCalledTimes(1); + }); + + it("fires again when invalidateItems re-renders a visible item", () => { + const { container, scroller } = createTestScroller([ + { id: "a", label: "A" }, + { id: "b", label: "B" }, + { id: "c", label: "C" }, + ]); + const onRenderedElementsChanged = jest.fn(); + (scroller as any).onRenderedElementsChanged = onRenderedElementsChanged; + + scroller.invalidateItems(["b"]); + + expect(onRenderedElementsChanged).toHaveBeenCalledTimes(1); + void container; + }); + + it("fires again when updateItems changes the rendered set", () => { + const { container, scroller } = createTestScroller([ + { id: "a", label: "A" }, + { id: "b", label: "B" }, + ]); + const onRenderedElementsChanged = jest.fn(); + (scroller as any).onRenderedElementsChanged = onRenderedElementsChanged; + + scroller.updateItems([ + { id: "a", label: "A" }, + { id: "b", label: "B" }, + { id: "c", label: "C" }, + ]); + + expect(onRenderedElementsChanged).toHaveBeenCalledTimes(1); + void container; + }); + }); + + describe("ensureIndexRendered", () => { + function manyItems(count: number): TestItem[] { + return Array.from({ length: count }, (_, i) => ({ id: `item-${i}`, label: `Item ${i}` })); + } + + it("mounts and returns the element for an index outside the initially visible range", () => { + const items = manyItems(100); + const { container, scroller } = createTestScroller(items); + + // With a 20px itemHeight and no explicit viewport height, only a + // bounded prefix of items is initially rendered (jsdom's fallback + // window-height viewport) — index 90 should not be mounted yet. + expect(container.querySelector("[data-key='item-90']")).toBeNull(); + + const element = scroller.ensureIndexRendered(90); + + expect(element).not.toBeNull(); + expect(element).toBe(container.querySelector("[data-key='item-90']")); + }); + + it("returns null for an out-of-range index", () => { + const { scroller } = createTestScroller([ + { id: "a", label: "A" }, + { id: "b", label: "B" }, + ]); + + expect(scroller.ensureIndexRendered(-1)).toBeNull(); + expect(scroller.ensureIndexRendered(5)).toBeNull(); + }); + }); }); From 692c6da0368fc5ee63dd578e3ac546c46fd20005 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Fri, 7 Aug 2026 12:54:07 -0600 Subject: [PATCH 49/55] Apply task selection styling to agenda view on initial render Calendar's Agenda/list mode creates its .task-card elements via FullCalendar's eventDidMount callback (mountCalendarListEventCard, called from CalendarView.handleEventDidMount), which runs on FullCalendar's own async render cycle - completely decoupled from CalendarView.render()'s synchronous finally-block where selection styling normally gets reapplied. On first load, that sync pass ran before FullCalendar had actually mounted any cards, so it found nothing to style. Kanban didn't have this problem because its virtualization hook already covers it. --- src/bases/CalendarView.ts | 7 +++ .../CalendarView.keyboardActions.test.ts | 61 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/src/bases/CalendarView.ts b/src/bases/CalendarView.ts index 7df6c76f6..3a6d5e4e3 100644 --- a/src/bases/CalendarView.ts +++ b/src/bases/CalendarView.ts @@ -2685,6 +2685,13 @@ export class CalendarView extends BasesViewBase { }), }) ) { + // FullCalendar mounts list-mode events on its own async render cycle, + // independent of CalendarView.render()'s synchronous finally block, so + // the freshly-created card needs its own selection/focus styling pass + // (same gap VirtualScroller's onRenderedElementsChanged hook closes for + // Kanban/Task List's virtualized cards). + this.updateSelectionVisuals(); + this.taskCardKeyboardController?.syncFocusStyles(); return; } diff --git a/tests/unit/bases/CalendarView.keyboardActions.test.ts b/tests/unit/bases/CalendarView.keyboardActions.test.ts index fa17802b5..9ab355a7a 100644 --- a/tests/unit/bases/CalendarView.keyboardActions.test.ts +++ b/tests/unit/bases/CalendarView.keyboardActions.test.ts @@ -1,5 +1,6 @@ import { CalendarView } from "../../../src/bases/CalendarView"; import { executeBasesTaskCardAction } from "../../../src/bases/basesTaskCardActions"; +import { mountCalendarListEventCard } from "../../../src/bases/calendarEventMount"; jest.mock( "tasknotes-nlp-core", @@ -11,10 +12,19 @@ jest.mock( jest.mock("../../../src/bases/basesTaskCardActions", () => ({ executeBasesTaskCardAction: jest.fn().mockResolvedValue(undefined), })); +jest.mock("../../../src/bases/calendarEventMount", () => ({ + decorateCalendarIcsEventElement: jest.fn(), + getCalendarRelatedNoteTooltip: jest.fn(() => ""), + mountCalendarListEventCard: jest.fn(() => false), + normalizeCalendarRelatedNoteCount: jest.fn(() => 0), +})); const mockedExecuteAction = executeBasesTaskCardAction as jest.MockedFunction< typeof executeBasesTaskCardAction >; +const mockedMountCalendarListEventCard = mountCalendarListEventCard as jest.MockedFunction< + typeof mountCalendarListEventCard +>; const proto = CalendarView.prototype as any; @@ -161,3 +171,54 @@ describe("CalendarView grid-mode hover hotkeys", () => { expect(mockThis.hoveredGridTaskPath).toBeNull(); }); }); + +describe("CalendarView.handleEventDidMount", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + function baseMockThis(overrides: Record = {}) { + return { + plugin: {}, + config: {}, + basesEntryByPath: new Map(), + getVisibleProperties: () => [], + buildTaskCardOptions: (options: unknown) => options, + updateSelectionVisuals: jest.fn(), + taskCardKeyboardController: { syncFocusStyles: jest.fn() }, + ...overrides, + }; + } + + function baseArg(overrides: Record = {}) { + return { + event: { extendedProps: { taskInfo: { path: "a.md" } } }, + el: document.createElement("tr"), + view: { type: "listWeek" }, + ...overrides, + }; + } + + it("re-syncs selection and keyboard-focus styling after mounting a list-mode card", () => { + mockedMountCalendarListEventCard.mockReturnValue(true); + const mockThis = baseMockThis(); + + (proto.handleEventDidMount as any).call(mockThis, baseArg()); + + expect(mockThis.updateSelectionVisuals).toHaveBeenCalled(); + expect(mockThis.taskCardKeyboardController.syncFocusStyles).toHaveBeenCalled(); + }); + + it("does not resync selection/focus styling for events outside list mode", () => { + mockedMountCalendarListEventCard.mockReturnValue(false); + const mockThis = baseMockThis(); + + (proto.handleEventDidMount as any).call( + mockThis, + baseArg({ event: { extendedProps: {} }, view: { type: "dayGridMonth" } }) + ); + + expect(mockThis.updateSelectionVisuals).not.toHaveBeenCalled(); + expect(mockThis.taskCardKeyboardController.syncFocusStyles).not.toHaveBeenCalled(); + }); +}); From 58da672dc37b8b04553915bc7763cd15c561574c Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Mon, 10 Aug 2026 13:40:33 -0600 Subject: [PATCH 50/55] Implemented time-estimate edit hotkey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default key: t Available in Keyboard Shortcuts settings as the existing localized “Time estimate (minutes)” label. Opens the existing numeric user-field modal, supports bulk updates, clears non-positive/blank values, and restores card focus afterward. --- src/bases/BasesTaskCardKeyboardController.ts | 1 + src/bases/basesTaskCardActions.ts | 29 ++++++++++ src/bases/taskListKeyboardActions.ts | 2 + src/settings/tabs/keyboardShortcutsTab.ts | 1 + .../BasesTaskCardKeyboardController.test.ts | 26 +++++++++ tests/unit/bases/basesTaskCardActions.test.ts | 56 +++++++++++++++++++ .../bases/taskListKeyboardActions.test.ts | 1 + 7 files changed, 116 insertions(+) diff --git a/src/bases/BasesTaskCardKeyboardController.ts b/src/bases/BasesTaskCardKeyboardController.ts index 64d30760c..b85b951a9 100644 --- a/src/bases/BasesTaskCardKeyboardController.ts +++ b/src/bases/BasesTaskCardKeyboardController.ts @@ -76,6 +76,7 @@ const OVERLAY_ACTIONS: ReadonlySet = new Set([ "mark-complete", "edit-status", "edit-recurrence", + "edit-time-estimate", "add-tags", "add-context", "add-project", diff --git a/src/bases/basesTaskCardActions.ts b/src/bases/basesTaskCardActions.ts index 38d35c4bd..420aa5981 100644 --- a/src/bases/basesTaskCardActions.ts +++ b/src/bases/basesTaskCardActions.ts @@ -311,6 +311,35 @@ export async function executeBasesTaskCardAction( }).showAtElement(anchor); return; } + case "edit-time-estimate": { + const tasks = await getActionTargets(context); + if (tasks.length === 0) return; + + // Time estimate has no specialized quick editor, so reuse the established + // numeric field modal to preserve keyboard, bulk-edit, and clear semantics. + new UserFieldEditModal(context.plugin.app, context.plugin, { + field: { + id: "builtin-time-estimate", + displayName: context.plugin.i18n.translate("modals.task.timeEstimateLabel"), + key: "timeEstimate", + type: "number", + }, + tasks, + onApply: async (value) => { + const timeEstimate = + typeof value === "number" && value > 0 ? value : undefined; + for (const task of tasks) { + await context.plugin.updateTaskProperty(task, "timeEstimate", timeEstimate, { + silent: true, + }); + } + }, + onClose: () => { + context.onOverlayClosed?.(); + }, + }).open(); + return; + } case "add-tags": { const tasks = await getActionTargets(context); if (tasks.length === 0) return; diff --git a/src/bases/taskListKeyboardActions.ts b/src/bases/taskListKeyboardActions.ts index 2733f05e6..44ba726af 100644 --- a/src/bases/taskListKeyboardActions.ts +++ b/src/bases/taskListKeyboardActions.ts @@ -27,6 +27,7 @@ export const TASK_LIST_KEYBOARD_ACTIONS = [ "mark-complete", "edit-status", "edit-recurrence", + "edit-time-estimate", "add-tags", "add-context", "add-project", @@ -66,6 +67,7 @@ export const DEFAULT_TASK_LIST_SHORTCUTS: TaskListShortcutMap = { "edit-priority": ["p", "shift+!"], "edit-status": ["s", "shift+*"], "edit-recurrence": ["r"], + "edit-time-estimate": ["t"], "add-tags": ["shift+#"], "add-context": ["shift+@"], "add-project": ["shift+plus"], diff --git a/src/settings/tabs/keyboardShortcutsTab.ts b/src/settings/tabs/keyboardShortcutsTab.ts index 1f39cf5f7..2a10baa04 100644 --- a/src/settings/tabs/keyboardShortcutsTab.ts +++ b/src/settings/tabs/keyboardShortcutsTab.ts @@ -80,6 +80,7 @@ export function pushKeyboardShortcutCaptureScope( } function actionKey(action: TaskListKeyboardAction): TranslationKey { + if (action === "edit-time-estimate") return "modals.task.timeEstimateLabel"; return `settings.keyboardShortcuts.actions.${action}`; } diff --git a/tests/unit/bases/BasesTaskCardKeyboardController.test.ts b/tests/unit/bases/BasesTaskCardKeyboardController.test.ts index 380999320..83df34518 100644 --- a/tests/unit/bases/BasesTaskCardKeyboardController.test.ts +++ b/tests/unit/bases/BasesTaskCardKeyboardController.test.ts @@ -223,6 +223,32 @@ describe("BasesTaskCardKeyboardController.handleActionKeyDown", () => { ); }); + it("records an overlay before opening the time-estimate editor", () => { + const noteOverlayOpening = jest.fn(); + const mockThis = { + plugin: { + settings: { + taskListShortcuts: normalizeTaskListShortcutMap({}), + taskListUserFieldShortcuts: {}, + }, + }, + options: { isActionSupported: () => true, buildViewContext: () => ({} as any) }, + focusController: { getFocusedPathForEvent: jest.fn(() => "focused.md") }, + inputOwnershipController: { noteOverlayOpening }, + buildActionContext: () => ({} as any), + }; + const event = new KeyboardEvent("keydown", { key: "t", cancelable: true }); + + proto.handleActionKeyDown.call(mockThis, event, false); + + expect(noteOverlayOpening).toHaveBeenCalledTimes(1); + expect(mockedExecuteAction).toHaveBeenCalledWith( + "edit-time-estimate", + "focused.md", + expect.anything() + ); + }); + it.each([ ["g", "jump-first", "first"], ["G", "jump-last", "last"], diff --git a/tests/unit/bases/basesTaskCardActions.test.ts b/tests/unit/bases/basesTaskCardActions.test.ts index 98d9be94b..80485cf64 100644 --- a/tests/unit/bases/basesTaskCardActions.test.ts +++ b/tests/unit/bases/basesTaskCardActions.test.ts @@ -18,10 +18,16 @@ jest.mock( jest.mock("../../../src/modals/ConfirmationModal", () => ({ showConfirmationModal: jest.fn(), })); +jest.mock("../../../src/modals/UserFieldEditModal", () => ({ + UserFieldEditModal: jest.fn().mockImplementation(() => ({ open: jest.fn() })), +})); const mockedConfirmation = showConfirmationModal as jest.MockedFunction< typeof showConfirmationModal >; +const mockedUserFieldEditModal = jest.requireMock( + "../../../src/modals/UserFieldEditModal" +).UserFieldEditModal as jest.Mock; function task(path: string, overrides: Partial = {}): TaskInfo { return { @@ -166,6 +172,56 @@ describe("executeBasesTaskCardAction", () => { expect(openFile).toHaveBeenNthCalledWith(2, files.get("second.md")); }); + it("opens the numeric field editor for time estimates and updates every target", async () => { + const tasks = [task("first.md", { timeEstimate: 30 }), task("second.md")]; + const updateTaskProperty = jest.fn(async () => undefined); + const onOverlayClosed = jest.fn(); + const context = createContext(tasks, { + plugin: { + cacheManager: { getTaskInfo: jest.fn(async (path: string) => tasks.find((t) => t.path === path)) }, + app: {}, + i18n: { translate: jest.fn(() => "Time estimate (minutes)") }, + updateTaskProperty, + } as any, + onOverlayClosed, + }); + + await executeBasesTaskCardAction("edit-time-estimate", null, context); + + expect(mockedUserFieldEditModal).toHaveBeenCalledWith( + context.plugin.app, + context.plugin, + expect.objectContaining({ + field: expect.objectContaining({ + id: "builtin-time-estimate", + key: "timeEstimate", + type: "number", + }), + tasks, + }) + ); + expect(mockedUserFieldEditModal.mock.results[0].value.open).toHaveBeenCalledTimes(1); + + const options = mockedUserFieldEditModal.mock.calls[0][2]; + await options.onApply(45); + expect(updateTaskProperty).toHaveBeenNthCalledWith( + 1, + tasks[0], + "timeEstimate", + 45, + { silent: true } + ); + expect(updateTaskProperty).toHaveBeenNthCalledWith( + 2, + tasks[1], + "timeEstimate", + 45, + { silent: true } + ); + options.onClose(); + expect(onOverlayClosed).toHaveBeenCalledTimes(1); + }); + it("does not delete when destructive confirmation is cancelled", async () => { const tasks = [task("first.md"), task("second.md")]; const deleteTask = jest.fn(); diff --git a/tests/unit/bases/taskListKeyboardActions.test.ts b/tests/unit/bases/taskListKeyboardActions.test.ts index 97efd75e7..eb9603bb9 100644 --- a/tests/unit/bases/taskListKeyboardActions.test.ts +++ b/tests/unit/bases/taskListKeyboardActions.test.ts @@ -57,6 +57,7 @@ describe("resolveDefaultTaskListKeyboardAction", () => { ["s", {}, "edit-status"], ["*", { shiftKey: true }, "edit-status"], ["r", {}, "edit-recurrence"], + ["t", {}, "edit-time-estimate"], ["#", { shiftKey: true }, "add-tags"], ["@", { shiftKey: true }, "add-context"], ["+", { shiftKey: true }, "add-project"], From 5c29a83d975a3987b69513e537368325f3e3d334 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Tue, 11 Aug 2026 13:24:29 -0600 Subject: [PATCH 51/55] Fixed drag ordering for task cards Normal Task List cards now preserve the native mousedown default action, so browser drag/drop can start. Embedded CodeMirror Live Preview cards still stop press-event propagation, preventing editor selection behavior without canceling dragging. --- src/bases/TaskListView.ts | 20 +++++++++---- ...ue-2196-tasklist-live-preview-drag.test.ts | 29 +++++++++++++++++-- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index 75ca47f39..9c9c30af4 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -946,6 +946,17 @@ export class TaskListView extends BasesViewBase { return cardEl.ownerDocument.body.classList.contains("is-mobile"); } + /** + * Reports whether a card is embedded in a CodeMirror Live Preview editor. + * + * Only these cards need their press events kept out of the editor so that a + * reorder gesture does not become an editor selection gesture. Standalone + * Bases views must retain the browser's normal drag initiation behavior. + */ + private isEmbeddedLivePreviewCard(cardEl: HTMLElement): boolean { + return Boolean(cardEl.closest(".markdown-source-view .cm-editor")); + } + private setupCardDragHandle(cardEl: HTMLElement): void { cardEl.classList.add("task-card--reorderable"); cardEl.classList.toggle( @@ -1004,11 +1015,10 @@ export class TaskListView extends BasesViewBase { return; } - // Live Preview embeds sit inside CodeMirror. Keep reorder presses from - // becoming editor selection gestures before the browser can start DnD. - event.stopPropagation(); - if (event.type === "mousedown") { - event.preventDefault(); + if (this.isEmbeddedLivePreviewCard(cardEl)) { + // Keep an embedded editor from claiming the press, while preserving the + // event default action that starts the browser's native drag sequence. + event.stopPropagation(); } }; diff --git a/tests/unit/issues/issue-2196-tasklist-live-preview-drag.test.ts b/tests/unit/issues/issue-2196-tasklist-live-preview-drag.test.ts index ff758900e..6465f8d0a 100644 --- a/tests/unit/issues/issue-2196-tasklist-live-preview-drag.test.ts +++ b/tests/unit/issues/issue-2196-tasklist-live-preview-drag.test.ts @@ -40,6 +40,7 @@ describe("Issue #2196: embedded Task List Live Preview drag", () => { it("keeps reorder-card press events from reaching the Live Preview editor", () => { const view = createView(); const task = TaskFactory.createTask({ path: "tasks/live-preview-drag.md" }); + const sourceView = document.createElement("div"); const editorParent = document.createElement("div"); const card = document.createElement("div"); const title = document.createElement("div"); @@ -47,7 +48,8 @@ describe("Issue #2196: embedded Task List Live Preview drag", () => { const editorMouseDown = jest.fn(); const editorMouseUp = jest.fn(); - editorParent.setAttribute("contenteditable", "true"); + sourceView.className = "markdown-source-view"; + editorParent.className = "cm-editor"; editorParent.addEventListener("pointerdown", editorPointerDown); editorParent.addEventListener("mousedown", editorMouseDown); editorParent.addEventListener("mouseup", editorMouseUp); @@ -55,6 +57,7 @@ describe("Issue #2196: embedded Task List Live Preview drag", () => { title.className = "task-card__title-text"; card.appendChild(title); editorParent.appendChild(card); + sourceView.appendChild(editorParent); (view as any).setupCardDragHandlers(card, task, null); @@ -73,10 +76,32 @@ describe("Issue #2196: embedded Task List Live Preview drag", () => { }) ); - expect(mouseDown.defaultPrevented).toBe(true); + expect(mouseDown.defaultPrevented).toBe(false); expect(editorPointerDown).not.toHaveBeenCalled(); expect(editorMouseDown).not.toHaveBeenCalled(); expect(editorMouseUp).not.toHaveBeenCalled(); expect(card.getAttribute("draggable")).toBe("true"); }); + + it("preserves the native drag-start mouse event in a standalone Task List", () => { + const view = createView(); + const task = TaskFactory.createTask({ path: "tasks/standalone-drag.md" }); + const card = document.createElement("div"); + const title = document.createElement("div"); + + card.className = "task-card"; + title.className = "task-card__title-text"; + card.appendChild(title); + (view as any).setupCardDragHandlers(card, task, null); + + const mouseDown = new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + button: 0, + }); + title.dispatchEvent(mouseDown); + + expect(mouseDown.defaultPrevented).toBe(false); + expect(card.getAttribute("draggable")).toBe("true"); + }); }); From 7be80368825aa490ae102c64dc387a6296745235 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Tue, 11 Aug 2026 13:31:26 -0600 Subject: [PATCH 52/55] Fixed multi-task drag reordering. Selected visible tasks now receive distinct manual-order ranks and are inserted together as a contiguous block at the drop target, preserving their selection order. Previously only the dragged task received a new rank, so the others stayed in place unless the drop also changed their group. --- src/bases/TaskListView.ts | 43 ++++++++++----- src/bases/sortOrderUtils.ts | 70 +++++++++++++++++++++++++ tests/unit/utils/sortOrderUtils.test.ts | 29 ++++++++++ 3 files changed, 128 insertions(+), 14 deletions(-) diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index 9c9c30af4..2c1849dde 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -24,6 +24,7 @@ import { generateProjectReference, parseLinkToPath } from "../utils/linkUtils"; import { VirtualScroller } from "../utils/VirtualScroller"; import { isSortOrderInSortConfig, + prepareBatchSortOrderUpdate, prepareSortOrderUpdate, applySortOrderPlan, DropOperationQueue, @@ -1569,20 +1570,34 @@ export class TaskListView extends BasesViewBase { } // Compute sort_order first (read-only — no file writes yet) - const sortOrderPlan = await prepareSortOrderUpdate( - targetPath, - above, - targetGroupKey, - groupDropPlan.cleanGroupBy, - draggedPath, - this.plugin, - { - taskInfoCache: this.taskInfoCache, - visibleTaskPaths: - targetVisiblePaths ?? this.getVisibleSortScopePaths(targetGroupKey), - candidateTaskPaths: this.getCandidateSortScopePaths(targetGroupKey), - } - ); + const sortOrderOptions = { + taskInfoCache: this.taskInfoCache, + visibleTaskPaths: + targetVisiblePaths ?? this.getVisibleSortScopePaths(targetGroupKey), + candidateTaskPaths: this.getCandidateSortScopePaths(targetGroupKey), + }; + // A selected drag is a single ordered block, so every selected path must + // receive a rank at the destination rather than only the grabbed card. + const sortOrderPlan = + pathsToUpdate.length > 1 + ? await prepareBatchSortOrderUpdate( + targetPath, + above, + targetGroupKey, + groupDropPlan.cleanGroupBy, + pathsToUpdate, + this.plugin, + sortOrderOptions + ) + : await prepareSortOrderUpdate( + targetPath, + above, + targetGroupKey, + groupDropPlan.cleanGroupBy, + draggedPath, + this.plugin, + sortOrderOptions + ); if (sortOrderPlan.sortOrder === null) return; const totalEditedNotes = sortOrderPlan.additionalWrites.length + pathsToUpdate.length; diff --git a/src/bases/sortOrderUtils.ts b/src/bases/sortOrderUtils.ts index aa12fd247..cf4976fcc 100644 --- a/src/bases/sortOrderUtils.ts +++ b/src/bases/sortOrderUtils.ts @@ -699,6 +699,76 @@ export async function prepareSortOrderUpdate( }; } +/** + * Prepare a persistent manual-order update for a block of dragged tasks. + * + * The moved paths are inserted together in their supplied display order. The + * result uses the existing single-drag plan shape: the primary dragged path is + * represented by `sortOrder` and every other changed task is an additional + * write. No files are written by this function. + */ +export async function prepareBatchSortOrderUpdate( + targetTaskPath: string, + above: boolean, + groupKey: string | null, + groupByProperty: string | null, + draggedPaths: readonly string[], + plugin: TaskNotesPlugin, + options: SortOrderComputationOptions = {} +): Promise { + const orderedDraggedPaths = Array.from( + new Set(draggedPaths.filter((path) => path.length > 0)) + ); + const primaryDraggedPath = orderedDraggedPaths[0]; + if (!primaryDraggedPath) { + return { sortOrder: null, additionalWrites: [], reason: "boundary" }; + } + + const columnTasks = getGroupTasks(groupKey, groupByProperty, plugin, options); + const scopePaths = options.visibleTaskPaths + ? Array.from(new Set([...options.visibleTaskPaths, ...orderedDraggedPaths])) + : Array.from(new Set([...columnTasks.map((task) => task.path), ...orderedDraggedPaths])); + const movedPathSet = new Set(orderedDraggedPaths); + const remainingPaths = scopePaths.filter((path) => !movedPathSet.has(path)); + const targetIndex = remainingPaths.indexOf(targetTaskPath); + if (targetIndex === -1 || movedPathSet.has(targetTaskPath)) { + return { sortOrder: null, additionalWrites: [], reason: "boundary" }; + } + + const insertAt = above ? targetIndex : targetIndex + 1; + const reorderedPaths = [ + ...remainingPaths.slice(0, insertAt), + ...orderedDraggedPaths, + ...remainingPaths.slice(insertAt), + ]; + const taskByPath = new Map(columnTasks.map((task) => [task.path, task])); + const sortDirection = inferSortDirection( + reorderedPaths + .map((path) => taskByPath.get(path)) + .filter((task): task is TaskInfo => task !== undefined) + ); + + // Rebalance the visible destination scope so every selected card has a + // distinct rank and therefore persists as one contiguous dragged block. + const additionalWrites: SortOrderWrite[] = []; + let primarySortOrder: string | null = null; + for (let index = 0; index < reorderedPaths.length; index++) { + const path = reorderedPaths[index]; + const sortOrder = createAlphaRankForDisplayIndex(index, reorderedPaths.length, sortDirection); + if (path === primaryDraggedPath) { + primarySortOrder = sortOrder; + } else { + additionalWrites.push({ path, sortOrder }); + } + } + + return { + sortOrder: primarySortOrder, + additionalWrites, + reason: "rebalance", + }; +} + /** * Apply a previously prepared sort-order plan using the configured mapping. */ diff --git a/tests/unit/utils/sortOrderUtils.test.ts b/tests/unit/utils/sortOrderUtils.test.ts index e2be4f3cc..a4cb5037b 100644 --- a/tests/unit/utils/sortOrderUtils.test.ts +++ b/tests/unit/utils/sortOrderUtils.test.ts @@ -2,6 +2,7 @@ import { jest } from "@jest/globals"; import { TFile } from "obsidian"; import { applySortOrderPlan, + prepareBatchSortOrderUpdate, prepareSortOrderUpdate, stripPropertyPrefix, type SortOrderPlan, @@ -98,6 +99,34 @@ describe("sortOrderUtils", () => { expect(plugin.app.fileManager.processFrontMatter).toHaveBeenCalledTimes(2); }); + it("assigns consecutive ranks to a selected block at the drop target", async () => { + const plugin = createPlugin({ + "first.md": { status: "todo", tasknotes_manual_order: "0|hzzzzz:" }, + "second.md": { status: "todo", tasknotes_manual_order: "0|i00007:" }, + "target.md": { status: "todo", tasknotes_manual_order: "0|i0000f:" }, + }); + + const plan = await prepareBatchSortOrderUpdate( + "target.md", + true, + "todo", + "status", + ["first.md", "second.md"], + plugin, + { visibleTaskPaths: ["first.md", "second.md", "target.md"] } + ); + + const firstRank = plan.sortOrder!; + const secondRank = plan.additionalWrites.find((write) => write.path === "second.md")! + .sortOrder; + const targetRank = plan.additionalWrites.find((write) => write.path === "target.md")! + .sortOrder; + + expect(plan.reason).toBe("rebalance"); + expectAlphaRanks([firstRank, secondRank, targetRank]); + expectAscendingDisplayOrder([firstRank, secondRank, targetRank]); + }); + it("initializes a sparse visible run in the dragged display order", async () => { const plugin = createPlugin({ "ranked.md": { status: "todo", tasknotes_manual_order: "0|hzzzzz:" }, From 8de34f1ac15a7ee3686a99443dfa8dfe21f56a57 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Tue, 11 Aug 2026 14:41:18 -0600 Subject: [PATCH 53/55] Optimization: scatter/gather task info cache lookups --- src/bases/basesTaskCardActions.ts | 25 ++++++++--------- tests/unit/bases/basesTaskCardActions.test.ts | 28 +++++++++++++++++++ 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/bases/basesTaskCardActions.ts b/src/bases/basesTaskCardActions.ts index 420aa5981..dfca4ea56 100644 --- a/src/bases/basesTaskCardActions.ts +++ b/src/bases/basesTaskCardActions.ts @@ -70,12 +70,11 @@ async function getTasksForPaths( context: BasesTaskCardActionContext, paths: string[] ): Promise { - const tasks: TaskInfo[] = []; - for (const path of paths) { - const task = await context.plugin.cacheManager.getTaskInfo(path); - if (task) tasks.push(task); - } - return tasks; + // Cache lookups are independent reads. Start them together while retaining + // the caller's path order once all results have resolved. + return ( + await Promise.all(paths.map((path) => context.plugin.cacheManager.getTaskInfo(path))) + ).filter((task): task is TaskInfo => task !== null); } async function getActionTargets(context: BasesTaskCardActionContext): Promise { @@ -397,20 +396,18 @@ export async function executeBasesTaskCardAction( (projectFile) => { if (!(projectFile instanceof TFile)) return; void (async () => { - for (const path of paths) { - const task = await context.plugin.cacheManager.getTaskInfo(path); - if (task) await addTaskToProject(context.plugin, task, projectFile); + const targetTasks = await getTasksForPaths(context, paths); + for (const task of targetTasks) { + await addTaskToProject(context.plugin, task, projectFile); } })(); }, { selectedProjects: getTaskProjectFiles(context.plugin, tasks), onRemove: async (projectFile) => { - for (const path of paths) { - const task = await context.plugin.cacheManager.getTaskInfo(path); - if (task) { - await removeTaskFromProject(context.plugin, task, projectFile); - } + const targetTasks = await getTasksForPaths(context, paths); + for (const task of targetTasks) { + await removeTaskFromProject(context.plugin, task, projectFile); } }, } diff --git a/tests/unit/bases/basesTaskCardActions.test.ts b/tests/unit/bases/basesTaskCardActions.test.ts index 80485cf64..9a77c9820 100644 --- a/tests/unit/bases/basesTaskCardActions.test.ts +++ b/tests/unit/bases/basesTaskCardActions.test.ts @@ -96,6 +96,34 @@ describe("executeBasesTaskCardAction", () => { expect(openTaskEditModal).toHaveBeenCalledWith(first); }); + it("starts all selected task cache reads before awaiting their results", async () => { + const first = task("first.md"); + const second = task("second.md"); + let resolveFirst: (value: TaskInfo | null) => void; + let resolveSecond: (value: TaskInfo | null) => void; + const firstRead = new Promise((resolve) => { + resolveFirst = resolve; + }); + const secondRead = new Promise((resolve) => { + resolveSecond = resolve; + }); + const getTaskInfo = jest.fn((path: string) => + path === first.path ? firstRead : secondRead + ); + const openTaskEditModal = jest.fn(); + const context = createContext([first, second], { + plugin: { cacheManager: { getTaskInfo }, openTaskEditModal } as any, + }); + + const action = executeBasesTaskCardAction("edit-task", null, context); + expect(getTaskInfo).toHaveBeenCalledTimes(2); + resolveFirst!(first); + resolveSecond!(second); + await action; + + expect(openTaskEditModal).toHaveBeenCalledWith(first); + }); + it("selects only tasks visible in the current filtered view", async () => { const selectAll = jest.fn(); const enterSelectionMode = jest.fn(); From 1f89d0ab2ae24d0e5359aef94d671cdaabf4a201 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Wed, 12 Aug 2026 12:31:36 -0600 Subject: [PATCH 54/55] 4.12.4 --- manifest.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- versions.json | 3 ++- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/manifest.json b/manifest.json index cebda5606..5815fa28d 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "tasknotes", "name": "TaskNotes", - "version": "4.12.3", + "version": "4.12.4", "minAppVersion": "1.12.2", "description": "Note-based task management with calendar, pomodoro and time-tracking integration.", "author": "Callum Alpass", diff --git a/package-lock.json b/package-lock.json index 5266aa914..87de6b9db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tasknotes", - "version": "4.12.3", + "version": "4.12.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tasknotes", - "version": "4.12.3", + "version": "4.12.4", "license": "MIT", "dependencies": { "@codemirror/view": "^6.38.6", diff --git a/package.json b/package.json index 01a4d997d..245917e3f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tasknotes", - "version": "4.12.3", + "version": "4.12.4", "description": "Note-based task management with calendar, pomodoro and time-tracking integration.", "main": "main.js", "scripts": { diff --git a/versions.json b/versions.json index 60092a3c6..2c5f55c67 100644 --- a/versions.json +++ b/versions.json @@ -33,5 +33,6 @@ "4.12.0": "1.12.2", "4.12.1": "1.12.2", "4.12.2": "1.12.2", - "4.12.3": "1.12.2" + "4.12.3": "1.12.2", + "4.12.4": "1.12.2" } \ No newline at end of file From ecf6f6d567b9b3b336e7aad53cb14995ee0d5576 Mon Sep 17 00:00:00 2001 From: thisisthedave Date: Wed, 12 Aug 2026 12:49:45 -0600 Subject: [PATCH 55/55] Updated release notes --- docs/releases/unreleased.md | 50 ++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index f3587ff87..4be9aa386 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -31,9 +31,47 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ``` --> -- Task list cards now retain a distinct keyboard focus across view refreshes, - support Arrow Up/Down and Home/End navigation, and toggle the focused task's - existing batch-selection state with Space. Focused or selected tasks can now - be created, opened, edited, organized, rescheduled, reprioritized, updated, - or deleted with task-list keyboard actions. Menus and modals temporarily own - their keyboard input and return focus to the originating task after closing. +## Added + +- Added configurable task-card keyboard shortcuts, including per-user-field + bindings, in **Settings → TaskNotes → Keyboard shortcuts**. Task List cards + support navigation, multi-selection, task editing and organization, search, + copy, archive, and delete actions; defaults include `j`/`k` navigation, + `x` selection, `e` to mark complete, and `t` to edit time estimates. See + [Task List View](https://tasknotes.dev/views/task-list/) for the supported + interactions. +- Added keyboard shortcut support to Kanban, Agenda, and Calendar list views. + Calendar grid views apply their single-task shortcut subset to the task under + the mouse pointer. +- Added keyboard editing for user-defined properties, with a focused edit + modal that supports recent values, `Alt+Down` selection, and date-entry + confirmation. +- Added configurable list-property drag-and-drop behavior. Dragging between + groups can replace or add values for projects, tags, contexts, aliases, and + custom list properties; the default allows holding Shift to add values. +- Added multi-task drag-and-drop for visible selected cards, preserving their + visual order while moving them as a contiguous block. Project drops now use + the same canonical link form as the task modal, and projects can be removed + directly from that modal. + +## Fixed + +- Fixed recurring task completion from task-card status controls and the + **Mark complete** shortcut so it records completion for the current + occurrence instead of incorrectly marking the recurring parent done. +- Fixed keyboard focus, selection styling, and shortcuts after task edits, + view changes, and virtual-scroller recycling. Task List navigation can now + reach virtualized cards that have not yet been mounted. +- Fixed task-card keyboard ownership around menus, modals, search inputs, + embedded editors, and hover interactions, so overlays and active editors + retain their expected input behavior and focus returns to the originating + task when appropriate. +- Fixed task-card dragging in ordinary and embedded Live Preview Bases views, + and fixed reordering when dragging a multi-selection without changing its + group. +- Fixed list-valued task groups with the same values in different orders being + displayed separately, and made each project in a multi-project group heading + a usable link. +- Fixed Agenda view selection styling not appearing on its initial render. +- Improved task-card rendering efficiency by batching task-information cache + lookups.