From 6c3c7ba4bb6ee3093055c642ff5f2dd657f669f7 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sun, 29 Mar 2026 21:02:49 +1100 Subject: [PATCH] Update Bases calendar width controls and editor scheduling --- docs/releases/unreleased.md | 5 + e2e/obsidian.ts | 11 +- e2e/release-gifs.spec.ts | 800 ++++++++++++++++++ package.json | 1 + src/bases/CalendarView.ts | 116 +++ src/bases/registration.ts | 9 + src/editor/ReadingModeInjectionScheduler.ts | 71 ++ src/editor/RelationshipsDecorations.ts | 24 +- src/editor/TaskCardNoteDecorations.ts | 24 +- src/i18n/resources/en.ts | 1 + .../.obsidian/plugins/tasknotes/data.json | 10 +- .../ReadingModeInjectionScheduler.test.ts | 54 ++ 12 files changed, 1111 insertions(+), 15 deletions(-) create mode 100644 e2e/release-gifs.spec.ts create mode 100644 src/editor/ReadingModeInjectionScheduler.ts create mode 100644 tests/unit/editor/ReadingModeInjectionScheduler.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 2fdec208..d360f25f 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -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 diff --git a/e2e/obsidian.ts b/e2e/obsidian.ts index bec9aee3..bce390c5 100644 --- a/e2e/obsidian.ts +++ b/e2e/obsidian.ts @@ -139,12 +139,19 @@ export async function launchObsidian(): Promise { // 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: { diff --git a/e2e/release-gifs.spec.ts b/e2e/release-gifs.spec.ts new file mode 100644 index 00000000..92f82afe --- /dev/null +++ b/e2e/release-gifs.spec.ts @@ -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; + +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 { + 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(); + 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 { + 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 { + 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 { + await runCommand(page, "Reload app without saving"); + await page.waitForTimeout(3500); +} + +async function setPluginSettings(page: Page, patch: Record): Promise { + 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 { + 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 { + 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 { + 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); + } +}); diff --git a/package.json b/package.json index e8e24a3e..6f73f5a9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/bases/CalendarView.ts b/src/bases/CalendarView.ts index c66d144c..afc42bbe 100644 --- a/src/bases/CalendarView.ts +++ b/src/bases/CalendarView.ts @@ -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 | 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(".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( + ".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("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( + ".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. diff --git a/src/bases/registration.ts b/src/bases/registration.ts index bb4ec51f..5f1b7c52 100644 --- a/src/bases/registration.ts +++ b/src/bases/registration.ts @@ -313,6 +313,15 @@ export async function registerBasesTaskList(plugin: TaskNotesPlugin): Promise(); + + schedule( + leaf: WorkspaceLeaf, + run: (context: ReadingModeInjectionContext) => Promise + ): 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 + ): Promise { + 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); + } + } + } +} diff --git a/src/editor/RelationshipsDecorations.ts b/src/editor/RelationshipsDecorations.ts index 015c2165..e6599187 100644 --- a/src/editor/RelationshipsDecorations.ts +++ b/src/editor/RelationshipsDecorations.ts @@ -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 { 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 diff --git a/src/editor/TaskCardNoteDecorations.ts b/src/editor/TaskCardNoteDecorations.ts index 0485013d..30c168ee 100644 --- a/src/editor/TaskCardNoteDecorations.ts +++ b/src/editor/TaskCardNoteDecorations.ts @@ -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 { 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 diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index b7c9279b..e31b657b 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -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)", diff --git a/tasknotes-e2e-vault/.obsidian/plugins/tasknotes/data.json b/tasknotes-e2e-vault/.obsidian/plugins/tasknotes/data.json index c63f0186..7461062b 100644 --- a/tasknotes-e2e-vault/.obsidian/plugins/tasknotes/data.json +++ b/tasknotes-e2e-vault/.obsidian/plugins/tasknotes/data.json @@ -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 } \ No newline at end of file diff --git a/tests/unit/editor/ReadingModeInjectionScheduler.test.ts b/tests/unit/editor/ReadingModeInjectionScheduler.test.ts new file mode 100644 index 00000000..e8687b0c --- /dev/null +++ b/tests/unit/editor/ReadingModeInjectionScheduler.test.ts @@ -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((resolve) => { + const run = async ({ isCurrent }: { isCurrent: () => boolean }) => { + runCount += 1; + events.push(`start-${runCount}-${isCurrent() ? "current" : "stale"}`); + + if (runCount === 1) { + resolve(); + await new Promise((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", + ]); + }); +});