Use official Obsidian Bases value rendering

This commit is contained in:
callumalpass 2026-04-26 18:57:21 +10:00
parent 1f6da1d408
commit 490faab1bc
22 changed files with 380 additions and 350 deletions

View file

@ -33,8 +33,17 @@ Example:
- (#1777) Added a Modal Fields setting to choose whether Tab/Shift+Tab move focus out of the task details editor or use the markdown editor's indentation behavior.
- Thanks to @P-Sc for the PR.
## Changed
- Updated the Obsidian API dependency to 1.12.3 and switched the Bases integration to the official API type declarations.
- Bases task cards and property-event cards now render metadata values through Obsidian's native Bases value renderer so links, dates, lists, icons, and formula values display more faithfully.
## Fixed
- (#1035) Fixed the task modal title field causing the modal to jump to the bottom when focused on iPhone.
- Thanks to @3zra47 for reporting and following up.
- (#1045) Fixed inline linked recurring tasks keeping the previous day's completed state after the calendar day changes.
- Thanks to @3zra47 for reporting and following up.
- (#1788) Fixed Task List subgroup headers and cards rendering without clear indentation.
- Thanks to @renatomen for the PR.
- (#1810) Updated Japanese Kanban terminology to use the more natural katakana spelling.
@ -68,3 +77,4 @@ Example:
- (#1744) Fixed Bases Task List views so changing the per-view `Expanded relationships` option takes effect on re-render
- Restores the expected `show-all` behavior for setups that hide subtasks at the top level with `note.projects.isEmpty()`
- Thanks to @minol-dev for reporting and @stanley-910 for PR #1792 exploring the related standalone-subtask workflow
- Fixed tag clicks in task cards opening the task edit modal in addition to the tag search panel, and kept rendered tag metadata on a single row inside task cards.

63
package-lock.json generated
View file

@ -9,7 +9,7 @@
"version": "4.5.1",
"license": "MIT",
"dependencies": {
"@codemirror/view": "^6.37.2",
"@codemirror/view": "^6.38.6",
"@fullcalendar/core": "^6.1.17",
"@fullcalendar/daygrid": "^6.1.17",
"@fullcalendar/interaction": "^6.1.17",
@ -30,7 +30,7 @@
},
"devDependencies": {
"@codemirror/autocomplete": "^6.19.1",
"@codemirror/state": "^6.5.2",
"@codemirror/state": "^6.5.0",
"@electron/asar": "^3.4.1",
"@playwright/test": "^1.57.0",
"@types/jest": "^30.0.0",
@ -48,7 +48,7 @@
"jest": "^30.1.1",
"jest-environment-jsdom": "^30.1.1",
"monkey-around": "^3.0.0",
"obsidian": "latest",
"obsidian": "^1.12.3",
"obsidian-typings": "^4.49.0",
"prettier": "^3.6.2",
"ts-jest": "^29.1.1",
@ -756,18 +756,18 @@
}
},
"node_modules/@codemirror/state": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz",
"integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.38.1",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz",
"integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==",
"version": "6.38.6",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz",
"integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.5.0",
@ -11142,9 +11142,9 @@
}
},
"node_modules/obsidian": {
"version": "1.8.7",
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.8.7.tgz",
"integrity": "sha512-h4bWwNFAGRXlMlMAzdEiIM2ppTGlrh7uGOJS6w4gClrsjc+ei/3YAtU2VdFUlCiPuTHpY4aBpFJJW75S1Tl/JA==",
"version": "1.12.3",
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz",
"integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -11152,8 +11152,8 @@
"moment": "2.29.4"
},
"peerDependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
"@codemirror/state": "6.5.0",
"@codemirror/view": "6.38.6"
}
},
"node_modules/obsidian-daily-notes-interface": {
@ -11226,6 +11226,16 @@
"typescript": ">=4.8.0"
}
},
"node_modules/obsidian-typings/node_modules/@codemirror/state": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/obsidian-typings/node_modules/@types/codemirror": {
"version": "5.60.16",
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.16.tgz",
@ -11236,6 +11246,31 @@
"@types/tern": "*"
}
},
"node_modules/obsidian-typings/node_modules/obsidian": {
"version": "1.8.7",
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.8.7.tgz",
"integrity": "sha512-h4bWwNFAGRXlMlMAzdEiIM2ppTGlrh7uGOJS6w4gClrsjc+ei/3YAtU2VdFUlCiPuTHpY4aBpFJJW75S1Tl/JA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/codemirror": "5.60.8",
"moment": "2.29.4"
},
"peerDependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/obsidian-typings/node_modules/obsidian/node_modules/@types/codemirror": {
"version": "5.60.8",
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz",
"integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/tern": "*"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",

View file

@ -45,7 +45,7 @@
"license": "MIT",
"devDependencies": {
"@codemirror/autocomplete": "^6.19.1",
"@codemirror/state": "^6.5.2",
"@codemirror/state": "^6.5.0",
"@electron/asar": "^3.4.1",
"@playwright/test": "^1.57.0",
"@types/jest": "^30.0.0",
@ -63,7 +63,7 @@
"jest": "^30.1.1",
"jest-environment-jsdom": "^30.1.1",
"monkey-around": "^3.0.0",
"obsidian": "latest",
"obsidian": "^1.12.3",
"obsidian-typings": "^4.49.0",
"prettier": "^3.6.2",
"ts-jest": "^29.1.1",
@ -71,7 +71,7 @@
"typescript": "^5.9.2"
},
"dependencies": {
"@codemirror/view": "^6.37.2",
"@codemirror/view": "^6.38.6",
"@fullcalendar/core": "^6.1.17",
"@fullcalendar/daygrid": "^6.1.17",
"@fullcalendar/interaction": "^6.1.17",

View file

@ -1,12 +1,33 @@
import { BasesDataItem } from "./helpers";
import { BasesEntryLike, BasesGroupLike, BasesValueLike, BasesViewLike } from "./types";
import type { BasesEntry, BasesEntryGroup, BasesPropertyId, BasesView, TFile, Value } from "obsidian";
type BasesViewDataSource = Pick<BasesView, "config" | "data">;
type BasesValueInternals = Value & {
data?: unknown;
date?: Date;
file?: TFile;
value?: unknown[];
get?: (index: number) => Value | unknown;
at?: (index: number) => Value | unknown;
length?: () => number;
toISOString?: () => string;
constructor?: {
name?: string;
};
};
type BasesEntryInternals = BasesEntry & {
frontmatter?: Record<string, unknown>;
properties?: Record<string, unknown>;
};
/**
* Adapter for accessing Bases data using public API (1.10.0+).
* Eliminates all internal API dependencies.
*/
export class BasesDataAdapter {
constructor(private basesView: BasesViewLike) {}
constructor(private basesView: BasesViewDataSource) {}
/**
* Extract all data items from Bases query result.
@ -18,7 +39,7 @@ export class BasesDataAdapter {
*/
extractDataItems(): BasesDataItem[] {
const entries = this.basesView.data.data;
return entries.map((entry: BasesEntryLike) => ({
return entries.map((entry: BasesEntry) => ({
key: entry.file.path,
data: entry,
file: entry.file,
@ -34,7 +55,7 @@ export class BasesDataAdapter {
*
* Note: Returns pre-grouped data. Bases has already applied groupBy configuration.
*/
getGroupedData(): BasesGroupLike[] {
getGroupedData(): BasesEntryGroup[] {
return this.basesView.data.groupedData;
}
@ -82,16 +103,16 @@ export class BasesDataAdapter {
* Uses public API: basesView.config.getDisplayName()
*/
getPropertyDisplayName(propertyId: string): string {
return this.basesView.config.getDisplayName(propertyId);
return this.basesView.config.getDisplayName(propertyId as BasesPropertyId);
}
/**
* Get property value from a Bases entry.
* Uses public API: entry.getValue()
*/
getPropertyValue(entry: BasesEntryLike, propertyId: string): unknown {
getPropertyValue(entry: BasesEntry, propertyId: string): unknown {
try {
const value = entry.getValue(propertyId);
const value = entry.getValue(propertyId as BasesPropertyId);
return this.convertValueToNative(value);
} catch (e) {
console.warn(`[BasesDataAdapter] Failed to get property ${propertyId}:`, e);
@ -104,8 +125,8 @@ export class BasesDataAdapter {
* Convert Bases Value object to native JavaScript value.
* Handles: PrimitiveValue, ListValue, DateValue, FileValue, NullValue, etc.
*/
private convertValueToNative(value: BasesValueLike | unknown): unknown {
const basesValue = value as BasesValueLike | null | undefined;
private convertValueToNative(value: Value | unknown): unknown {
const basesValue = value as BasesValueInternals | null | undefined;
if (basesValue == null || basesValue.constructor?.name === "NullValue") {
return null;
}
@ -116,11 +137,16 @@ export class BasesDataAdapter {
}
// ListValue
if (typeof basesValue.length === "function" && typeof basesValue.at === "function") {
const getListItem = typeof basesValue.get === "function"
? basesValue.get.bind(basesValue)
: typeof basesValue.at === "function"
? basesValue.at.bind(basesValue)
: null;
if (typeof basesValue.length === "function" && getListItem) {
const len = basesValue.length();
const result = [];
for (let i = 0; i < len; i++) {
const item = basesValue.at(i);
const item = getListItem(i);
result.push(this.convertValueToNative(item));
}
return result;
@ -143,7 +169,14 @@ export class BasesDataAdapter {
}
// Fallback: try to extract raw data
return basesValue;
if (typeof basesValue.toString === "function") {
const stringValue = basesValue.toString();
if (stringValue !== "[object Object]") {
return stringValue;
}
}
return value;
}
/**
@ -151,10 +184,10 @@ export class BasesDataAdapter {
* Handles Bases Value objects, particularly DateValue which has special structure.
* For FileValue (links), returns the file path which can be rendered as a clickable link.
*/
convertGroupKeyToString(key: BasesValueLike | unknown): string {
const basesKey = key as BasesValueLike | null | undefined;
convertGroupKeyToString(key: Value | unknown): string {
const basesKey = key as BasesValueInternals | null | undefined;
// Check if key exists and is valid
if (basesKey == null || (basesKey.hasKey && !basesKey.hasKey())) {
if (basesKey == null || basesKey.constructor?.name === "NullValue") {
return "Unknown";
}
@ -210,11 +243,12 @@ export class BasesDataAdapter {
* Extracts frontmatter and basic file properties only (cheap operations).
* Computed file properties (backlinks, links, etc.) are fetched lazily via getComputedProperty().
*/
private extractEntryProperties(entry: BasesEntryLike): Record<string, unknown> {
private extractEntryProperties(entry: BasesEntry): Record<string, unknown> {
// Extract all properties from the entry's frontmatter
// We don't filter by visible properties here - that happens during rendering
// This ensures all properties are available for TaskInfo creation
const frontmatter = entry.frontmatter || entry.properties || {};
const entryInternals = entry as BasesEntryInternals;
const frontmatter = entryInternals.frontmatter || entryInternals.properties || {};
// Start with frontmatter properties
const result = { ...frontmatter };
@ -249,11 +283,11 @@ export class BasesDataAdapter {
* Call this during rendering for visible items only - NOT during bulk extraction.
* This is much more efficient for expensive properties like backlinks.
*/
getComputedProperty(basesEntry: BasesEntryLike | null | undefined, propertyId: string): unknown {
getComputedProperty(basesEntry: BasesEntry | null | undefined, propertyId: string): unknown {
if (!basesEntry) return null;
try {
const value = basesEntry.getValue(propertyId);
const value = basesEntry.getValue(propertyId as BasesPropertyId);
return this.convertValueToNative(value);
} catch (e) {
return null;

View file

@ -1,4 +1,5 @@
import { Component, App, setIcon } from "obsidian";
import { Component, App, setIcon } from "obsidian";
import type { BasesPropertyId, BasesQueryResult, BasesView, BasesViewConfig } from "obsidian";
import TaskNotesPlugin from "../main";
import { BasesDataAdapter } from "./BasesDataAdapter";
import { PropertyMappingService } from "./PropertyMappingService";
@ -9,19 +10,19 @@ import { SearchBox } from "./components/SearchBox";
import { TaskSearchFilter } from "./TaskSearchFilter";
import { BatchContextMenu } from "../components/BatchContextMenu";
import type { TaskCardOptions } from "../ui/TaskCard";
import { BasesConfigLike, BasesQueryResultLike } from "./types";
/**
* Abstract base class for all TaskNotes Bases views.
* Properly extends Component to leverage lifecycle, and implements BasesView interface.
* Note: Bases types (BasesView, BasesViewConfig) are available from obsidian-api declarations.
*/
export abstract class BasesViewBase extends Component {
/**
* Abstract base class for all TaskNotes Bases views.
* Properly extends Component to leverage lifecycle, and implements BasesView interface.
* Note: Bases types (BasesView, BasesViewConfig) are available from obsidian-api declarations.
*/
export abstract class BasesViewBase extends Component implements BasesView {
// BasesView properties (provided by Bases when factory returns this instance)
// These match the BasesView interface from Obsidian's internal Bases API
app!: App;
config!: BasesConfigLike;
data!: BasesQueryResultLike;
config!: BasesViewConfig;
data!: BasesQueryResult;
allProperties!: BasesPropertyId[];
protected plugin: TaskNotesPlugin;
protected dataAdapter: BasesDataAdapter;
protected propertyMapper: PropertyMappingService;
@ -355,10 +356,10 @@ export abstract class BasesViewBase extends Component {
* @param baseFileName - Suggested filename from Bases (typically unused in TaskNotes)
* @param frontmatterProcessor - Optional callback that Bases uses to set default frontmatter values
*/
async createFileForView(
baseFileName: string,
frontmatterProcessor?: (frontmatter: any) => void
): Promise<void> {
async createFileForView(
baseFileName?: string,
frontmatterProcessor?: (frontmatter: any) => void
): Promise<void> {
const { TaskCreationModal } = await import("../modals/TaskCreationModal");
// Extract any default values from the frontmatter processor if provided

View file

@ -479,6 +479,11 @@ export class CalendarView extends BasesViewBase {
return defaultValue;
}
private getConfigOption<T>(key: string, fallback: T): T {
const value = this.config.get(key);
return value === null || value === undefined ? fallback : (value as T);
}
/**
* Read event toggle options from config.
* These should be re-read on every render to respond to toggle changes.
@ -490,20 +495,20 @@ export class CalendarView extends BasesViewBase {
}
try {
this.viewOptions.showScheduled = this.config.get('showScheduled') ?? this.viewOptions.showScheduled;
this.viewOptions.showDue = this.config.get('showDue') ?? this.viewOptions.showDue;
this.viewOptions.showScheduledToDueSpan = this.config.get('showScheduledToDueSpan') ?? this.viewOptions.showScheduledToDueSpan;
this.viewOptions.showRecurring = this.config.get('showRecurring') ?? this.viewOptions.showRecurring;
this.viewOptions.showTimeEntries = this.config.get('showTimeEntries') ?? this.viewOptions.showTimeEntries;
this.viewOptions.showTimeblocks = this.config.get('showTimeblocks') ?? this.viewOptions.showTimeblocks;
this.viewOptions.showPropertyBasedEvents = this.config.get('showPropertyBasedEvents') ?? this.viewOptions.showPropertyBasedEvents;
this.viewOptions.showScheduled = this.getConfigOption('showScheduled', this.viewOptions.showScheduled);
this.viewOptions.showDue = this.getConfigOption('showDue', this.viewOptions.showDue);
this.viewOptions.showScheduledToDueSpan = this.getConfigOption('showScheduledToDueSpan', this.viewOptions.showScheduledToDueSpan);
this.viewOptions.showRecurring = this.getConfigOption('showRecurring', this.viewOptions.showRecurring);
this.viewOptions.showTimeEntries = this.getConfigOption('showTimeEntries', this.viewOptions.showTimeEntries);
this.viewOptions.showTimeblocks = this.getConfigOption('showTimeblocks', this.viewOptions.showTimeblocks);
this.viewOptions.showPropertyBasedEvents = this.getConfigOption('showPropertyBasedEvents', this.viewOptions.showPropertyBasedEvents);
// ICS calendar toggles
if (this.plugin.icsSubscriptionService) {
const subscriptions = this.plugin.icsSubscriptionService.getSubscriptions();
for (const sub of subscriptions) {
const key = `showICS_${sub.id}`;
this.icsCalendarToggles.set(sub.id, this.config.get(key) ?? true);
this.icsCalendarToggles.set(sub.id, this.getConfigOption(key, true));
}
}
@ -512,7 +517,7 @@ export class CalendarView extends BasesViewBase {
const calendars = this.plugin.googleCalendarService.getAvailableCalendars();
for (const cal of calendars) {
const key = `showGoogleCalendar_${cal.id}`;
this.googleCalendarToggles.set(cal.id, this.config.get(key) ?? true);
this.googleCalendarToggles.set(cal.id, this.getConfigOption(key, true));
}
}
@ -521,7 +526,7 @@ export class CalendarView extends BasesViewBase {
const calendars = this.plugin.microsoftCalendarService.getAvailableCalendars();
for (const cal of calendars) {
const key = `showMicrosoftCalendar_${cal.id}`;
this.microsoftCalendarToggles.set(cal.id, this.config.get(key) ?? true);
this.microsoftCalendarToggles.set(cal.id, this.getConfigOption(key, true));
}
}
} catch (e) {
@ -544,75 +549,75 @@ export class CalendarView extends BasesViewBase {
this.readEventToggles();
// Date navigation
this.viewOptions.initialDate = this.config.get('initialDate') ?? this.viewOptions.initialDate;
this.viewOptions.initialDateProperty = this.config.get('initialDateProperty') ?? this.viewOptions.initialDateProperty;
this.viewOptions.initialDateStrategy = this.config.get('initialDateStrategy') ?? this.viewOptions.initialDateStrategy;
this.viewOptions.initialDate = this.getConfigOption('initialDate', this.viewOptions.initialDate);
this.viewOptions.initialDateProperty = this.getConfigOption('initialDateProperty', this.viewOptions.initialDateProperty);
this.viewOptions.initialDateStrategy = this.getConfigOption('initialDateStrategy', this.viewOptions.initialDateStrategy);
// Layout
this.viewOptions.calendarView = this.config.get('calendarView') ?? this.viewOptions.calendarView;
this.viewOptions.customDayCount = this.config.get('customDayCount') ?? this.viewOptions.customDayCount;
this.viewOptions.listDayCount = this.config.get('listDayCount') ?? this.viewOptions.listDayCount;
this.viewOptions.calendarView = this.getConfigOption('calendarView', this.viewOptions.calendarView);
this.viewOptions.customDayCount = this.getConfigOption('customDayCount', this.viewOptions.customDayCount);
this.viewOptions.listDayCount = this.getConfigOption('listDayCount', this.viewOptions.listDayCount);
// Validate time values to prevent crashes from invalid input
this.viewOptions.slotMinTime = this.validateTimeValue(
this.config.get('slotMinTime'),
this.getConfigOption<string | undefined>('slotMinTime', undefined),
this.viewOptions.slotMinTime,
false
);
this.viewOptions.slotMaxTime = this.validateTimeValue(
this.config.get('slotMaxTime'),
this.getConfigOption<string | undefined>('slotMaxTime', undefined),
this.viewOptions.slotMaxTime,
true // Allow 24:00 for end time
);
this.viewOptions.slotDuration = this.validateTimeValue(
this.config.get('slotDuration'),
this.getConfigOption<string | undefined>('slotDuration', undefined),
this.viewOptions.slotDuration,
false
);
this.viewOptions.scrollTime = this.validateTimeValue(
this.config.get('scrollTime'),
this.getConfigOption<string | undefined>('scrollTime', undefined),
this.viewOptions.scrollTime,
false
);
this.viewOptions.firstDay = Number(this.config.get('firstDay') ?? this.viewOptions.firstDay);
this.viewOptions.weekNumbers = this.config.get('weekNumbers') ?? this.viewOptions.weekNumbers;
this.viewOptions.nowIndicator = this.config.get('nowIndicator') ?? this.viewOptions.nowIndicator;
this.viewOptions.showWeekends = this.config.get('showWeekends') ?? this.viewOptions.showWeekends;
this.viewOptions.showAllDaySlot = this.config.get('showAllDaySlot') ?? this.viewOptions.showAllDaySlot;
this.viewOptions.showTodayHighlight = this.config.get('showTodayHighlight') ?? this.viewOptions.showTodayHighlight;
const todayColumnWidthMultiplier = Number(this.config.get('todayColumnWidthMultiplier') ?? 1);
this.viewOptions.firstDay = Number(this.getConfigOption('firstDay', this.viewOptions.firstDay));
this.viewOptions.weekNumbers = this.getConfigOption('weekNumbers', this.viewOptions.weekNumbers);
this.viewOptions.nowIndicator = this.getConfigOption('nowIndicator', this.viewOptions.nowIndicator);
this.viewOptions.showWeekends = this.getConfigOption('showWeekends', this.viewOptions.showWeekends);
this.viewOptions.showAllDaySlot = this.getConfigOption('showAllDaySlot', this.viewOptions.showAllDaySlot);
this.viewOptions.showTodayHighlight = this.getConfigOption('showTodayHighlight', this.viewOptions.showTodayHighlight);
const todayColumnWidthMultiplier = Number(this.getConfigOption('todayColumnWidthMultiplier', 1));
this.viewOptions.todayColumnWidthMultiplier =
todayColumnWidthMultiplier >= 1 && todayColumnWidthMultiplier <= 5
? Math.round(todayColumnWidthMultiplier * 2) / 2
: 1;
this.viewOptions.selectMirror = this.config.get('selectMirror') ?? this.viewOptions.selectMirror;
this.viewOptions.timeFormat = this.config.get('timeFormat') ?? this.viewOptions.timeFormat;
this.viewOptions.eventMinHeight = this.config.get('eventMinHeight') ?? this.viewOptions.eventMinHeight;
this.viewOptions.slotEventOverlap = this.config.get('slotEventOverlap') ?? this.viewOptions.slotEventOverlap;
this.viewOptions.selectMirror = this.getConfigOption('selectMirror', this.viewOptions.selectMirror);
this.viewOptions.timeFormat = this.getConfigOption('timeFormat', this.viewOptions.timeFormat);
this.viewOptions.eventMinHeight = this.getConfigOption('eventMinHeight', this.viewOptions.eventMinHeight);
this.viewOptions.slotEventOverlap = this.getConfigOption('slotEventOverlap', this.viewOptions.slotEventOverlap);
// Convert slider values: 0 means special behavior (null/true/false)
const eventMaxStackValue = this.config.get('eventMaxStack');
const eventMaxStackValue = this.getConfigOption<number | undefined>('eventMaxStack', undefined);
if (eventMaxStackValue !== undefined) {
this.viewOptions.eventMaxStack = eventMaxStackValue === 0 ? null : eventMaxStackValue;
}
const dayMaxEventsValue = this.config.get('dayMaxEvents');
const dayMaxEventsValue = this.getConfigOption<number | undefined>('dayMaxEvents', undefined);
if (dayMaxEventsValue !== undefined) {
// 0 = auto (true), positive number = limit
this.viewOptions.dayMaxEvents = dayMaxEventsValue === 0 ? true : dayMaxEventsValue;
}
const dayMaxEventRowsValue = this.config.get('dayMaxEventRows');
const dayMaxEventRowsValue = this.getConfigOption<number | undefined>('dayMaxEventRows', undefined);
if (dayMaxEventRowsValue !== undefined) {
// 0 = unlimited (false), positive number = limit
this.viewOptions.dayMaxEventRows = dayMaxEventRowsValue === 0 ? false : dayMaxEventRowsValue;
}
// Property-based events
this.viewOptions.startDateProperty = this.config.get('startDateProperty') ?? this.viewOptions.startDateProperty;
this.viewOptions.endDateProperty = this.config.get('endDateProperty') ?? this.viewOptions.endDateProperty;
this.viewOptions.titleProperty = this.config.get('titleProperty') ?? this.viewOptions.titleProperty;
this.viewOptions.startDateProperty = this.getConfigOption('startDateProperty', this.viewOptions.startDateProperty);
this.viewOptions.endDateProperty = this.getConfigOption('endDateProperty', this.viewOptions.endDateProperty);
this.viewOptions.titleProperty = this.getConfigOption('titleProperty', this.viewOptions.titleProperty);
// Read enableSearch toggle (default: false for backward compatibility)
const enableSearchValue = this.config.get('enableSearch');

View file

@ -3086,7 +3086,8 @@ export class KanbanView extends BasesViewBase {
targetDate,
hideStatusIndicator,
expandedRelationshipFilterMode: this.expandedRelationshipFilterMode,
resolveExpandedRelationshipFilterMode: () => this.config?.get("expandedRelationshipFilterMode"),
resolveExpandedRelationshipFilterMode: (): "inherit" | "show-all" =>
normalizeExpandedRelationshipFilterMode(this.config?.get("expandedRelationshipFilterMode")),
expandedRelationshipTaskPaths: this.currentVisibleTaskPaths,
});
}

View file

@ -1773,7 +1773,8 @@ export class TaskListView extends BasesViewBase {
return this.buildTaskCardOptions({
targetDate,
expandedRelationshipFilterMode: this.expandedRelationshipFilterMode,
resolveExpandedRelationshipFilterMode: () => this.config?.get("expandedRelationshipFilterMode"),
resolveExpandedRelationshipFilterMode: (): "inherit" | "show-all" =>
normalizeExpandedRelationshipFilterMode(this.config?.get("expandedRelationshipFilterMode")),
expandedRelationshipTaskPaths: this.currentVisibleTaskPaths,
});
}

View file

@ -6,6 +6,7 @@
*/
import { App, Plugin } from "obsidian";
import type { BasesViewRegistration as ObsidianBasesViewRegistration } from "obsidian";
export interface BasesQuery {
on?: (event: string, callback: () => void) => void;
@ -31,82 +32,7 @@ export interface BasesContainer {
};
}
// View Option Types (Obsidian 1.10.0+ Public API)
export type ViewOption =
| PropertyOption
| DropdownOption
| SliderOption
| ToggleOption
| TextOption
| MultitextOption
| GroupOption;
export interface PropertyOption {
type: "property";
key: string;
displayName: string;
default?: string;
placeholder?: string;
filter?: (prop: string) => boolean;
}
export interface DropdownOption {
type: "dropdown";
key: string;
displayName: string;
options: string[];
default?: string;
}
export interface SliderOption {
type: "slider";
key: string;
displayName: string;
min: number;
max: number;
step: number;
default?: number;
}
export interface ToggleOption {
type: "toggle";
key: string;
displayName: string;
default?: boolean;
}
export interface TextOption {
type: "text";
key: string;
displayName: string;
default?: string;
placeholder?: string;
}
export interface MultitextOption {
type: "multitext";
key: string;
displayName: string;
default?: string;
placeholder?: string;
}
export interface GroupOption {
type: "group";
displayName: string;
options: ViewOption[];
}
/**
* Bases View Registration interface (Obsidian 1.10.0+ Public API)
* Updated to match official BasesViewFactory signature.
*/
export interface BasesViewRegistration {
name: string;
icon: string;
factory: (controller: any, containerEl: HTMLElement) => any;
options?: () => ViewOption[];
}
export type BasesViewRegistration = ObsidianBasesViewRegistration;
export interface BasesAPI {
registrations: Record<string, BasesViewRegistration>;
@ -169,9 +95,9 @@ export function registerBasesView(
registration: BasesViewRegistration
): boolean {
// Use public API (Obsidian 1.10.0+)
if (typeof (plugin as any).registerBasesView === "function") {
if (typeof plugin.registerBasesView === "function") {
try {
const success = (plugin as any).registerBasesView(viewId, registration);
const success = plugin.registerBasesView(viewId, registration);
if (success) {
console.debug(
`[TaskNotes][Bases] Successfully registered view via public API: ${viewId}`

View file

@ -1,6 +1,7 @@
/* eslint-disable no-console */
import TaskNotesPlugin from "../main";
import { requireApiVersion } from "obsidian";
import type { BasesAllOptions, BasesOptions } from "obsidian";
import { buildTaskListViewFactory } from "./TaskListView";
import { buildKanbanViewFactory } from "./KanbanView";
import { buildCalendarViewFactory } from "./CalendarView";
@ -45,7 +46,10 @@ export async function registerBasesTaskList(plugin: TaskNotesPlugin): Promise<vo
key: "expandedRelationshipFilterMode",
displayName: "Expanded relationships",
default: "inherit",
options: ["inherit", "show-all"],
options: {
inherit: "Inherit",
"show-all": "Show all",
},
},
],
});
@ -109,7 +113,7 @@ export async function registerBasesTaskList(plugin: TaskNotesPlugin): Promise<vo
default: false,
},
{
type: "multitext",
type: "text",
key: "columnOrder",
displayName: "Column Order (Advanced)",
placeholder: "Auto-managed when dragging columns",
@ -120,7 +124,10 @@ export async function registerBasesTaskList(plugin: TaskNotesPlugin): Promise<vo
key: "expandedRelationshipFilterMode",
displayName: "Expanded relationships",
default: "inherit",
options: ["inherit", "show-all"],
options: {
inherit: "Inherit",
"show-all": "Show all",
},
},
],
});
@ -134,7 +141,7 @@ export async function registerBasesTaskList(plugin: TaskNotesPlugin): Promise<vo
const calendarSettings = plugin.settings.calendarViewSettings;
const t = (key: string) => plugin.i18n.translate(`views.basesCalendar.settings.${key}`);
const options: any[] = [
const options: BasesAllOptions[] = [
{
type: "group",
displayName: t("groups.events"),
@ -444,7 +451,7 @@ export async function registerBasesTaskList(plugin: TaskNotesPlugin): Promise<vo
const subscriptions = plugin.icsSubscriptionService.getSubscriptions();
if (subscriptions.length > 0) {
// Create a group for ICS calendars
const icsToggles: any[] = subscriptions.map(sub => ({
const icsToggles: BasesOptions[] = subscriptions.map(sub => ({
type: "toggle",
key: `showICS_${sub.id}`,
displayName: sub.name,
@ -465,7 +472,7 @@ export async function registerBasesTaskList(plugin: TaskNotesPlugin): Promise<vo
const availableCalendars = plugin.googleCalendarService.getAvailableCalendars();
if (availableCalendars.length > 0) {
// Create toggles for Google calendars
const googleToggles: any[] = availableCalendars.map(cal => ({
const googleToggles: BasesOptions[] = availableCalendars.map(cal => ({
type: "toggle",
key: `showGoogleCalendar_${cal.id}`,
displayName: cal.summary || cal.id,
@ -486,7 +493,7 @@ export async function registerBasesTaskList(plugin: TaskNotesPlugin): Promise<vo
const availableCalendars = plugin.microsoftCalendarService.getAvailableCalendars();
if (availableCalendars.length > 0) {
// Create toggles for Microsoft calendars
const microsoftToggles: any[] = availableCalendars.map(cal => ({
const microsoftToggles: BasesOptions[] = availableCalendars.map(cal => ({
type: "toggle",
key: `showMicrosoftCalendar_${cal.id}`,
displayName: cal.summary || cal.id,

View file

@ -1,55 +0,0 @@
export interface BasesValueLike {
data?: unknown;
date?: Date;
file?: {
path: string;
name?: string;
basename?: string;
extension?: string;
stat?: {
size?: number;
ctime?: number;
mtime?: number;
};
};
length?: () => number;
at?: (index: number) => BasesValueLike | unknown;
hasKey?: () => boolean;
toISOString?: () => string;
constructor?: {
name?: string;
};
}
export interface BasesEntryLike {
file: NonNullable<BasesValueLike["file"]>;
frontmatter?: Record<string, unknown>;
properties?: Record<string, unknown>;
getValue(propertyId: string): BasesValueLike | unknown;
}
export interface BasesGroupLike {
hasKey(): boolean;
key?: BasesValueLike | unknown;
rows?: BasesEntryLike[];
entries: BasesEntryLike[];
}
export interface BasesConfigLike {
getSort(): unknown;
getOrder(): string[];
getDisplayName(propertyId: string): string;
get(key: string): any;
set(key: string, value: unknown): void;
getAsPropertyId(key: string): string | null;
}
export interface BasesQueryResultLike {
data: BasesEntryLike[];
groupedData: BasesGroupLike[];
}
export interface BasesViewLike {
config: BasesConfigLike;
data: BasesQueryResultLike;
}

View file

@ -1,55 +0,0 @@
/**
* Type declarations for Obsidian 1.11.0 APIs
* These augment the existing obsidian module types
*/
import "obsidian";
declare module "obsidian" {
/**
* A group of related settings with a heading
* @since Obsidian 1.11.0
*/
export class SettingGroup {
/**
* Creates a new setting group
* @param containerEl - The container element to add the group to
*/
constructor(containerEl: HTMLElement);
/**
* Sets the heading text for the group
* @param text - The heading text or a DocumentFragment
*/
setHeading(text: string | DocumentFragment): this;
/**
* Adds a CSS class to the group element
* @param cls - The class name to add
*/
addClass(cls: string): this;
/**
* Adds a setting to the group
* @param cb - Callback that receives the Setting to configure
*/
addSetting(cb: (setting: Setting) => void): this;
}
interface Setting {
/**
* Adds a custom component to the setting
* @since Obsidian 1.11.0
* @param cb - Callback that receives the setting element and returns a component
*/
addComponent<T extends BaseComponent>(cb: (el: HTMLElement) => T): this;
}
interface SettingTab {
/**
* The icon to display in the settings sidebar
* @since Obsidian 1.11.0
*/
icon?: IconName;
}
}

View file

@ -1,34 +0,0 @@
/**
* Type declarations for Obsidian 1.12.2 CLI APIs
* These augment the existing obsidian module types
*/
import "obsidian";
declare module "obsidian" {
export interface CliData {
[key: string]: string | "true";
}
export interface CliFlag {
value?: string;
description: string;
required?: boolean;
}
export type CliFlags = Record<string, CliFlag>;
export type CliHandler = (params: CliData) => string | Promise<string>;
interface Plugin {
/**
* Register a CLI handler to handle a command from the CLI.
* @since Obsidian 1.12.2
*/
registerCliHandler(
command: string,
description: string,
flags: CliFlags | null,
handler: CliHandler
): void;
}
}

View file

@ -1,5 +1,7 @@
import { setIcon } from "obsidian";
import type { BasesEntry, BasesPropertyId, BasesViewConfig } from "obsidian";
import TaskNotesPlugin from "../main";
import { isNullBasesValue, renderBasesValue } from "./taskCardPresentation";
export interface PropertyEventCardOptions {
showProperties: boolean;
@ -14,9 +16,9 @@ export const DEFAULT_PROPERTY_EVENT_CARD_OPTIONS: PropertyEventCardOptions = {
* Shows file title and Bases properties configured as visible in the view
*/
export function createPropertyEventCard(
entry: any, // BasesEntry from Bases
entry: BasesEntry,
plugin: TaskNotesPlugin,
viewConfig?: any, // BasesViewConfig
viewConfig?: BasesViewConfig,
options: Partial<PropertyEventCardOptions> = {}
): HTMLElement {
const opts = { ...DEFAULT_PROPERTY_EVENT_CARD_OPTIONS, ...options };
@ -65,9 +67,10 @@ export function createPropertyEventCard(
// Metadata line: show visible properties from Bases view
if (opts.showProperties && viewConfig) {
const metadata = content.createEl("div", { cls: "task-card__metadata" });
const parts: string[] = [];
let renderedProperties = 0;
try {
const doc = metadata.ownerDocument;
// Get visible properties from Bases view configuration
const visibleProperties = viewConfig.getOrder?.() || [];
@ -83,30 +86,33 @@ export function createPropertyEventCard(
}
// Get property value from Bases entry
const value = entry.getValue?.(propertyId);
const value = entry.getValue(propertyId as BasesPropertyId);
if (value && value.data !== null && value.data !== undefined) {
// Get user-friendly property name
const displayName = viewConfig.getDisplayName?.(propertyId) || propertyId;
// Format the value
let displayValue = String(value.data);
// Truncate long values
if (displayValue.length > 30) {
displayValue = displayValue.substring(0, 27) + "...";
if (!isNullBasesValue(value)) {
if (renderedProperties > 0) {
metadata.appendChild(doc.createTextNode(" • "));
}
parts.push(`${displayName}: ${displayValue}`);
// Get user-friendly property name
const displayName = viewConfig.getDisplayName(propertyId as BasesPropertyId) || propertyId;
const propertyEl = metadata.createSpan({
cls: "property-event-card__metadata-property",
});
propertyEl.createSpan({ text: `${displayName}: ` });
const valueEl = propertyEl.createSpan({
cls: "property-event-card__metadata-value",
});
renderBasesValue(valueEl, value, plugin.app.renderContext);
renderedProperties++;
}
}
} catch (error) {
console.debug("[TaskNotes][PropertyEventCard] Error reading properties:", error);
}
if (parts.length > 0) {
metadata.textContent = parts.join(" • ");
} else {
if (renderedProperties === 0) {
// Fallback: show file path if no properties
metadata.textContent = file.path;
}

View file

@ -37,6 +37,8 @@ import {
import { DEFAULT_INTERNAL_VISIBLE_PROPERTIES } from "../settings/defaults";
import {
extractBasesValue,
isNullBasesValue,
renderBasesValue,
resolveTaskCardPropertyLabel,
type TaskCardPresentationOptions,
} from "./taskCardPresentation";
@ -938,6 +940,7 @@ function hasValidValue(value: any): boolean {
return (
value !== null &&
value !== undefined &&
!isNullBasesValue(value) &&
!(Array.isArray(value) && value.length === 0) &&
!(typeof value === "string" && value.trim() === "")
);
@ -967,7 +970,9 @@ function renderUserProperty(
element.createEl("span", { text: `${fieldName}: ` });
// Create value container
const valueContainer = element.createEl("span");
const valueContainer = element.createEl("span", {
cls: "task-card__metadata-value",
});
// Create shared services to avoid redundant object creation
const linkServices: LinkServices = {
@ -1051,7 +1056,9 @@ function renderGenericProperty(
element.createEl("span", { text: `${displayName}: ` });
// Create value container
const valueContainer = element.createEl("span");
const valueContainer = element.createEl("span", {
cls: "task-card__metadata-value",
});
if (Array.isArray(value)) {
// Handle arrays - render each item separately to detect links
@ -1076,6 +1083,10 @@ function renderPropertyValue(
value: unknown,
plugin?: TaskNotesPlugin
): void {
if (plugin && renderBasesValue(container, value, plugin.app.renderContext)) {
return;
}
if (typeof value === "string" && plugin) {
// Check if string contains links and render appropriately
const linkServices: LinkServices = {

View file

@ -226,6 +226,7 @@ export function renderTextWithLinks(
href: tag,
role: "button",
tabindex: "0",
"data-tn-click-exclude": "true",
},
});
@ -238,6 +239,7 @@ export function renderTextWithLinks(
tagEl.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
options.onTagClick!(tag, e as any);
}
});

View file

@ -19,6 +19,7 @@ export function renderTag(container: HTMLElement, tag: string, services?: TagSer
href: normalized,
role: "button",
tabindex: "0",
"data-tn-click-exclude": "true",
},
});
@ -34,6 +35,7 @@ export function renderTag(container: HTMLElement, tag: string, services?: TagSer
el.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
services.onTagClick!(normalized, e as any);
}
});
@ -81,6 +83,7 @@ export function renderContextsValue(
attr: {
role: "button",
tabindex: "0",
"data-tn-click-exclude": "true",
},
});
@ -94,6 +97,7 @@ export function renderContextsValue(
el.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
services.onTagClick!(normalized, e);
}
});
@ -120,6 +124,7 @@ export function renderContextsValue(
attr: {
role: "button",
tabindex: "0",
"data-tn-click-exclude": "true",
},
});
@ -133,6 +138,7 @@ export function renderContextsValue(
el.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
services.onTagClick!(normalized, e);
}
});

View file

@ -1,16 +1,57 @@
import type { RenderContext, Value } from "obsidian";
export interface TaskCardPresentationOptions {
propertyLabels?: Record<string, string>;
}
export function isBasesValue(value: unknown): value is Value {
return (
typeof value === "object" &&
value !== null &&
typeof (value as { renderTo?: unknown }).renderTo === "function" &&
typeof (value as { toString?: unknown }).toString === "function"
);
}
export function isNullBasesValue(value: unknown): boolean {
return value === null || (value as { constructor?: { name?: string } } | undefined)?.constructor?.name === "NullValue";
}
export function renderBasesValue(
container: HTMLElement,
value: unknown,
renderContext: RenderContext
): boolean {
if (!isBasesValue(value) || isNullBasesValue(value)) {
return false;
}
try {
value.renderTo(container, renderContext);
if (!container.hasChildNodes() && !container.textContent) {
container.textContent = value.toString();
}
} catch (error) {
console.debug("[TaskNotes] Error rendering Bases value:", error);
container.textContent = value.toString();
}
return true;
}
/**
* Extract raw value from a Bases Value object.
* Bases API may return objects like {icon: "...", data: ...} or {icon: "...", link: "..."}
* instead of raw primitive values. This function extracts the actual value.
*
* For link values (icon: "lucide-link"), Bases strips the [[]] from wikilinks,
* so we need to restore them to ensure proper rendering.
* Normalize older Bases-like wrapper objects. Official Bases Value instances are
* returned intact so TaskCard can render them with Value.renderTo().
*/
export function extractBasesValue(value: unknown): unknown {
if (isNullBasesValue(value)) {
return "";
}
if (isBasesValue(value)) {
return value;
}
if (value && typeof value === "object" && "icon" in value) {
const v = value as Record<string, unknown>;

View file

@ -11,6 +11,18 @@ export interface ClickHandlerOptions {
contextMenuHandler?: (e: MouseEvent) => Promise<void>; // Optional context menu handler
}
const DEFAULT_EXCLUDE_SELECTOR = [
"a",
"button",
"input",
"textarea",
"select",
"[role=\"button\"]",
"[data-tn-no-drag=\"true\"]",
"[data-tn-click-exclude=\"true\"]",
".tag",
].join(", ");
/**
* Creates a reusable click handler that supports single/double click distinction
* based on user settings.
@ -19,6 +31,9 @@ export interface ClickHandlerOptions {
export function createTaskClickHandler(options: ClickHandlerOptions) {
const { task, plugin, excludeSelector, onSingleClick, onDoubleClick, contextMenuHandler } =
options;
const clickExcludeSelector = excludeSelector
? `${DEFAULT_EXCLUDE_SELECTOR}, ${excludeSelector}`
: DEFAULT_EXCLUDE_SELECTOR;
let clickTimeout: ReturnType<typeof setTimeout> | null = null;
@ -71,11 +86,9 @@ export function createTaskClickHandler(options: ClickHandlerOptions) {
};
const clickHandler = async (e: MouseEvent) => {
if (excludeSelector) {
const target = e.target as HTMLElement;
if (target.closest(excludeSelector)) {
return;
}
const target = e.target as HTMLElement;
if (target.closest(clickExcludeSelector)) {
return;
}
// Check for selection mode - only shift+click triggers selection

View file

@ -119,6 +119,37 @@
white-space: nowrap;
}
.tasknotes-plugin .task-card__metadata-property {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: 100%;
}
.tasknotes-plugin .task-card__metadata-value {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
}
.tasknotes-plugin .task-card__metadata-property:has(.tag),
.tasknotes-plugin .task-card__metadata-value:has(.tag) {
flex-wrap: nowrap;
overflow: hidden;
}
.tasknotes-plugin .task-card__metadata-property .tag {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
white-space: nowrap;
}
.tasknotes-plugin .task-card__metadata-pill {
display: inline-flex;
align-items: center;

View file

@ -20,6 +20,7 @@
import { describe, it, expect } from '@jest/globals';
import {
extractBasesValue,
renderBasesValue,
resolveTaskCardPropertyLabel,
} from "../../../src/ui/taskCardPresentation";
@ -64,4 +65,18 @@ describe('Issue #1720: Bases date-like value rendering', () => {
})
).toBe("Modified");
});
it('preserves official Bases Values for native rendering', () => {
const value = {
renderTo: (el: HTMLElement) => {
el.textContent = "Native Bases value";
},
toString: () => "Fallback Bases value",
};
const container = document.createElement("span");
expect(extractBasesValue(value)).toBe(value);
expect(renderBasesValue(container, value, {} as any)).toBe(true);
expect(container.textContent).toBe("Native Bases value");
});
});

View file

@ -593,6 +593,35 @@ describe('TaskCard Component', () => {
expect(mockPlugin.openTaskEditModal).toHaveBeenCalledWith(task);
});
it('should not open the edit modal when clicking a Bases-rendered tag', async () => {
const basesTagsValue = {
renderTo: (el: HTMLElement) => {
el.createEl('a', {
cls: 'tag',
text: '#client',
attr: { href: '#client' },
});
},
toString: () => '#client',
};
const tagTask = TaskFactory.createTask({
customProperties: {
'file.tags': basesTagsValue,
},
});
mockPlugin.fieldMapper.lookupMappingKey = jest.fn(() => null);
const tagCard = createTaskCard(tagTask, mockPlugin, ['file.tags']);
container.appendChild(tagCard);
const tag = tagCard.querySelector('.tag') as HTMLElement;
expect(tag).toBeTruthy();
tag.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await new Promise(resolve => setTimeout(resolve, 10));
expect(mockPlugin.openTaskEditModal).not.toHaveBeenCalled();
});
it('should handle Ctrl+click to open source note', async () => {
const mockFile = new TFile('test.md');
mockApp.vault.getAbstractFileByPath.mockReturnValue(mockFile);