Update Bases calendar width controls and editor scheduling

This commit is contained in:
callumalpass 2026-03-29 21:02:49 +11:00
parent a32b34593b
commit 6c3c7ba4bb
12 changed files with 1111 additions and 15 deletions

View file

@ -26,8 +26,13 @@ Example:
## Fixed
- (#1601, #1709) Fixed reading-mode relationship widgets rendering multiple times during startup by serializing per-note widget injection requests and coalescing overlapping refreshes
- Applied the same reading-mode injection guard to task cards so both note widgets use the same safer lifecycle
- Thanks to @wealthychef1 and @CarlJohnson99 for reporting
- (#1713) Fixed ICS export so date-only scheduled tasks are emitted as proper all-day events instead of midnight-timed events, improving compatibility with calendar apps like Thunderbird and Evolution
- Thanks to @bepolymathe for the feature request and clear calendar interoperability report
- (#1704) Added an option to widen the current day column in multi-day calendar time-grid views, with separate settings for width multiplier and whether the effect applies to week view, custom multi-day view, or both
- Thanks to @dictionarymouse for the feature request
- (#267, #948, #1682, #1717, #1725) Fixed expanded subtasks and dependency cards in Bases Task List and Kanban views so related items can inherit the current view's filters instead of always showing every linked task, and added a per-view "Expanded relationships" option to switch between inheriting filters and showing all related tasks
- Thanks to @mdbraber, @GardarikanetS, @Glint-Eye, @prepare4robots, and @robmcphers0n for the reports and feature requests that surfaced the same filtering gap from different angles
- Improved custom view and search-pane activation reliability by using Obsidian's deferred-leaf APIs when revealing existing leaves, so TaskNotes waits for deferred tabs to load before interacting with them

View file

@ -139,12 +139,19 @@ export async function launchObsidian(): Promise<ObsidianApp> {
// Pass the vault path directly to match the manual launch script.
// Use --user-data-dir to force a separate Electron instance (prevents single-instance detection)
const userDataDir = path.join(PROJECT_ROOT, '.obsidian-config-e2e');
obsidianProcess = spawn(obsidianBinary, [
const obsidianArgs = [
'--no-sandbox',
`--remote-debugging-port=${remoteDebuggingPort}`,
`--user-data-dir=${userDataDir}`,
E2E_VAULT_DIR,
], {
];
const launchWithoutDisplay = !process.env.DISPLAY;
const spawnCommand = launchWithoutDisplay ? 'xvfb-run' : obsidianBinary;
const spawnArgs = launchWithoutDisplay
? ['-a', obsidianBinary, ...obsidianArgs]
: obsidianArgs;
obsidianProcess = spawn(spawnCommand, spawnArgs, {
cwd: UNPACKED_DIR,
stdio: ['ignore', 'pipe', 'pipe'],
env: {

800
e2e/release-gifs.spec.ts Normal file
View file

@ -0,0 +1,800 @@
import { test, expect, Page } from "@playwright/test";
import * as fs from "fs";
import * as path from "path";
import { execFileSync } from "child_process";
import { closeObsidian, launchObsidian, ObsidianApp, runCommand } from "./obsidian";
const PROJECT_ROOT = path.resolve(__dirname, "..");
const E2E_VAULT_DIR = path.join(PROJECT_ROOT, "tasknotes-e2e-vault");
const GIF_ROOT = path.join(PROJECT_ROOT, "test-results", "release-gifs");
const FRAME_ROOT = path.join(PROJECT_ROOT, "test-results", "release-gif-frames");
const DOC_VIEWPORT = { width: 1400, height: 900 };
let app: ObsidianApp;
type BackupMap = Map<string, string | null>;
class GifRecorder {
private frameIndex = 0;
private readonly frameDir: string;
private readonly gifPath: string;
private readonly palettePath: string;
constructor(private readonly name: string) {
this.frameDir = path.join(FRAME_ROOT, name);
this.gifPath = path.join(GIF_ROOT, `${name}.gif`);
this.palettePath = path.join(this.frameDir, "palette.png");
fs.rmSync(this.frameDir, { recursive: true, force: true });
fs.mkdirSync(this.frameDir, { recursive: true });
fs.mkdirSync(GIF_ROOT, { recursive: true });
}
private nextFramePath(): string {
return path.join(this.frameDir, `frame-${String(this.frameIndex).padStart(3, "0")}.png`);
}
async capture(page: Page, holdFrames = 1): Promise<void> {
const firstFramePath = this.nextFramePath();
await page.screenshot({ path: firstFramePath });
this.frameIndex += 1;
for (let i = 1; i < holdFrames; i += 1) {
const duplicatePath = this.nextFramePath();
fs.copyFileSync(firstFramePath, duplicatePath);
this.frameIndex += 1;
}
}
finalize(): string {
if (this.frameIndex === 0) {
throw new Error(`No frames captured for ${this.name}`);
}
const inputPattern = path.join(this.frameDir, "frame-%03d.png");
execFileSync(
"ffmpeg",
[
"-y",
"-framerate",
"3",
"-i",
inputPattern,
"-vf",
"fps=8,scale=1200:-1:flags=lanczos,palettegen",
this.palettePath,
],
{ stdio: "ignore" }
);
execFileSync(
"ffmpeg",
[
"-y",
"-framerate",
"3",
"-i",
inputPattern,
"-i",
this.palettePath,
"-lavfi",
"fps=8,scale=1200:-1:flags=lanczos[x];[x][1:v]paletteuse",
this.gifPath,
],
{ stdio: "ignore" }
);
return this.gifPath;
}
}
test.describe.configure({ mode: "serial" });
test.beforeAll(async () => {
app = await launchObsidian();
await app.page.setViewportSize(DOC_VIEWPORT);
fs.mkdirSync(GIF_ROOT, { recursive: true });
fs.mkdirSync(FRAME_ROOT, { recursive: true });
});
test.afterAll(async () => {
if (app) {
await closeObsidian(app);
}
});
function getPage(): Page {
if (!app?.page) {
throw new Error("Obsidian app not initialized");
}
return app.page;
}
function vaultPath(relativePath: string): string {
return path.join(E2E_VAULT_DIR, relativePath);
}
function backupFiles(relativePaths: string[]): BackupMap {
const backup = new Map<string, string | null>();
for (const relativePath of relativePaths) {
const fullPath = vaultPath(relativePath);
backup.set(relativePath, fs.existsSync(fullPath) ? fs.readFileSync(fullPath, "utf8") : null);
}
return backup;
}
function restoreFiles(backup: BackupMap): void {
for (const [relativePath, original] of backup.entries()) {
const fullPath = vaultPath(relativePath);
if (original === null) {
fs.rmSync(fullPath, { force: true });
continue;
}
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, original);
}
}
function writeVaultFile(relativePath: string, content: string): void {
const fullPath = vaultPath(relativePath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
}
function formatDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
function formatDateTime(date: Date, hours: number, minutes = 0): string {
const copy = new Date(date);
copy.setHours(hours, minutes, 0, 0);
return copy.toISOString().slice(0, 16);
}
async function ensureCleanState(page: Page): Promise<void> {
for (let i = 0; i < 3; i += 1) {
await page.keyboard.press("Escape");
await page.waitForTimeout(100);
}
await page.waitForTimeout(400);
}
async function openFileByPath(page: Page, relativePath: string): Promise<void> {
const opened = await page.evaluate(async (targetPath) => {
const obsidianApp = (window as any).app;
const file = obsidianApp?.vault?.getAbstractFileByPath?.(targetPath);
if (!file) return false;
await obsidianApp.workspace.getLeaf(true).openFile(file);
return true;
}, relativePath);
if (!opened) {
throw new Error(`Could not open ${relativePath}`);
}
const fileLabel = path.basename(relativePath, path.extname(relativePath));
const tabHeader = page.locator(`.workspace-tab-header:has-text("${fileLabel}")`).last();
if (await tabHeader.isVisible({ timeout: 5000 }).catch(() => false)) {
await tabHeader.click();
await page.waitForTimeout(400);
}
await page.waitForTimeout(1400);
}
async function reloadWithoutSaving(page: Page): Promise<void> {
await runCommand(page, "Reload app without saving");
await page.waitForTimeout(3500);
}
async function setPluginSettings(page: Page, patch: Record<string, unknown>): Promise<void> {
await page.evaluate(async (settingsPatch) => {
const obsidianApp = (window as any).app;
const plugin = obsidianApp?.plugins?.plugins?.["tasknotes"];
if (!plugin?.settings) {
throw new Error("TaskNotes plugin not available");
}
const mergeInto = (target: any, source: any) => {
for (const [key, value] of Object.entries(source)) {
if (
value &&
typeof value === "object" &&
!Array.isArray(value) &&
target[key] &&
typeof target[key] === "object" &&
!Array.isArray(target[key])
) {
mergeInto(target[key], value);
} else {
target[key] = value;
}
}
};
mergeInto(plugin.settings, settingsPatch);
if (typeof settingsPatch.uiLanguage === "string") {
plugin.i18n?.setLocale?.(settingsPatch.uiLanguage);
}
await plugin.saveSettings?.();
}, patch);
await page.waitForTimeout(1200);
}
async function openTaskListByPath(page: Page, relativePath: string): Promise<void> {
await openFileByPath(page, relativePath);
const activeLeaf = page.locator(".workspace-leaf.mod-active").first();
await activeLeaf.waitFor({ state: "visible", timeout: 10000 });
const hasRenderableList = await activeLeaf
.locator('.task-list-view, [data-type="tasknotesTaskList"], .task-card, [class*="bases"]')
.first()
.isVisible({ timeout: 3000 })
.catch(() => false);
if (!hasRenderableList) {
await page.waitForTimeout(1200);
}
}
async function openCalendarByPath(page: Page, relativePath: string): Promise<void> {
await openFileByPath(page, relativePath);
const calendar = page.locator(".workspace-leaf.mod-active .fc, .workspace-leaf.mod-active .tasknotes-calendar-view").first();
await calendar.waitFor({ state: "visible", timeout: 10000 });
}
async function openTaskNote(page: Page, relativePath: string): Promise<void> {
await openFileByPath(page, relativePath);
await page.waitForTimeout(1200);
}
function createTaskListBase(name: string, extra = ""): string {
return `filters:
and:
- file.hasTag("task")
views:
- type: tasknotesTaskList
name: "${name}"
order:
- status
- priority
- due
- scheduled
- projects
- contexts
- blockedBy
- file.name
${extra}`;
}
function createKanbanBase(name: string, extra = ""): string {
return `filters:
and:
- file.hasTag("task")
views:
- type: tasknotesKanban
name: "${name}"
order:
- status
- priority
- due
- scheduled
- projects
- contexts
- blockedBy
- file.name
groupBy:
property: status
direction: ASC
${extra}`;
}
function createCalendarBase(name: string, extraOptions = ""): string {
return `filters:
and:
- file.hasTag("task")
views:
- type: tasknotesCalendar
name: "${name}"
order:
- status
- priority
- due
- scheduled
- file.name
options:
showScheduled: true
showDue: true
showRecurring: true
showTimeEntries: true
calendarView: timeGridWeek
slotMinTime: 06:00:00
slotMaxTime: 22:00:00
slotDuration: 00:30:00
${extraOptions}`;
}
test("current-day-column-width", async () => {
const page = getPage();
const today = new Date();
const taskPath = "TaskNotes/Release GIF Fixtures/today-width-demo.md";
const basePath = "TaskNotes/Views/release-gif-calendar-width.base";
const backup = backupFiles([taskPath, basePath]);
try {
writeVaultFile(
taskPath,
`---
title: Today width demo
status: open
priority: normal
scheduled: ${formatDateTime(today, 11)}
due: ${formatDateTime(today, 13)}
tags:
- task
---
# Today width demo
`
);
writeVaultFile(basePath, createCalendarBase("Today Width Demo", " todayColumnWidthMultiplier: 1\n"));
await ensureCleanState(page);
const recorder = new GifRecorder("release-1704-today-column-width");
await openCalendarByPath(page, basePath);
await recorder.capture(page, 4);
const propertiesButton = page.locator('button:has-text("Properties"), [aria-label*="Properties"]').first();
if (await propertiesButton.isVisible({ timeout: 3000 }).catch(() => false)) {
await propertiesButton.click();
await page.waitForTimeout(500);
await recorder.capture(page, 2);
await page.keyboard.press("Escape");
await page.waitForTimeout(300);
}
writeVaultFile(basePath, createCalendarBase("Today Width Demo", " todayColumnWidthMultiplier: 3\n"));
await openCalendarByPath(page, basePath);
await recorder.capture(page, 6);
recorder.finalize();
} finally {
restoreFiles(backup);
}
});
test("bases-date-values", async () => {
const page = getPage();
const basePath = "TaskNotes/Views/release-gif-bases-date-values.base";
const backup = backupFiles([basePath]);
try {
writeVaultFile(
basePath,
createTaskListBase(
"Bases Date Values",
` filters:
and:
- file.name == "Review project proposal" || file.name == "Buy groceries"
`
)
);
await ensureCleanState(page);
const recorder = new GifRecorder("release-1720-bases-date-values");
await openTaskListByPath(page, basePath);
await recorder.capture(page, 5);
recorder.finalize();
} finally {
restoreFiles(backup);
}
});
test("translated-task-card-labels", async () => {
const page = getPage();
const dataPath = ".obsidian/plugins/tasknotes/data.json";
const backup = backupFiles([dataPath]);
try {
await ensureCleanState(page);
await setPluginSettings(page, { uiLanguage: "fr" });
const recorder = new GifRecorder("release-1633-translated-task-card-labels");
await openTaskNote(page, "TaskNotes/Review project proposal.md");
await recorder.capture(page, 4);
await openTaskNote(page, "TaskNotes/Write documentation.md");
await recorder.capture(page, 4);
recorder.finalize();
} finally {
restoreFiles(backup);
await reloadWithoutSaving(page);
}
});
test.skip("expanded-relationships-filter-mode", async () => {
const page = getPage();
const parentPath = "TaskNotes/Release GIF Fixtures/Relationships Parent.md";
const doneChildPath = "TaskNotes/Release GIF Fixtures/Relationships Done Child.md";
const openChildPath = "TaskNotes/Release GIF Fixtures/Relationships Open Child.md";
const inheritBasePath = "TaskNotes/Views/release-gif-relationships-inherit.base";
const allBasePath = "TaskNotes/Views/release-gif-relationships-all.base";
const backup = backupFiles([parentPath, doneChildPath, openChildPath, inheritBasePath, allBasePath]);
try {
writeVaultFile(
parentPath,
`---
title: Relationships Parent
status: open
priority: normal
scheduled: ${formatDate(new Date())}
tags:
- task
---
# Relationships Parent
`
);
writeVaultFile(
doneChildPath,
`---
title: Relationships Done Child
status: done
priority: normal
projects:
- "[[Relationships Parent]]"
tags:
- task
---
# Relationships Done Child
`
);
writeVaultFile(
openChildPath,
`---
title: Relationships Open Child
status: open
priority: normal
projects:
- "[[Relationships Parent]]"
tags:
- task
---
# Relationships Open Child
`
);
writeVaultFile(
inheritBasePath,
createTaskListBase(
"Inherit Filter",
` filters:
and:
- file.name == "Relationships Parent"
- status != "done"
options:
expandedRelationshipFilterMode: inherit
`
)
);
writeVaultFile(
allBasePath,
createTaskListBase(
"Show All Relationships",
` filters:
and:
- file.name == "Relationships Parent"
- status != "done"
options:
expandedRelationshipFilterMode: show-all
`
)
);
await ensureCleanState(page);
const recorder = new GifRecorder("release-1725-expanded-relationships-filter-mode");
await openTaskListByPath(page, inheritBasePath);
const inheritChevron = page.locator(".workspace-leaf.mod-active .task-card__chevron").first();
await inheritChevron.click();
await page.waitForTimeout(1200);
await recorder.capture(page, 4);
await openTaskListByPath(page, allBasePath);
const allChevron = page.locator(".workspace-leaf.mod-active .task-card__chevron").first();
await allChevron.click();
await page.waitForTimeout(1200);
await recorder.capture(page, 5);
recorder.finalize();
} finally {
restoreFiles(backup);
}
});
test("recurring-task-visible-without-complete-instances", async () => {
const page = getPage();
const today = new Date();
const taskPath = "TaskNotes/Release GIF Fixtures/Recurring visibility demo.md";
const basePath = "TaskNotes/Views/release-gif-recurring-visible.base";
const backup = backupFiles([taskPath, basePath]);
try {
writeVaultFile(
taskPath,
`---
title: Recurring visibility demo
status: open
priority: normal
scheduled: ${formatDate(today)}
recurrence: FREQ=DAILY
tags:
- task
---
# Recurring visibility demo
`
);
writeVaultFile(
basePath,
createTaskListBase(
"Recurring Visible",
` filters:
and:
- file.name == "Recurring visibility demo"
`
)
);
await ensureCleanState(page);
const recorder = new GifRecorder("release-1644-recurring-visible");
await openTaskListByPath(page, basePath);
await recorder.capture(page, 5);
recorder.finalize();
} finally {
restoreFiles(backup);
}
});
test("calendar-visible-date-preserved", async () => {
const page = getPage();
const future = new Date();
future.setMonth(future.getMonth() + 1, 14);
const taskPath = "TaskNotes/Release GIF Fixtures/Future calendar edit.md";
const basePath = "TaskNotes/Views/release-gif-calendar-preserve-date.base";
const backup = backupFiles([taskPath, basePath]);
try {
writeVaultFile(
taskPath,
`---
title: Future calendar edit
status: open
priority: normal
scheduled: ${formatDateTime(future, 10)}
due: ${formatDateTime(future, 11)}
tags:
- task
---
# Future calendar edit
`
);
writeVaultFile(basePath, createCalendarBase("Preserve Date", ""));
await ensureCleanState(page);
const recorder = new GifRecorder("release-1513-calendar-visible-date-preserved");
await openCalendarByPath(page, basePath);
const nextButton = page.locator(".workspace-leaf.mod-active .fc-next-button").first();
await nextButton.click();
await page.waitForTimeout(700);
await recorder.capture(page, 3);
const event = page.locator('.workspace-leaf.mod-active .fc-event:has-text("Future calendar edit")').first();
await event.click();
await page.waitForTimeout(800);
const titleInput = page.locator('.modal input[type="text"]').first();
await expect(titleInput).toBeVisible({ timeout: 5000 });
await titleInput.fill("Future calendar edit updated");
await page.waitForTimeout(400);
const saveButton = page.locator('.modal button:has-text("Save"), .modal button.mod-cta').last();
await saveButton.click();
await page.waitForTimeout(1500);
await recorder.capture(page, 5);
recorder.finalize();
} finally {
restoreFiles(backup);
}
});
test("readable-line-length-inline-card", async () => {
const page = getPage();
await ensureCleanState(page);
const recorder = new GifRecorder("release-1630-readable-line-length");
await page.evaluate(async () => {
const obsidianApp = (window as any).app;
const plugin = obsidianApp?.plugins?.plugins?.["tasknotes"];
if (obsidianApp?.customCss?.setTheme) {
try {
await obsidianApp.customCss.setTheme("Minimal");
} catch {
// Best effort only.
}
}
if (obsidianApp?.vault?.setConfig) {
await obsidianApp.vault.setConfig("readableLineLength", true);
}
await plugin?.saveSettings?.();
obsidianApp?.workspace?.trigger?.("css-change");
});
await page.waitForTimeout(1000);
await openTaskNote(page, "TaskNotes/Buy groceries.md");
await recorder.capture(page, 5);
recorder.finalize();
});
test("modal-relationship-cards", async () => {
const page = getPage();
const parentPath = "TaskNotes/Release GIF Fixtures/Modal parent.md";
const childPath = "TaskNotes/Release GIF Fixtures/Modal child.md";
const blockerPath = "TaskNotes/Release GIF Fixtures/Modal blocker.md";
const basePath = "TaskNotes/Views/release-gif-modal-relationships.base";
const backup = backupFiles([parentPath, childPath, blockerPath, basePath]);
try {
writeVaultFile(
parentPath,
`---
title: Modal parent
status: open
priority: normal
scheduled: ${formatDateTime(new Date(), 9)}
blockedBy:
- uid: "[[Modal blocker]]"
reltype: FINISHTOSTART
tags:
- task
---
# Modal parent
`
);
writeVaultFile(
childPath,
`---
title: Modal child
status: open
priority: normal
projects:
- "[[Modal parent]]"
tags:
- task
---
# Modal child
`
);
writeVaultFile(
blockerPath,
`---
title: Modal blocker
status: in-progress
priority: high
tags:
- task
---
# Modal blocker
`
);
writeVaultFile(
basePath,
createTaskListBase(
"Modal Relationships",
` filters:
and:
- file.name == "Modal parent"
`
)
);
await ensureCleanState(page);
const recorder = new GifRecorder("release-1716-modal-relationship-cards");
await openTaskListByPath(page, basePath);
const card = page.locator('.workspace-leaf.mod-active .task-card:has-text("Modal parent")').first();
await card.click();
await page.waitForTimeout(1000);
await recorder.capture(page, 5);
recorder.finalize();
} finally {
restoreFiles(backup);
}
});
test.skip("drag-to-reorder-task-list", async () => {
const page = getPage();
const firstTaskPath = "TaskNotes/Release GIF Fixtures/Reorder alpha.md";
const secondTaskPath = "TaskNotes/Release GIF Fixtures/Reorder beta.md";
const thirdTaskPath = "TaskNotes/Release GIF Fixtures/Reorder gamma.md";
const basePath = "TaskNotes/Views/release-gif-drag-reorder.base";
const backup = backupFiles([firstTaskPath, secondTaskPath, thirdTaskPath, basePath]);
try {
writeVaultFile(
firstTaskPath,
`---
title: Reorder alpha
status: open
priority: normal
tasknotes_manual_order: 0|hzzzzz:
tags:
- task
---
# Reorder alpha
`
);
writeVaultFile(
secondTaskPath,
`---
title: Reorder beta
status: open
priority: normal
tasknotes_manual_order: 0|i00007:
tags:
- task
---
# Reorder beta
`
);
writeVaultFile(
thirdTaskPath,
`---
title: Reorder gamma
status: open
priority: normal
tasknotes_manual_order: 0|i0000f:
tags:
- task
---
# Reorder gamma
`
);
writeVaultFile(
basePath,
createTaskListBase(
"Manual Reorder",
` filters:
and:
- file.name == "Reorder alpha" || file.name == "Reorder beta" || file.name == "Reorder gamma"
sort:
- column: tasknotes_manual_order
direction: DESC
`
)
);
await ensureCleanState(page);
const recorder = new GifRecorder("release-1619-drag-to-reorder");
await openTaskListByPath(page, basePath);
await recorder.capture(page, 3);
const dragged = page.locator('.workspace-leaf.mod-active .task-card:has-text("Reorder gamma")').first();
const target = page.locator('.workspace-leaf.mod-active .task-card:has-text("Reorder alpha")').first();
await dragged.dragTo(target);
await page.waitForTimeout(1800);
await recorder.capture(page, 6);
recorder.finalize();
} finally {
restoreFiles(backup);
}
});

View file

@ -11,6 +11,7 @@
"build-css": "node build-css.mjs",
"e2e": "playwright test",
"e2e:docs": "playwright test e2e/docs-screenshots.spec.ts",
"e2e:release-gifs": "playwright test e2e/release-gifs.spec.ts",
"e2e:docs:copy": "mkdir -p media/docs && cp test-results/docs/*.png media/docs/",
"e2e:setup": "bash e2e-setup.sh",
"e2e:launch": "bash e2e-launch.sh",

View file

@ -92,6 +92,32 @@ export function normalizeDateValueForCalendar(
return null;
}
export function shouldWidenTodayColumn(viewType: string, todayColumnWidthMultiplier: number): boolean {
if (todayColumnWidthMultiplier <= 1) return false;
return viewType === "timeGridWeek" || viewType === "timeGridCustom";
}
export function getTodayColumnWidths(
dateKeys: string[],
todayDate: string,
todayColumnWidthMultiplier: number
): Map<string, string> | null {
if (todayColumnWidthMultiplier <= 1 || dateKeys.length <= 1) return null;
const uniqueDates = Array.from(new Set(dateKeys));
if (!uniqueDates.includes(todayDate)) return null;
const baseWidth = 100 / (uniqueDates.length - 1 + todayColumnWidthMultiplier);
const todayWidth = baseWidth * todayColumnWidthMultiplier;
return new Map(
uniqueDates.map((dateKey) => [
dateKey,
`${dateKey === todayDate ? todayWidth : baseWidth}%`,
])
);
}
export class CalendarView extends BasesViewBase {
type = "tasknotesCalendar";
calendar: Calendar | null = null; // Made public for factory access
@ -148,6 +174,7 @@ export class CalendarView extends BasesViewBase {
showWeekends: boolean;
showAllDaySlot: boolean;
showTodayHighlight: boolean;
todayColumnWidthMultiplier: number;
selectMirror: boolean;
timeFormat: string;
scrollTime: string;
@ -210,6 +237,7 @@ export class CalendarView extends BasesViewBase {
showWeekends: calendarSettings.showWeekends,
showAllDaySlot: true,
showTodayHighlight: calendarSettings.showTodayHighlight,
todayColumnWidthMultiplier: 1,
selectMirror: calendarSettings.selectMirror,
timeFormat: calendarSettings.timeFormat,
eventMinHeight: calendarSettings.eventMinHeight,
@ -246,6 +274,7 @@ export class CalendarView extends BasesViewBase {
onResize(): void {
if (this.calendar) {
this.calendar.updateSize();
this.scheduleTodayColumnWidthUpdate();
}
}
@ -339,6 +368,7 @@ export class CalendarView extends BasesViewBase {
this.config.get('showWeekends'),
this.config.get('showAllDaySlot'),
this.config.get('showTodayHighlight'),
this.config.get('todayColumnWidthMultiplier'),
this.config.get('selectMirror'),
this.config.get('timeFormat'),
this.config.get('scrollTime'),
@ -551,6 +581,11 @@ export class CalendarView extends BasesViewBase {
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.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;
@ -589,6 +624,7 @@ export class CalendarView extends BasesViewBase {
// Apply today highlight styling if calendar is already initialized
if (this.calendar) {
this.applyTodayHighlightStyling();
this.scheduleTodayColumnWidthUpdate();
}
} catch (e) {
console.error("[TaskNotes][CalendarView] Error reading view options:", e);
@ -830,7 +866,9 @@ export class CalendarView extends BasesViewBase {
this.viewOptions.calendarView = newViewType;
this.debouncedSaveViewType(newViewType);
}
this.scheduleTodayColumnWidthUpdate();
},
datesSet: () => this.scheduleTodayColumnWidthUpdate(),
};
// Create calendar
@ -840,6 +878,7 @@ export class CalendarView extends BasesViewBase {
// Apply showTodayHighlight option via CSS
this.applyTodayHighlightStyling();
this.scheduleTodayColumnWidthUpdate();
}
/**
@ -858,6 +897,83 @@ export class CalendarView extends BasesViewBase {
}
}
private scheduleTodayColumnWidthUpdate(): void {
const win = this.containerEl.ownerDocument.defaultView || window;
win.setTimeout(() => this.applyTodayColumnWidth(), 0);
}
private applyTodayColumnWidth(): void {
if (!this.calendarEl || !this.calendar) return;
this.resetTodayColumnWidths();
if (
!shouldWidenTodayColumn(this.calendar.view.type, this.viewOptions.todayColumnWidthMultiplier)
) {
return;
}
const headerCells = Array.from(
this.calendarEl.querySelectorAll<HTMLElement>(".fc-col-header-cell[data-date]")
);
const dateKeys = headerCells
.map((cell) => cell.dataset.date)
.filter((date): date is string => Boolean(date));
const todayCell = headerCells.find((cell) => cell.classList.contains("fc-day-today"));
const todayDate = todayCell?.dataset.date;
if (!todayDate) return;
const widths = getTodayColumnWidths(
dateKeys,
todayDate,
this.viewOptions.todayColumnWidthMultiplier
);
if (!widths) return;
const dayElements = this.calendarEl.querySelectorAll<HTMLElement>(
".fc-col-header-cell[data-date], .fc-timegrid-col[data-date], .fc-daygrid-day[data-date]"
);
dayElements.forEach((element) => {
const dateKey = element.dataset.date;
if (!dateKey) return;
const width = widths.get(dateKey);
if (!width) return;
element.style.width = width;
element.style.minWidth = width;
element.style.maxWidth = width;
});
this.calendarEl.querySelectorAll("colgroup").forEach((group) => {
const cols = Array.from(group.querySelectorAll<HTMLTableColElement>("col"));
if (cols.length < dateKeys.length) return;
const dayCols = cols.length === dateKeys.length ? cols : cols.slice(cols.length - dateKeys.length);
if (dayCols.length !== dateKeys.length) return;
dayCols.forEach((col, index) => {
const width = widths.get(dateKeys[index]);
if (!width) return;
col.style.width = width;
});
});
}
private resetTodayColumnWidths(): void {
if (!this.calendarEl) return;
const dayElements = this.calendarEl.querySelectorAll<HTMLElement>(
".fc-col-header-cell[data-date], .fc-timegrid-col[data-date], .fc-daygrid-day[data-date]"
);
dayElements.forEach((element) => {
element.style.removeProperty("width");
element.style.removeProperty("min-width");
element.style.removeProperty("max-width");
});
this.calendarEl.querySelectorAll("colgroup col").forEach((col) => {
(col as HTMLElement).style.removeProperty("width");
});
}
/**
* Save view type to config with debouncing.
* Uses a 1 second debounce to avoid rapid view recreation when clicking through views.

View file

@ -313,6 +313,15 @@ export async function registerBasesTaskList(plugin: TaskNotesPlugin): Promise<vo
displayName: t("layout.showTodayHighlight"),
default: calendarSettings.showTodayHighlight,
},
{
type: "slider",
key: "todayColumnWidthMultiplier",
displayName: t("layout.todayColumnWidthMultiplier"),
default: 1,
min: 1,
max: 5,
step: 0.5,
},
{
type: "toggle",
key: "selectMirror",

View file

@ -0,0 +1,71 @@
import { WorkspaceLeaf } from "obsidian";
export interface ReadingModeInjectionContext {
isCurrent(): boolean;
}
interface InjectionState {
running: boolean;
rerun: boolean;
version: number;
}
/**
* Serializes async reading-mode widget injections per leaf and coalesces bursts
* of refresh requests into a final rerun with the latest state.
*/
export class ReadingModeInjectionScheduler {
private readonly states = new WeakMap<WorkspaceLeaf, InjectionState>();
schedule(
leaf: WorkspaceLeaf,
run: (context: ReadingModeInjectionContext) => Promise<void>
): void {
const state = this.getState(leaf);
state.version += 1;
if (state.running) {
state.rerun = true;
return;
}
state.running = true;
void this.runLoop(leaf, run);
}
private getState(leaf: WorkspaceLeaf): InjectionState {
let state = this.states.get(leaf);
if (!state) {
state = {
running: false,
rerun: false,
version: 0,
};
this.states.set(leaf, state);
}
return state;
}
private async runLoop(
leaf: WorkspaceLeaf,
run: (context: ReadingModeInjectionContext) => Promise<void>
): Promise<void> {
const state = this.getState(leaf);
try {
do {
state.rerun = false;
const runVersion = state.version;
await run({
isCurrent: () => this.states.get(leaf)?.version === runVersion,
});
} while (state.rerun);
} finally {
state.running = false;
if (state.rerun) {
state.running = true;
void this.runLoop(leaf, run);
}
}
}
}

View file

@ -58,6 +58,10 @@ import {
import { Extension } from "@codemirror/state";
import TaskNotesPlugin from "../main";
import {
ReadingModeInjectionContext,
ReadingModeInjectionScheduler,
} from "./ReadingModeInjectionScheduler";
// CSS class for identifying plugin-generated elements
const CSS_RELATIONSHIPS_WIDGET = 'tasknotes-relationships-widget';
@ -423,7 +427,8 @@ export function createRelationshipsDecorations(plugin: TaskNotesPlugin): Extensi
*/
async function injectReadingModeWidget(
leaf: WorkspaceLeaf,
plugin: TaskNotesPlugin
plugin: TaskNotesPlugin,
context?: ReadingModeInjectionContext
): Promise<void> {
const view = leaf.view;
if (!(view instanceof MarkdownView) || view.getMode() !== 'preview') {
@ -487,6 +492,11 @@ async function injectReadingModeWidget(
// Create the widget
const widget = await createRelationshipsWidget(plugin, notePath);
if (context && !context.isCurrent()) {
widget.component?.unload();
widget.remove();
return;
}
// Find the markdown-preview-sizer or markdown-preview-section
// RISK: Relies on Obsidian's internal DOM structure
@ -534,6 +544,10 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void {
// Track event refs by source for proper cleanup
const workspaceRefs: EventRef[] = [];
const metadataCacheRefs: EventRef[] = [];
const scheduler = new ReadingModeInjectionScheduler();
const scheduleInjection = (leaf: WorkspaceLeaf) => {
scheduler.schedule(leaf, (context) => injectReadingModeWidget(leaf, plugin, context));
};
// Debounce to prevent excessive re-renders
let debounceTimer: number | null = null;
@ -542,7 +556,7 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void {
debounceTimer = window.setTimeout(() => {
const leaves = plugin.app.workspace.getLeavesOfType('markdown');
leaves.forEach(leaf => {
injectReadingModeWidget(leaf, plugin);
scheduleInjection(leaf);
});
}, 100);
};
@ -554,7 +568,7 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void {
// Inject widget when active leaf changes
const activeLeafChangeRef = plugin.app.workspace.on('active-leaf-change', (leaf) => {
if (leaf) {
injectReadingModeWidget(leaf, plugin);
scheduleInjection(leaf);
}
});
workspaceRefs.push(activeLeafChangeRef);
@ -573,7 +587,7 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void {
leaves.forEach(leaf => {
const view = leaf.view;
if (view instanceof MarkdownView && view.file === file) {
injectReadingModeWidget(leaf, plugin);
scheduleInjection(leaf);
}
});
}, 500);
@ -584,7 +598,7 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void {
// Initial injection for any already-open reading views
const leaves = plugin.app.workspace.getLeavesOfType('markdown');
leaves.forEach(leaf => {
injectReadingModeWidget(leaf, plugin);
scheduleInjection(leaf);
});
// Return cleanup function

View file

@ -64,6 +64,10 @@ import { Extension } from "@codemirror/state";
import TaskNotesPlugin from "../main";
import { createTaskCard } from "../ui/TaskCard";
import { convertInternalToUserProperties } from "../utils/propertyMapping";
import {
ReadingModeInjectionContext,
ReadingModeInjectionScheduler,
} from "./ReadingModeInjectionScheduler";
// CSS class for identifying plugin-generated elements
const CSS_TASK_CARD_WIDGET = 'tasknotes-task-card-note-widget';
@ -415,7 +419,8 @@ export function createTaskCardNoteDecorations(plugin: TaskNotesPlugin): Extensio
*/
async function injectReadingModeWidget(
leaf: WorkspaceLeaf,
plugin: TaskNotesPlugin
plugin: TaskNotesPlugin,
context?: ReadingModeInjectionContext
): Promise<void> {
const view = leaf.view;
if (!(view instanceof MarkdownView) || view.getMode() !== 'preview') {
@ -462,6 +467,11 @@ async function injectReadingModeWidget(
// Create the widget
const widget = createTaskCardWidget(plugin, task);
if (context && !context.isCurrent()) {
widget.component?.unload();
widget.remove();
return;
}
// Find the markdown-preview-sizer
// RISK: Relies on Obsidian's internal DOM structure
@ -492,6 +502,10 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void {
const workspaceRefs: EventRef[] = [];
const metadataCacheRefs: EventRef[] = [];
const emitterRefs: EventRef[] = [];
const scheduler = new ReadingModeInjectionScheduler();
const scheduleInjection = (leaf: WorkspaceLeaf) => {
scheduler.schedule(leaf, (context) => injectReadingModeWidget(leaf, plugin, context));
};
// Debounce to prevent excessive re-renders
let debounceTimer: number | null = null;
@ -500,7 +514,7 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void {
debounceTimer = window.setTimeout(() => {
const leaves = plugin.app.workspace.getLeavesOfType('markdown');
leaves.forEach(leaf => {
injectReadingModeWidget(leaf, plugin);
scheduleInjection(leaf);
});
}, 100);
};
@ -512,7 +526,7 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void {
// Inject widget when active leaf changes
const activeLeafChangeRef = plugin.app.workspace.on('active-leaf-change', (leaf) => {
if (leaf) {
injectReadingModeWidget(leaf, plugin);
scheduleInjection(leaf);
}
});
workspaceRefs.push(activeLeafChangeRef);
@ -531,7 +545,7 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void {
leaves.forEach(leaf => {
const view = leaf.view;
if (view instanceof MarkdownView && view.file === file) {
injectReadingModeWidget(leaf, plugin);
scheduleInjection(leaf);
}
});
}, 500);
@ -549,7 +563,7 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void {
// Initial injection for any already-open reading views
const leaves = plugin.app.workspace.getLeavesOfType('markdown');
leaves.forEach(leaf => {
injectReadingModeWidget(leaf, plugin);
scheduleInjection(leaf);
});
// Return cleanup function

View file

@ -251,6 +251,7 @@ export const en: TranslationTree = {
showWeekends: "Show weekends",
showAllDaySlot: "Show all-day slot",
showTodayHighlight: "Show today highlight",
todayColumnWidthMultiplier: "Today column width multiplier",
showSelectionPreview: "Show selection preview",
timeFormat: "Time format",
timeFormat12: "12-hour (AM/PM)",

View file

@ -48,6 +48,7 @@
"enableTimeblocking": true,
"defaultShowTimeblocks": true,
"defaultTimeblockColor": "#6366f1",
"timeblockAttachmentSearchOrder": "name-asc",
"nowIndicator": true,
"selectMirror": true,
"weekNumbers": false,
@ -146,7 +147,8 @@
"icsEventId": "icsEventId",
"icsEventTag": "ics_event",
"googleCalendarEventId": "googleCalendarEventId",
"reminders": "reminders"
"reminders": "reminders",
"sortOrder": "tasknotes_manual_order"
},
"customStatuses": [
{
@ -222,7 +224,7 @@
}
],
"recurrenceMigrated": false,
"lastSeenVersion": "4.3.2",
"lastSeenVersion": "4.4.0",
"showReleaseNotesOnUpdate": true,
"showTrackedTasksInStatusBar": false,
"autoStopTimeTrackingOnComplete": true,
@ -466,5 +468,7 @@
"includeObsidianLink": true,
"defaultReminderMinutes": null
},
"enableMCP": false
"enableMCP": false,
"resetCheckboxesOnRecurrence": false,
"enableDebugLogging": false
}

View file

@ -0,0 +1,54 @@
import { describe, expect, it } from "@jest/globals";
import { ReadingModeInjectionScheduler } from "../../../src/editor/ReadingModeInjectionScheduler";
describe("ReadingModeInjectionScheduler", () => {
it("serializes concurrent requests for the same leaf and reruns once with the latest state", async () => {
const scheduler = new ReadingModeInjectionScheduler();
const leaf = {} as any;
const events: string[] = [];
let releaseFirstRun: (() => void) | null = null;
let runCount = 0;
const firstRunStarted = new Promise<void>((resolve) => {
const run = async ({ isCurrent }: { isCurrent: () => boolean }) => {
runCount += 1;
events.push(`start-${runCount}-${isCurrent() ? "current" : "stale"}`);
if (runCount === 1) {
resolve();
await new Promise<void>((resume) => {
releaseFirstRun = resume;
});
events.push(`finish-${runCount}-${isCurrent() ? "current" : "stale"}`);
return;
}
events.push(`finish-${runCount}-${isCurrent() ? "current" : "stale"}`);
};
scheduler.schedule(leaf, run);
});
await firstRunStarted;
scheduler.schedule(leaf, async ({ isCurrent }) => {
runCount += 1;
events.push(`start-${runCount}-${isCurrent() ? "current" : "stale"}`);
events.push(`finish-${runCount}-${isCurrent() ? "current" : "stale"}`);
});
expect(runCount).toBe(1);
releaseFirstRun?.();
await new Promise(resolve => setTimeout(resolve, 0));
expect(runCount).toBe(2);
expect(events).toEqual([
"start-1-current",
"finish-1-stale",
"start-2-current",
"finish-2-current",
]);
});
});