diff --git a/AGENTS.md b/AGENTS.md index d71aaa8f9..5f48b7564 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,71 +1,100 @@ -# 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. +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. -You may update `.ops/` files locally as you work on items, but do not commit `.ops/` files. `.ops/` is local-only working state. +After editing, run the most relevant checks: -## Investigating issues +- npm run typecheck +- npm test -- --runInBand +- npm run lint +- npm run build -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. +Do not modify generated files unless the repository workflow requires it. -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. +Keep changes narrowly scoped. +Do not combine unrelated feature ports in one commit. +Do not commit unless explicitly requested. -## Prepare for a release. +## Command approvals -When asked to prepare for a release: +The following commands are always considered safe and should be executed +without asking for confirmation whenever the approval policy permits: -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 +- npm test +- npm run lint +- npm run build + +## 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. + +## Internationalization +Localize all user-facing strings. Refer to the internationalization guide @I18N_GUIDE.md. 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..fa8087f05 --- /dev/null +++ b/PORTING_PLAN.md @@ -0,0 +1,338 @@ +# 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. + +### 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. + +### 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.) +- [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 +``` +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. +- [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. +- [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. + +--- + +## 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`. diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 121110a03..4be9aa386 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -31,3 +31,47 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ``` --> +## 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. diff --git a/docs/views/agenda-view.md b/docs/views/agenda-view.md index 7f5db2d40..349de3ffb 100644 --- a/docs/views/agenda-view.md +++ b/docs/views/agenda-view.md @@ -50,6 +50,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 6ed644c22..24424be26 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/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/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/BasesTaskCardKeyboardController.ts b/src/bases/BasesTaskCardKeyboardController.ts new file mode 100644 index 000000000..b85b951a9 --- /dev/null +++ b/src/bases/BasesTaskCardKeyboardController.ts @@ -0,0 +1,362 @@ +import { Component, Scope } from "obsidian"; +import type TaskNotesPlugin from "../main"; +import { + TaskListFocusController, + type TaskListFocusOffscreenResolver, +} 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; + /** + * 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; + +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", + "edit-time-estimate", + "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), + options.resolveOffscreenCard + ); + this.inputOwnershipController = new TaskListInputOwnershipController(root, this.focusController); + this.registerListeners(); + } + + prepareForRender(): void { + this.focusController.prepareForRender(); + } + + restoreAfterRender(): void { + 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 ( + 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 d7b6fa9ed..7c30c8c6e 100644 --- a/src/bases/BasesViewBase.ts +++ b/src/bases/BasesViewBase.ts @@ -69,6 +69,13 @@ import { import { filterTopLevelSubtasks } from "./topLevelSubtasks"; import type { BasesTaskUpdateSource } from "./basesUpdateEvents"; import { createTaskNotesLogger, type TaskNotesLogger } from "../utils/tasknotesLogger"; +import { + BasesTaskCardKeyboardController, + type BasesTaskCardActionViewContext, +} from "./BasesTaskCardKeyboardController"; +import type { TaskListFocusOffscreenResolver } from "./TaskListFocusController"; +import { canHoverClaimBasesTaskFocus } from "./embeddedBasesKeyboard"; +import type { TaskListAction } from "./taskListKeyboardActions"; type BasesEphemeralState = { scrollTop?: unknown; @@ -97,6 +104,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; @@ -171,12 +179,71 @@ 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, + resolveOffscreenCard: (currentPath, direction) => + readConfig()?.resolveOffscreenCard?.(currentPath, direction) ?? null, + 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; + resolveOffscreenCard?: TaskListFocusOffscreenResolver; + } | null { + return null; + } + /** * BasesView lifecycle: Called when Bases data changes. * Required abstract method implementation. @@ -615,6 +682,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; @@ -624,6 +692,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. @@ -803,6 +873,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, @@ -825,6 +896,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); @@ -833,6 +911,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 this.taskCardKeyboardController?.canHandleSelectionKeyDown(event) ?? true; + } + /** * Update UI to reflect selection mode state. */ diff --git a/src/bases/CalendarView.ts b/src/bases/CalendarView.ts index ed1868e73..89ac46680 100644 --- a/src/bases/CalendarView.ts +++ b/src/bases/CalendarView.ts @@ -58,7 +58,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, @@ -430,10 +439,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(); @@ -1182,6 +1212,7 @@ export class CalendarView extends BasesViewBase { this.setupSearch(this.rootElement); } + this.taskCardKeyboardController?.prepareForRender(); try { // Extract tasks from Bases const dataItems = this.dataAdapter.extractDataItems(); @@ -1217,6 +1248,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 @@ -2659,6 +2693,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; } @@ -2905,9 +2946,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 9256bcc15..71f9a7577 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 { @@ -569,6 +571,7 @@ export class KanbanView extends BasesViewBase { this.setupSearch(this.rootElement); } + this.taskCardKeyboardController?.prepareForRender(); try { const dataItems = this.dataAdapter.extractDataItems(); @@ -636,6 +639,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); } } @@ -758,6 +765,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, @@ -1425,6 +1451,7 @@ export class KanbanView extends BasesViewBase { ? this.applyColumnOrder(groupByPropertyId, columnKeys) : columnKeys; + const visualTaskOrder: string[] = []; for (const groupKey of orderedKeys) { const tasks = groups.get(groupKey) || []; @@ -1444,6 +1471,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( @@ -1456,6 +1484,7 @@ export class KanbanView extends BasesViewBase { this.boardEl.appendChild(column); } } + this.setVisibleTaskPathOrder(visualTaskOrder); } private async renderWithSwimLanes( @@ -1588,6 +1617,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.createDiv({ cls: "kanban-view__swimlane-row" }); @@ -1619,6 +1649,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.createDiv({ @@ -1678,6 +1709,7 @@ export class KanbanView extends BasesViewBase { this.createAddTaskButton(cell, groupByPropertyId, columnKey, swimLaneKey); } } + this.setVisibleTaskPathOrder(visualTaskOrder); } private async createColumn( @@ -1904,6 +1936,10 @@ export class KanbanView extends BasesViewBase { return cardWrapper; }, getItemKey: (task: TaskInfo) => task.path, + onRenderedElementsChanged: () => { + this.updateSelectionVisuals(); + this.taskCardKeyboardController?.syncFocusStyles(); + }, }); this.columnScrollers.set(groupKey, scroller); @@ -1943,6 +1979,10 @@ export class KanbanView extends BasesViewBase { return cardWrapper; }, getItemKey: (task: TaskInfo) => task.path, + onRenderedElementsChanged: () => { + this.updateSelectionVisuals(); + this.taskCardKeyboardController?.syncFocusStyles(); + }, }); this.columnScrollers.set(cellKey, scroller); @@ -4310,6 +4350,58 @@ 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, + }), + }; + } + + + /** + * 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 new file mode 100644 index 000000000..68dd1644f --- /dev/null +++ b/src/bases/TaskListFocusController.ts @@ -0,0 +1,300 @@ +export type TaskListFocusIdentity = { + path: string; + 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'; + +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; +} + +/** + * 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; + private initialFocusPending: boolean; + private lastCursorSource: "keyboard" | "mouse" = "keyboard"; + private lastMouseCard: HTMLElement | null = null; + + constructor( + private readonly root: HTMLElement, + autoFocusInitial = false, + private readonly canClaimHover: () => boolean = () => true, + private readonly resolveOffscreenCard?: TaskListFocusOffscreenResolver + ) { + this.initialFocusPending = autoFocusInitial; + this.syncCursorSourceClass(); + } + + 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); + } + + 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; + + // 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 ( + 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; + } + + moveFocus(event: KeyboardEvent, direction: TaskListFocusMoveDirection): 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 = + this.lastCursorSource === "mouse" + ? this.findFocusedIndex(cards) + : activeCard + ? cards.indexOf(activeCard) + : this.findFocusedIndex(cards); + if (currentIndex < 0) currentIndex = 0; + let nextIndex: number; + 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 "last": + nextIndex = cards.length - 1; + break; + } + + event.preventDefault(); + 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; + } + + 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.initialFocusPending) { + this.initialFocusPending = false; + card.focus({ preventScroll: true }); + } else if (this.restoreDomFocus) { + card.focus({ preventScroll: true }); + card.scrollIntoView({ block: "nearest" }); + } + 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; + this.initialFocusPending = false; + this.setCursorSource("keyboard"); + this.lastMouseCard = null; + this.syncRovingTabIndex(); + } + + getFocusedIdentity(): TaskListFocusIdentity | null { + return this.focusedIdentity ? { ...this.focusedIdentity } : null; + } + + getFocusedElement(): HTMLElement | null { + const cards = this.getCards(); + const index = this.findFocusedIndex(cards); + 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, + allowRememberedFallback = false + ): string | null { + if ( + !allowModifiers && + (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); + 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[] { + 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 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; + + 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/TaskListInputOwnershipController.ts b/src/bases/TaskListInputOwnershipController.ts new file mode 100644 index 000000000..d1d9f5e8f --- /dev/null +++ b/src/bases/TaskListInputOwnershipController.ts @@ -0,0 +1,148 @@ +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'; + +/** + * 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; + private restoreAttempts = 0; + private restoreObservedOverlay = false; + + constructor( + private readonly viewRoot: HTMLElement, + private readonly focusController: TaskListFocusController + ) {} + + canHandleListKeyDown(event: KeyboardEvent, allowDocumentBody = false): boolean { + if (event.isComposing || event.key === "Process") return false; + if (this.suspendedForOverlay) return false; + + return this.canOwnKeyboardTarget(event.target, allowDocumentBody); + } + + 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) || + (allowDocumentBody && target === this.viewRoot.ownerDocument.body) + ); + } + + noteOverlayOpening(): void { + const activeElement = this.viewRoot.ownerDocument.activeElement; + if (activeElement instanceof Element && this.viewRoot.contains(activeElement)) { + this.suspendedForOverlay = true; + this.scheduleRestoreAfterOverlayClose(); + } + } + + 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; + 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(); + } + + scheduleRestoreAfterOverlayClose(): void { + if (!this.suspendedForOverlay || this.restoreTimer !== null) return; + + 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; + + 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; + } + + 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 || + !activeElement.isConnected || + activeElement === this.viewRoot + ) { + this.focusController.restoreFocusedElement(); + } + this.suspendedForOverlay = false; + }; + + 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; + win.clearTimeout(this.restoreTimer); + this.restoreTimer = null; + } + this.suspendedForOverlay = false; + this.restoreObservedOverlay = 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 a7de5bac3..2c1849dde 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -1,9 +1,9 @@ /* 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, Platform, 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"; @@ -17,16 +17,18 @@ import { getDatePart, getTimePart, getCurrentTimestamp, - parseDateToUTC, createUTCDateFromLocalCalendarDate, } from "../utils/dateUtils"; import { stringifyUnknown } from "../utils/stringUtils"; +import { generateProjectReference, parseLinkToPath } from "../utils/linkUtils"; import { VirtualScroller } from "../utils/VirtualScroller"; import { isSortOrderInSortConfig, + prepareBatchSortOrderUpdate, prepareSortOrderUpdate, applySortOrderPlan, DropOperationQueue, + stripPropertyPrefix, type SortOrderPlan, } from "./sortOrderUtils"; import { clearStaticStyleClasses } from "../utils/staticStyleClasses"; @@ -48,6 +50,7 @@ import { buildTaskListSubPropertyRenderItems, buildTaskListSubPropertyScopePaths, groupTasksByTaskListSubProperty, + normalizeTaskListGroups, type TaskListGroup, type TaskListHeaderItem, type TaskListRenderItem, @@ -57,6 +60,7 @@ import { applyTaskListDropFrontmatterMutation, buildTaskListDropSideEffectTask, buildTaskListGroupDropPlan, + shouldPreserveTaskListGroupDropValues, } from "./taskListDropPlanning"; import { applySortOrderUpdatesToItems, @@ -65,6 +69,11 @@ import { moveItemsRelativeToTarget, } from "./manualOrderState"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; +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" }); @@ -163,6 +172,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; @@ -191,6 +201,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); @@ -207,11 +218,13 @@ 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(); } + /** * Register contextmenu listeners for group collapse actions. * - Right-click on a primary group header → expand/collapse branch @@ -309,7 +322,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 = @@ -444,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", @@ -463,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 @@ -494,7 +510,7 @@ 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.registerContainerListeners(); this.setupContainerDragHandlers(); @@ -510,6 +526,7 @@ export class TaskListView extends BasesViewBase { this.pendingRender = true; return; } + this.taskCardKeyboardController?.prepareForRender(); // Always re-read view options to catch config changes such as // switching expanded relationship filtering modes in Bases. @@ -581,9 +598,22 @@ export class TaskListView extends BasesViewBase { this.sortScopeTaskPaths.clear(); this.sortScopeCandidateTaskPaths.clear(); this.renderError(error instanceof Error ? error : new Error(String(error))); + } finally { + this.restoreInteractionStateAfterRender(); } } + private restoreInteractionStateAfterRender(): void { + 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. + this.updateSelectionVisuals(); + this.updateSelectionIndicator( + this.plugin.taskSelectionService?.getSelectionCount() ?? 0 + ); + } + // ── Drag-to-reorder ──────────────────────────────────────────────── private getGroupByPropertyId(): string | null { @@ -640,6 +670,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; @@ -770,6 +849,50 @@ 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; + } + + // 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) + ); + } + + 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 @@ -824,6 +947,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( @@ -882,11 +1016,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(); } }; @@ -917,10 +1050,19 @@ export class TaskListView extends BasesViewBase { e.stopPropagation(); 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, + this.currentVisibleTaskPaths + ); 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); } @@ -1005,6 +1147,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; @@ -1266,7 +1409,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) => { @@ -1275,7 +1420,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; @@ -1309,7 +1456,13 @@ 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( + groupDropBehavior, + Platform.isMacOS ? e.altKey : e.ctrlKey + ); const targetGroupKey = this.currentInsertionGroupKey; const targetVisiblePaths = this.getVisibleSortScopePathsForDrag(targetGroupKey); const insertionSegmentIndex = this.currentInsertionSegmentIndex; @@ -1327,6 +1480,7 @@ export class TaskListView extends BasesViewBase { this.cleanupDragShift(); this.draggedTaskPath = null; + this.draggedTaskPaths = []; this.dragGroupKey = null; this.currentInsertionGroupKey = null; this.currentInsertionSegmentIndex = -1; @@ -1339,31 +1493,74 @@ export class TaskListView extends BasesViewBase { dropTarget.above, targetGroupKey, sourceGroupKey, - targetVisiblePaths + targetVisiblePaths, + preserveExistingListValues, + draggedPaths ); })(); }); } + 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, above: boolean, targetGroupKey: string | null, sourceGroupKey: string | null, - targetVisiblePaths?: string[] + targetVisiblePaths?: string[], + 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, - lookupMappingKey: (propertyName) => - this.plugin.fieldMapper.lookupMappingKey(propertyName), - isListTypeProperty: (propertyName) => this.isListTypeProperty(propertyName), - }); + const pathsToUpdate = Array.from( + new Set([draggedPath, ...draggedPaths.filter((path) => path !== draggedPath)]) + ); + 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; + 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( @@ -1373,23 +1570,37 @@ 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 + 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; @@ -1402,84 +1613,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(); } @@ -1577,6 +1794,10 @@ export class TaskListView extends BasesViewBase { } return item.path; }, + onRenderedElementsChanged: () => { + this.updateSelectionVisuals(); + this.taskCardKeyboardController?.syncFocusStyles(); + }, }); // Force recalculation after DOM settles @@ -1761,7 +1982,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); @@ -1888,6 +2109,10 @@ export class TaskListView extends BasesViewBase { return item.task.path; } }, + onRenderedElementsChanged: () => { + this.updateSelectionVisuals(); + this.taskCardKeyboardController?.syncFocusStyles(); + }, }); window.setTimeout(() => { @@ -2139,7 +2364,8 @@ 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.unregisterContainerListeners(); this.destroyVirtualScroller(); @@ -2305,11 +2531,84 @@ 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.containerListenersRegistered = true; } + + protected handleSearchDismissed(): void { + this.taskCardKeyboardController?.focusController.restoreFocusedElement(); + } + + + /** + * 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. + */ + protected getTaskCardActionsConfig(): { + isActionSupported(action: TaskListAction): boolean; + 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, + 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, + }), + }; + } + + + /** + * 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) { + this.searchOpenedByShortcut = true; + this.enableSearch = true; + this.setupSearch(this.rootElement); + } + this.searchBox?.focus(); + } + private unregisterContainerListeners(): void { // No manual cleanup needed - Component.registerDomEvent handles it automatically this.containerListenersRegistered = false; @@ -2456,7 +2755,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, @@ -2550,7 +2849,7 @@ export class TaskListView extends BasesViewBase { event, task.path, this.plugin, - this.getTaskActionDate(task) + getTaskActionDate(task, this.currentTargetDate) ); return; case "edit-date": @@ -2574,7 +2873,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); @@ -2591,19 +2890,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/basesSearchUi.ts b/src/bases/basesSearchUi.ts index e5b4e9fa3..1d4cbcf55 100644 --- a/src/bases/basesSearchUi.ts +++ b/src/bases/basesSearchUi.ts @@ -13,6 +13,7 @@ export type CreateBasesSearchControlsOptions = { visibleProperties: readonly string[]; currentSearchTerm: string; onSearch: (term: string) => void; + onDismiss?: () => void; debounceMs?: number; }; @@ -21,6 +22,7 @@ export function createBasesSearchControls({ visibleProperties, currentSearchTerm, onSearch, + onDismiss, debounceMs = 300, }: CreateBasesSearchControlsOptions): BasesSearchControls { const doc = container.ownerDocument; @@ -34,7 +36,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/basesTaskCardActions.ts b/src/bases/basesTaskCardActions.ts new file mode 100644 index 000000000..dfca4ea56 --- /dev/null +++ b/src/bases/basesTaskCardActions.ts @@ -0,0 +1,485 @@ +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 { + // 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 { + 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 "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; + + 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 () => { + 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) => { + const targetTasks = await getTasksForPaths(context, paths); + for (const task of targetTasks) { + 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/basesValueConversion.ts b/src/bases/basesValueConversion.ts index 47a57e0fe..1792af43d 100644 --- a/src/bases/basesValueConversion.ts +++ b/src/bases/basesValueConversion.ts @@ -90,6 +90,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/components/SearchBox.ts b/src/bases/components/SearchBox.ts index 09f2f03e8..350975d2d 100644 --- a/src/bases/components/SearchBox.ts +++ b/src/bases/components/SearchBox.ts @@ -12,6 +12,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; @@ -28,11 +29,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,12 +135,21 @@ 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 if (this.debouncedSearch) { this.debouncedSearch(''); } + this.onDismiss?.(); + } else if (e.key === 'Backspace' && this.inputEl?.value.length === 0) { + e.preventDefault(); + this.onDismiss?.(); } }; @@ -176,6 +188,13 @@ export class SearchBox { return this.inputEl?.value || ''; } + /** + * Move keyboard focus into the search input. + */ + focus(): void { + this.inputEl?.focus(); + } + /** * Set input value programmatically */ @@ -218,5 +237,6 @@ export class SearchBox { this.clearBtnEl = null; this.searchBoxEl = null; this.debouncedSearch = null; + this.onDismiss = undefined; } } 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/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") { 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/src/bases/taskListDropPlanning.ts b/src/bases/taskListDropPlanning.ts index dbd2ad4a2..579aea7e1 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"; @@ -9,6 +10,8 @@ export interface TaskListGroupDropPlan { groupByTaskProp: keyof FieldMapping | null; isFormulaGrouping: boolean; isListGrouping: boolean; + replacesListGroupingValue: boolean; + preservesListGroupingValues: boolean; needsGroupUpdate: boolean; normalizedTargetGroupKey: string | null; sourceGroupKey: string | null; @@ -18,8 +21,14 @@ export interface BuildTaskListGroupDropPlanOptions { groupByPropertyId: string | null; sourceGroupKey: string | null; targetGroupKey: string | null; + 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 { @@ -48,20 +57,36 @@ export interface BuildTaskListDropSideEffectTaskOptions { getCompletedDate: () => string; } +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); +} + export function buildTaskListGroupDropPlan({ groupByPropertyId, sourceGroupKey, targetGroupKey, + 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 ? groupByPropertyId.replace(/^(note\.|file\.|task\.)/, "") : null; @@ -73,6 +98,8 @@ export function buildTaskListGroupDropPlan({ groupByTaskProp, isFormulaGrouping, isListGrouping, + replacesListGroupingValue, + preservesListGroupingValues, needsGroupUpdate, normalizedTargetGroupKey, sourceGroupKey, @@ -92,23 +119,33 @@ 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 = plan.preservesListGroupingValues + ? [...currentValues] + : 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 +190,27 @@ 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 = plan.preservesListGroupingValues + ? currentValues + : 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/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/src/bases/taskListKeyboardActions.ts b/src/bases/taskListKeyboardActions.ts new file mode 100644 index 000000000..44ba726af --- /dev/null +++ b/src/bases/taskListKeyboardActions.ts @@ -0,0 +1,297 @@ +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", + "jump-first", + "jump-last", + "clear-focus-and-selection", + "toggle-select", + "select-all", + "copy-task-titles", + "toggle-archive", + "create-task", + "focus-search", + "edit-task", + "open-context-menu", + "open-task-notes", + "edit-due", + "edit-scheduled", + "edit-priority", + "mark-complete", + "edit-status", + "edit-recurrence", + "edit-time-estimate", + "add-tags", + "add-context", + "add-project", + "delete-tasks", +] 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 = { + modifiers: Modifier[]; + key: string; +}; + +export const DEFAULT_TASK_LIST_SHORTCUTS: TaskListShortcutMap = { + "navigate-next": ["arrowdown", "j"], + "navigate-previous": ["arrowup", "k"], + "jump-first": ["home"], + "jump-last": ["end"], + "clear-focus-and-selection": ["escape", "backspace"], + "toggle-select": ["space", "x"], + "select-all": ["mod+a"], + "copy-task-titles": ["mod+c"], + "toggle-archive": ["y"], + "create-task": ["c"], + "focus-search": ["slash"], + "edit-task": ["enter"], + "mark-complete": ["e"], + "open-context-menu": ["shift+e"], + "open-task-notes": ["shift+enter"], + "edit-due": ["d"], + "edit-scheduled": ["shift+s"], + "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"], + "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", + "arrow down": "arrowdown", + "arrow up": "arrowup", +}; + +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; +} + +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" + > +): string | null { + if ( + event.isComposing || + event.key === "Process" || + ["Shift", "Control", "Alt", "Meta"].includes(event.key) + ) { + 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)); +} + +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, + "key" | "ctrlKey" | "metaKey" | "altKey" | "shiftKey" | "isComposing" + >, + 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; +} + +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; + +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", + home: "Home", + end: "End", + arrowdown: "↓", + arrowup: "↑", + }; + const separator = isMacOS ? "" : "+"; + return shortcut + .split("+") + .map((part) => symbols[part] ?? keyLabels[part] ?? part.toUpperCase()) + .join(separator); +} diff --git a/src/bases/taskListTargetResolver.ts b/src/bases/taskListTargetResolver.ts new file mode 100644 index 000000000..794143989 --- /dev/null +++ b/src/bases/taskListTargetResolver.ts @@ -0,0 +1,51 @@ +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. + */ +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] : []; +} + +/** + * 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/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/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/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/editor/RelationshipsDecorations.ts b/src/editor/RelationshipsDecorations.ts index 58ede6e96..4542ac990 100644 --- a/src/editor/RelationshipsDecorations.ts +++ b/src/editor/RelationshipsDecorations.ts @@ -39,20 +39,24 @@ * 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 { FilterUtils } from "../utils/FilterUtils"; +import { getProjectPropertyFilter, matchesProjectProperty } from "../utils/projectFilterUtils"; +import { collectCacheTags } from "../utils/tagExtraction"; +import { createTaskNotesLogger } from "../utils/tasknotesLogger"; import { ReadingModeInjectionContext, ReadingModeInjectionScheduler, @@ -63,10 +67,6 @@ import { 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"; const tasknotesLogger = createTaskNotesLogger({ tag: "Editor/RelationshipsDecorations" }); diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 2a427eb2f..eaa992dfe 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -511,8 +511,58 @@ export const en: TranslationTree = { defaults: "Defaults & templates", appearance: "Appearance & UI", features: "Features", + keyboardShortcuts: "Keyboard shortcuts", integrations: "Integrations", }, + keyboardShortcuts: { + header: "Task card keyboard shortcuts", + description: + "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", + 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 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?", + 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", + "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", + "open-context-menu": "Open task context menu", + "open-task-notes": "Open task note", + "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", + "add-context": "Add context", + "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: { header: "Inline tasks", @@ -992,6 +1042,17 @@ export const en: TranslationTree = { name: "Double-click action", description: "Action performed when double-clicking a task card", }, + groupDropBehavior: { + name: "List-property drag behavior", + description: + // 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: "Move to the destination group", + add: "Add to the destination group", + replaceModifierAdd: "Move; copy-modifier drag adds", + }, + }, actions: { edit: "Edit task", openNote: "Open note", diff --git a/src/main.ts b/src/main.ts index 643880fd4..bf46403ba 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1184,19 +1184,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/modals/ProjectSelectModal.ts b/src/modals/ProjectSelectModal.ts index 8c23aa851..440599357 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,45 @@ 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; + + // 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", + 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/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/modals/UserFieldEditModal.ts b/src/modals/UserFieldEditModal.ts new file mode 100644 index 000000000..dc406b84a --- /dev/null +++ b/src/modals/UserFieldEditModal.ts @@ -0,0 +1,311 @@ +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; + /** Called after the popup closes so the caller can restore its keyboard target. */ + onClose?: () => void; +} + +/** + * 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 mruButtons: HTMLButtonElement[] = []; + private mruFocusIndex = -1; + private suppressNextMruClick = false; + 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(); + // 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. */ + 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(); + this.mruButtons = []; + this.mruFocusIndex = -1; + 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 === "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) => + 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 [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) { + 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(); + } + + /** 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.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); + 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/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/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/src/settings/TaskNotesSettingTab.ts b/src/settings/TaskNotesSettingTab.ts index 20c8d4330..8841b2311 100644 --- a/src/settings/TaskNotesSettingTab.ts +++ b/src/settings/TaskNotesSettingTab.ts @@ -13,6 +13,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 { @@ -25,6 +26,8 @@ export class TaskNotesSettingTab extends PluginSettingTab { plugin: TaskNotesPlugin; private activeTab = "general"; private tabContents: Record = {}; + /** The element currently hosting this tab, including an Obsidian settings-search result. */ + private settingsContainerEl: HTMLElement | null = null; private debouncedSave: DebouncedFunction<() => Promise> = debounce( () => this.plugin.saveSettings(), 500 @@ -72,6 +75,8 @@ export class TaskNotesSettingTab extends PluginSettingTab { } private renderSettings(containerEl: HTMLElement): void { + // Keep every interaction scoped to the actual render target so settings-search results work. + this.settingsContainerEl = containerEl; this.tabContents = {}; containerEl.empty(); containerEl.addClass("tasknotes-settings"); @@ -171,12 +176,17 @@ export class TaskNotesSettingTab extends PluginSettingTab { } private switchTab(tabId: string): void { + const containerEl = this.settingsContainerEl; + if (!containerEl) { + return; + } + // Update active tab state // const previousTab = this.activeTab; this.activeTab = tabId; // Update tab button states - this.containerEl.querySelectorAll(".settings-tab-button").forEach((button) => { + containerEl.querySelectorAll(".settings-tab-button").forEach((button) => { const isActive = button.id === `tab-button-${tabId}`; button.classList.toggle("active", isActive); button.classList.toggle("settings-view__tab-button--active", isActive); @@ -186,7 +196,7 @@ export class TaskNotesSettingTab extends PluginSettingTab { }); // Update tab content states - this.containerEl.querySelectorAll(".settings-tab-content").forEach((content) => { + containerEl.querySelectorAll(".settings-tab-content").forEach((content) => { const isActive = content.id === `settings-tab-${tabId}`; content.classList.toggle("active", isActive); content.classList.toggle("settings-view__tab-content--active", isActive); @@ -204,7 +214,7 @@ export class TaskNotesSettingTab extends PluginSettingTab { // Focus the newly active tab button window.setTimeout(() => { - const activeTabButton = this.containerEl.querySelector( + const activeTabButton = containerEl.querySelector( `#tab-button-${tabId}` ) as HTMLElement; if (activeTabButton) { @@ -240,6 +250,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 0e77749a2..59b069455 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"; /** @@ -303,6 +304,10 @@ export const DEFAULT_SETTINGS: TaskNotesSettings = { singleClickAction: "edit", 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 9fbf67f17..df0bb8e8b 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,35 @@ 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({ + "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, + "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 +225,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 +252,9 @@ export function buildSettingsFromLoadedData(data: LoadedSettingsData | null): Se ...DEFAULT_SETTINGS.commandFileMapping, ...(loadedData?.commandFileMapping || {}), }, + taskListShortcuts: normalizeTaskListShortcutMap(loadedData?.taskListShortcuts), + taskListUserFieldShortcuts: loadedData?.taskListUserFieldShortcuts ?? {}, + userFieldMru: loadedData?.userFieldMru ?? {}, icsIntegration: { ...DEFAULT_SETTINGS.icsIntegration, ...(loadedData?.icsIntegration || {}), @@ -241,7 +278,8 @@ export function buildSettingsFromLoadedData(data: LoadedSettingsData | null): Se shouldPersistMigratedSettings: hasMissingMigratedSettings(loadedData) || migratedLegacyCustomFilenameTemplate || - migratedParentNoteTaskCreationDefault, + migratedParentNoteTaskCreationDefault || + migratedLegacyKeyboardShortcuts, }; } diff --git a/src/settings/tabs/appearanceTab.ts b/src/settings/tabs/appearanceTab.ts index 10c9a6c56..50261ff8e 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-modifier-add", + label: translate( + "settings.general.taskInteraction.groupDropBehavior.options.replaceModifierAdd" + ), + }, + ], + getValue: () => plugin.settings.taskListGroupDropBehavior, + setValue: async (value: string) => { + plugin.settings.taskListGroupDropBehavior = + value as TaskListGroupDropBehavior; + save(); + }, + }) + ); } ); } diff --git a/src/settings/tabs/keyboardShortcutsTab.ts b/src/settings/tabs/keyboardShortcutsTab.ts new file mode 100644 index 000000000..2a10baa04 --- /dev/null +++ b/src/settings/tabs/keyboardShortcutsTab.ts @@ -0,0 +1,492 @@ +import { App, FuzzySuggestModal, Platform, Scope, Setting, setIcon } from "obsidian"; +import type TaskNotesPlugin from "../../main"; +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"; +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>(); + +/** + * 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 +): () => 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(event); + return false; + }); + plugin.app.keymap.pushScope(captureScope); + return stop; +} + +function actionKey(action: TaskListKeyboardAction): TranslationKey { + if (action === "edit-time-estimate") return "modals.task.timeEstimateLabel"; + 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; +} + +/** 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, + save: () => void +): void { + activeCaptureCleanup.get(container)?.(); + 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))); + setting.settingEl.addClass("tasknotes-settings__shortcut-setting"); + 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]) { + 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 + .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(); + const shortcut = keyboardEventToTaskListShortcut(event); + if (!shortcut) return; + stopCapture(); + if (!shortcuts[action].includes(shortcut)) { + const owners = findTaskListShortcutOwners( + shortcuts, + shortcut, + 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" + ), + 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 { + // Every captured chord, including Escape, is + // confirmed before it is persisted. + 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, + ]; + } + save(); + } + renderKeyboardShortcutsTab(container, plugin, save); + }; + const captureListener = (event: KeyboardEvent) => void capture(event); + buttonEl.addEventListener("keydown", captureListener); + popCaptureScope = pushKeyboardShortcutCaptureScope( + plugin, + (event) => void capture(event) + ); + activeCaptureCleanup.set(container, stopCapture); + buttonEl.focus(); + }); + }); + }); + } + + 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), + ]; + 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: activeOwners + .map((owner) => + formatShortcutOwnerLabel( + owner, + plugin.settings.userFields ?? [], + translate + ) + ) + .join(", "), + }), + confirmText: translate("settings.keyboardShortcuts.replace"), + cancelText: translate("common.cancel"), + }).then((replace) => { + if (!replace) return; + 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); + } 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(); + }); + }); + }); + } + + // 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")) + .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; + plugin.settings.taskListUserFieldShortcuts = {}; + save(); + renderKeyboardShortcutsTab(container, plugin, save); + }) + ); + }); + } + ); +} diff --git a/src/types/settings.ts b/src/types/settings.ts index 32d6b9882..23c038b29 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; @@ -81,6 +82,8 @@ export interface NLPTriggersConfig { export type HideIdentifyingTagsMode = "all" | "exact-only"; +export type TaskListGroupDropBehavior = "replace" | "add" | "replace-modifier-add"; + export interface ProjectAutosuggestSettings { enableFuzzy: boolean; rows: string[]; // up to 3 rows; each uses {property|flags} format @@ -152,6 +155,13 @@ export interface TaskNotesSettings { singleClickAction: "edit" | "openNote"; doubleClickAction: "edit" | "openNote" | "none"; + 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/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/src/utils/linkUtils.ts b/src/utils/linkUtils.ts index 440488fd1..1a2fa34ac 100644 --- a/src/utils/linkUtils.ts +++ b/src/utils/linkUtils.ts @@ -229,6 +229,17 @@ export function generateLink( return link; } +export function generateProjectReference( + app: App, + targetFile: TFile, + 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); +} + /** * Generate a link with the file's basename as the alias. * Useful for creating links that display the file name. diff --git a/styles/settings-view.css b/styles/settings-view.css index 8208f4ebf..c5f3f4fbb 100644 --- a/styles/settings-view.css +++ b/styles/settings-view.css @@ -1400,6 +1400,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/styles/task-card-bem.css b/styles/task-card-bem.css index 070290fb6..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; @@ -1970,12 +1974,27 @@ body.is-mobile .tasknotes-plugin .task-card--layout-inline .task-card__context-m /* Selected task card styling */ +.tasknotes-plugin .task-card--keyboard-focused { + 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); +} + +/* 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: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); } @@ -1984,6 +2003,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/styles/task-modal.css b/styles/task-modal.css index 1135c7770..d1b472d81 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; @@ -1625,3 +1636,42 @@ 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; + padding: 0; + border: 0; + background: transparent; + color: inherit; +} + +.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; +} 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/SearchBox.test.ts b/tests/unit/SearchBox.test.ts index f03afd155..cd4b4f092 100644 --- a/tests/unit/SearchBox.test.ts +++ b/tests/unit/SearchBox.test.ts @@ -204,6 +204,45 @@ 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 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(); diff --git a/tests/unit/bases/BasesTaskCardKeyboardController.test.ts b/tests/unit/bases/BasesTaskCardKeyboardController.test.ts new file mode 100644 index 000000000..83df34518 --- /dev/null +++ b/tests/unit/bases/BasesTaskCardKeyboardController.test.ts @@ -0,0 +1,553 @@ +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("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"], + ] 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..9ab355a7a --- /dev/null +++ b/tests/unit/bases/CalendarView.keyboardActions.test.ts @@ -0,0 +1,224 @@ +import { CalendarView } from "../../../src/bases/CalendarView"; +import { executeBasesTaskCardAction } from "../../../src/bases/basesTaskCardActions"; +import { mountCalendarListEventCard } from "../../../src/bases/calendarEventMount"; + +jest.mock( + "tasknotes-nlp-core", + () => ({ + NaturalLanguageParserCore: class {}, + }), + { virtual: true } +); +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; + +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(); + }); +}); + +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(); + }); +}); diff --git a/tests/unit/bases/KanbanView.keyboardActions.test.ts b/tests/unit/bases/KanbanView.keyboardActions.test.ts new file mode 100644 index 000000000..828772c2d --- /dev/null +++ b/tests/unit/bases/KanbanView.keyboardActions.test.ts @@ -0,0 +1,96 @@ +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); + }); +}); + +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 new file mode 100644 index 000000000..9ecf99b4f --- /dev/null +++ b/tests/unit/bases/TaskListFocusController.test.ts @@ -0,0 +1,487 @@ +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) => { + if (event.key === "ArrowDown") controller.moveFocus(event, "next"); + else if (event.key === "ArrowUp") controller.moveFocus(event, "previous"); + else if (event.key === "Home") controller.moveFocus(event, "first"); + else if (event.key === "End") controller.moveFocus(event, "last"); + }); + 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(); + }); + + 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("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); + 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("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(); + 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(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", () => { + 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 }); + 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", () => { + 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); + 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"); + 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); + }); + + 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"); + }); + + 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"); + }); + + 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); + 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"); + }); + + 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]); + }); + + 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/TaskListInputOwnershipController.test.ts b/tests/unit/bases/TaskListInputOwnershipController.test.ts new file mode 100644 index 000000000..8e149ada7 --- /dev/null +++ b/tests/unit/bases/TaskListInputOwnershipController.test.ts @@ -0,0 +1,161 @@ +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("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"; + 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("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"); + 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("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"); + 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.keyboardActions.test.ts b/tests/unit/bases/TaskListView.keyboardActions.test.ts new file mode 100644 index 000000000..df66f9a10 --- /dev/null +++ b/tests/unit/bases/TaskListView.keyboardActions.test.ts @@ -0,0 +1,232 @@ +import { TaskListView } from "../../../src/bases/TaskListView"; + +jest.mock( + "tasknotes-nlp-core", + () => ({ + NaturalLanguageParserCore: class {}, + }), + { virtual: true } +); + +describe("TaskListView keyboard actions", () => { + 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("wires the shared keyboard controller to Task List state and callbacks", () => { + 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"); + const mockThis = { + plugin: { taskSelectionService: {} }, + app: undefined, + currentTargetDate, + rootElement, + itemsContainer, + currentVisibleTaskPaths: new Set(["a.md"]), + showBatchContextMenu, + createFileForView, + focusTaskListSearch, + resolveOffscreenTaskCard, + }; + + 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(); + + 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/bases/TaskListView.keyboardSelection.test.ts b/tests/unit/bases/TaskListView.keyboardSelection.test.ts new file mode 100644 index 000000000..b28fea191 --- /dev/null +++ b/tests/unit/bases/TaskListView.keyboardSelection.test.ts @@ -0,0 +1,174 @@ +import { TaskListView } from "../../../src/bases/TaskListView"; +import { TaskListFocusController } from "../../../src/bases/TaskListFocusController"; +import { executeBasesTaskCardAction } from "../../../src/bases/basesTaskCardActions"; + +jest.mock( + "tasknotes-nlp-core", + () => ({ + NaturalLanguageParserCore: class {}, + }), + { virtual: true } +); + +describe("TaskListView keyboard selection", () => { + it("toggles the focused task through the existing selection service", async () => { + const toggleSelection = jest.fn(); + const context = { + taskSelectionService: { toggleSelection }, + isPathVisible: (path: string) => path === "focused.md", + }; + + 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", async () => { + 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 focusedPath = focusController.getFocusedIdentity()?.path ?? null; + const context = { + taskSelectionService: { toggleSelection }, + isPathVisible: (path: string) => ["first.md", "hovered.md"].includes(path), + }; + + 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", async () => { + const toggleSelection = jest.fn(); + const context = { + taskSelectionService: { toggleSelection }, + isPathVisible: (path: string) => path === "visible.md", + }; + + await executeBasesTaskCardAction("toggle-select", "hidden.md", context as any); + + expect(toggleSelection).not.toHaveBeenCalled(); + }); + + it("clears selection while preserving task focus for subsequent shortcuts", async () => { + const clearSelection = jest.fn(); + const exitSelectionMode = jest.fn(); + const restoreFocus = jest.fn(() => true); + const rootElement = document.createElement("div"); + rootElement.tabIndex = -1; + document.body.appendChild(rootElement); + const context = { + taskSelectionService: { clearSelection, exitSelectionMode }, + restoreFocus, + rootElement, + }; + + await executeBasesTaskCardAction("clear-focus-and-selection", null, context as any); + + expect(clearSelection).toHaveBeenCalled(); + expect(exitSelectionMode).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", async () => { + const rootElement = document.createElement("div"); + rootElement.tabIndex = -1; + document.body.appendChild(rootElement); + const context = { + taskSelectionService: { + clearSelection: jest.fn(), + exitSelectionMode: jest.fn(), + }, + restoreFocus: jest.fn(() => false), + rootElement, + }; + + await executeBasesTaskCardAction("clear-focus-and-selection", null, context as any); + + expect(document.activeElement).toBe(rootElement); + }); + + 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), + getSelectionCount: jest.fn(() => 2), + exitSelectionMode, + onSelectionChange: jest.fn(() => jest.fn()), + onSelectionModeChange: jest.fn(() => jest.fn()), + }, + }, + taskCardKeyboardController: { + canHandleSelectionKeyDown: 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); + expect(view.updateSelectionModeUI).toHaveBeenCalledWith(true); + expect(view.updateSelectionVisuals).toHaveBeenCalled(); + expect(view.updateSelectionIndicator).toHaveBeenCalledWith(2); + const event = new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }); + + card.dispatchEvent(event); + + expect(view.taskCardKeyboardController.canHandleSelectionKeyDown).toHaveBeenCalledWith( + event + ); + expect(exitSelectionMode).not.toHaveBeenCalled(); + }); + + it("rehydrates selection visuals after every card render", () => { + const restoreAfterRender = jest.fn(); + const updateSelectionVisuals = jest.fn(); + const updateSelectionIndicator = jest.fn(); + const view = { + taskCardKeyboardController: { 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/bases/basesTaskCardActions.test.ts b/tests/unit/bases/basesTaskCardActions.test.ts new file mode 100644 index 000000000..9a77c9820 --- /dev/null +++ b/tests/unit/bases/basesTaskCardActions.test.ts @@ -0,0 +1,332 @@ +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(), +})); +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 { + 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("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(); + 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("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(); + 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/basesValueConversion.test.ts b/tests/unit/bases/basesValueConversion.test.ts index d18fab691..fdbc2fe67 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/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/bases/taskListDropPlanning.test.ts b/tests/unit/bases/taskListDropPlanning.test.ts index 5d6d46441..90bb71eb4 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"; @@ -9,6 +10,7 @@ const lookupMappingKey = (property: string): keyof FieldMapping | null => { const mappings: Partial> = { status: "status", contexts: "contexts", + projects: "projects", priority: "priority", }; return mappings[property] ?? null; @@ -27,6 +29,22 @@ const createTask = (overrides: Partial = {}): TaskInfo => ({ }); describe("taskListDropPlanning", () => { + it.each([ + ["replace", false, false], + ["replace", true, false], + ["add", false, true], + ["add", true, true], + ["replace-modifier-add", false, false], + ["replace-modifier-add", true, true], + ] as const)( + "resolves %s behavior with additive modifier=%s to preserve=%s", + (behavior, additiveModifierKey, expected) => { + expect(shouldPreserveTaskListGroupDropValues(behavior, additiveModifierKey)).toBe( + expected + ); + } + ); + it("marks formula grouping as read-only while preserving the stripped property for sorting", () => { const plan = buildTaskListGroupDropPlan({ groupByPropertyId: "formula.score", @@ -42,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", @@ -67,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", @@ -99,6 +150,127 @@ 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, + normalizeListGroupValue: (taskProperty, _propertyName, value) => + taskProperty === "projects" ? `[[${value}]]` : value, + }); + 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("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", @@ -155,7 +327,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", @@ -175,8 +347,83 @@ 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", + 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", + }); + }); + + 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/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"); diff --git a/tests/unit/bases/taskListKeyboardActions.test.ts b/tests/unit/bases/taskListKeyboardActions.test.ts new file mode 100644 index 000000000..eb9603bb9 --- /dev/null +++ b/tests/unit/bases/taskListKeyboardActions.test.ts @@ -0,0 +1,176 @@ +import { + DEFAULT_TASK_LIST_SHORTCUTS, + findTaskListShortcutConflicts, + findTaskListShortcutOwners, + formatTaskListShortcut, + normalizeTaskListShortcut, + normalizeTaskListShortcutMap, + resolveDefaultTaskListKeyboardAction, + resolveTaskListKeyboardAction, + replaceTaskListShortcut, + taskListShortcutToScopeBinding, +} 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([ + ["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"], + ["c", { metaKey: true }, "copy-task-titles"], + ["y", {}, "toggle-archive"], + ["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"], + ["d", { ctrlKey: true }, "mark-complete"], + ["d", { metaKey: true }, "mark-complete"], + ["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"], + ["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("Process"), + key("d", { isComposing: true }), + key("q"), + ])("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("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"], + ["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": ["z"], + "edit-status": ["Z"], + }); + + expect(findTaskListShortcutConflicts(shortcuts).get("z")).toEqual([ + "edit-due", + "edit-status", + ]); + }); + + it("finds duplicate owners and replaces their binding atomically", () => { + const shortcuts = normalizeTaskListShortcutMap({ + "edit-due": ["z"], + "edit-status": ["z"], + "jump-first": ["home"], + }); + + expect(findTaskListShortcutOwners(shortcuts, "z", "jump-first")).toEqual([ + "edit-due", + "edit-status", + ]); + const replaced = replaceTaskListShortcut(shortcuts, "jump-first", "z"); + expect(replaced["edit-due"]).toEqual([]); + expect(replaced["edit-status"]).toEqual([]); + expect(replaced["jump-first"]).toEqual(["home", "z"]); + }); + + 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"); + }); + + 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); + }); +}); diff --git a/tests/unit/bases/taskListTargetResolver.test.ts b/tests/unit/bases/taskListTargetResolver.test.ts new file mode 100644 index 000000000..409acf54a --- /dev/null +++ b/tests/unit/bases/taskListTargetResolver.test.ts @@ -0,0 +1,73 @@ +import { + resolveTaskListDragPaths, + 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"]); + }); +}); + +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", + ]); + }); +}); 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(); + }); +}); 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); 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/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"]); + }); }); 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"); + }); }); diff --git a/tests/unit/modals/UserFieldEditModal.keyboard.test.ts b/tests/unit/modals/UserFieldEditModal.keyboard.test.ts new file mode 100644 index 000000000..1e5371506 --- /dev/null +++ b/tests/unit/modals/UserFieldEditModal.keyboard.test.ts @@ -0,0 +1,123 @@ +import { UserFieldEditModal } from "../../../src/modals/UserFieldEditModal"; + +function createModal() { + const onApply = jest.fn().mockResolvedValue(undefined); + const onClose = jest.fn(); + 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, + onClose, + }); + (modal as any).onOpen(); + document.body.appendChild(modal.contentEl); + return { modal, onApply, onClose }; +} + +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]); + }); + + it("notifies the task view when the popup closes", () => { + const { modal, onClose } = createModal(); + + modal.onClose(); + + 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(); + } + }); +}); 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({ diff --git a/tests/unit/settings/SettingsDefaults.test.ts b/tests/unit/settings/SettingsDefaults.test.ts index 45eb353e2..f0c95db5f 100644 --- a/tests/unit/settings/SettingsDefaults.test.ts +++ b/tests/unit/settings/SettingsDefaults.test.ts @@ -5,6 +5,10 @@ describe('Settings defaults', () => { expect(DEFAULT_SETTINGS.viewsButtonAlignment).toBe('right'); }); + test('task-list list-property drops move unless the copy modifier is held', () => { + expect(DEFAULT_SETTINGS.taskListGroupDropBehavior).toBe('replace-modifier-add'); + }); + test('occurrence filename templates are opt-in', () => { expect(DEFAULT_SETTINGS.occurrenceFilenameTemplate).toBe(''); }); diff --git a/tests/unit/settings/TabButtons.css-classes.test.ts b/tests/unit/settings/TabButtons.css-classes.test.ts index 921f1019b..fdaf4a6a3 100644 --- a/tests/unit/settings/TabButtons.css-classes.test.ts +++ b/tests/unit/settings/TabButtons.css-classes.test.ts @@ -110,6 +110,21 @@ describe('Settings UI - Tab Button CSS Classes', () => { expect(appearanceButton.classList.contains('vertical-tab-nav-item')).toBe(true); }); + test('switches tabs in an Obsidian settings-search result', () => { + const searchResult = document.createElement('div'); + const definition = tab.getSettingDefinitions()[0]; + definition.render({ settingEl: searchResult } as any); + + const generalButton = searchResult.querySelector('#tab-button-general') as HTMLElement; + const appearanceButton = searchResult.querySelector('#tab-button-appearance') as HTMLElement; + + appearanceButton.click(); + + expect(generalButton.classList.contains('is-active')).toBe(false); + expect(appearanceButton.classList.contains('is-active')).toBe(true); + expect(searchResult.querySelector('#settings-tab-appearance')?.classList.contains('active')).toBe(true); + }); + test('all tab buttons have vertical-tab-nav-item class', () => { tab.display(); diff --git a/tests/unit/settings/keyboardShortcutsTab.test.ts b/tests/unit/settings/keyboardShortcutsTab.test.ts new file mode 100644 index 000000000..74cb7d618 --- /dev/null +++ b/tests/unit/settings/keyboardShortcutsTab.test.ts @@ -0,0 +1,87 @@ +import { Scope } from "obsidian"; +import { + formatShortcutOwnerLabel, + partitionShortcutOwners, + 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"); + }); + + 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", () => { + 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).toHaveBeenCalledWith(event); + expect(app.keymap.popScope.mock.calls[0][0] === scope).toBe(true); + + stop(); + expect(app.keymap.popScope).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/settings/settingsPersistence.test.ts b/tests/unit/settings/settingsPersistence.test.ts index 43d4a6dae..d2936c7f1 100644 --- a/tests/unit/settings/settingsPersistence.test.ts +++ b/tests/unit/settings/settingsPersistence.test.ts @@ -186,6 +186,49 @@ 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: { + 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"], + toggleArchive: ["a"], + }, + }); + + 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); + }); + it("merges only known settings keys into saved data while preserving other persisted data", () => { const settings = { ...DEFAULT_SETTINGS, 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(); + }); + }); }); 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:" }, 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