Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l
```

-->

## Fixed

- (#2081) Kept task card and relationships widgets visible in reading mode when Obsidian scroll or preview updates remove injected widget DOM, and refreshed visible widgets immediately after task metadata changes. Thanks to @mukhozhuk for reporting and debugging.
105 changes: 105 additions & 0 deletions src/editor/ReadingModeWidgetObserver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { MarkdownView, WorkspaceLeaf } from "obsidian";
import { shouldSkipMarkdownWidgetLeaf } from "./MarkdownWidgetContext";

type FrameHandle = {
id: number;
cancel: () => void;
};

function scheduleBeforePaint(win: Window, callback: () => void): FrameHandle {
if (typeof win.requestAnimationFrame === "function") {
const id = win.requestAnimationFrame(callback);
return {
id,
cancel: () => win.cancelAnimationFrame(id),
};
}

const id = win.setTimeout(callback, 0);
return {
id,
cancel: () => win.clearTimeout(id),
};
}

function nodeContainsSelector(node: Node, selector: string): boolean {
if (node.nodeType !== Node.ELEMENT_NODE) {
return false;
}

const element = node as Element;
return element.matches(selector) || Boolean(element.querySelector(selector));
}

function mutationTouchesPreviewWidget(mutation: MutationRecord, widgetSelector: string): boolean {
const changedNodes = [...Array.from(mutation.addedNodes), ...Array.from(mutation.removedNodes)];
return changedNodes.some(
(node) =>
nodeContainsSelector(node, widgetSelector) ||
nodeContainsSelector(node, ".markdown-preview-sizer")
);
}

function getReadingModeContainer(leaf: WorkspaceLeaf): HTMLElement | null {
const view = leaf.view;
if (!(view instanceof MarkdownView) || view.getMode() !== "preview") {
return null;
}

if (shouldSkipMarkdownWidgetLeaf(leaf)) {
return null;
}

return view.previewMode.containerEl;
}

export function observeReadingModeWidgetMutations(
leaf: WorkspaceLeaf,
widgetSelector: string,
scheduleInjection: (leaf: WorkspaceLeaf) => void,
observedContainers: WeakSet<HTMLElement>,
cleanupCallbacks: Array<() => void>,
shouldRefresh: (leaf: WorkspaceLeaf) => boolean
): void {
const containerEl = getReadingModeContainer(leaf);
if (!containerEl || observedContainers.has(containerEl)) {
return;
}

let pendingFrame: FrameHandle | null = null;
const requestRefresh = () => {
if (pendingFrame) {
return;
}

const win = containerEl.ownerDocument.defaultView ?? window;
pendingFrame = scheduleBeforePaint(win, () => {
pendingFrame = null;
if (!containerEl.isConnected || !shouldRefresh(leaf)) {
return;
}

const sizer = containerEl.querySelector<HTMLElement>(".markdown-preview-sizer");
if (sizer && !sizer.querySelector(widgetSelector)) {
scheduleInjection(leaf);
}
});
};

const observer = new MutationObserver((mutations) => {
if (mutations.some((mutation) => mutationTouchesPreviewWidget(mutation, widgetSelector))) {
requestRefresh();
}
});

observer.observe(containerEl, {
childList: true,
subtree: true,
});

observedContainers.add(containerEl);
cleanupCallbacks.push(() => {
pendingFrame?.cancel();
observer.disconnect();
});
}
103 changes: 91 additions & 12 deletions src/editor/RelationshipsDecorations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
ReadingModeInjectionContext,
ReadingModeInjectionScheduler,
} from "./ReadingModeInjectionScheduler";
import { observeReadingModeWidgetMutations } from "./ReadingModeWidgetObserver";
import {
shouldSkipMarkdownWidgetEditor,
shouldSkipMarkdownWidgetLeaf,
Expand Down Expand Up @@ -211,10 +212,7 @@

const spacerGap = Math.max(
0,
Math.round(
contentContainer.getBoundingClientRect().bottom -
contentBottom
)
Math.round(contentContainer.getBoundingClientRect().bottom - contentBottom)
);
if (spacerGap > 0) {
const defaultMarginTop = getRelationshipsWidgetDefaultMarginTop(widget);
Expand Down Expand Up @@ -249,6 +247,7 @@
container.setAttribute("contenteditable", "false");
container.setAttribute("spellcheck", "false");
container.setAttribute("data-widget-type", "relationships");
container.setAttribute("data-note-path", notePath);

// Create container for embedded Bases view
const basesContainer = activeDocument.createElement("div");
Expand Down Expand Up @@ -709,15 +708,25 @@
}

if (!isTaskNote && !isProjectNote) {
// Remove any existing widgets if conditions no longer met
// Preserve same-file widgets while Obsidian is rebuilding metadata.
try {
const previewView = view.previewMode;
const containerEl = previewView.containerEl;
containerEl.querySelectorAll(`.${CSS_RELATIONSHIPS_WIDGET}`).forEach((el) => {
const holder = el as HTMLElementWithComponent;
holder.component?.unload();
el.remove();
});
const existingWidgets = Array.from(
containerEl.querySelectorAll<HTMLElement>(`.${CSS_RELATIONSHIPS_WIDGET}`)
);
const hasWidgetForDifferentFile = existingWidgets.some(
(widget) => widget.dataset.notePath !== file.path
);
const isConfirmedNonRelationshipsNote = Boolean(metadata?.frontmatter);

if (hasWidgetForDifferentFile || isConfirmedNonRelationshipsNote) {
existingWidgets.forEach((el) => {
const holder = el as HTMLElementWithComponent;
holder.component?.unload();
el.remove();
});
}
} catch (error) {
tasknotesLogger.debug(
"[TaskNotes] Error cleaning up relationships widget in reading mode:",
Expand Down Expand Up @@ -797,10 +806,49 @@
const workspaceRefs: EventRef[] = [];
const metadataCacheRefs: EventRef[] = [];
const dependencyCacheRefs: EventRef[] = [];
const markdownWidgetObserverCleanups: Array<() => void> = [];
const observedMarkdownContainers = new WeakSet<HTMLElement>();
const scheduler = new ReadingModeInjectionScheduler();
const scheduleInjection = (leaf: WorkspaceLeaf) => {
scheduler.schedule(leaf, (context) => injectReadingModeWidget(leaf, plugin, context));
};
const shouldRefreshMarkdownLeaf = (leaf: WorkspaceLeaf) => {
const view = leaf.view;
return (
plugin.settings.showRelationships &&
view instanceof MarkdownView &&
view.getMode() === "preview" &&
Boolean(view.file)
);
};
const observeMarkdownLeaf = (leaf: WorkspaceLeaf) => {
observeReadingModeWidgetMutations(
leaf,
`.${CSS_RELATIONSHIPS_WIDGET}`,
scheduleInjection,
observedMarkdownContainers,
markdownWidgetObserverCleanups,
shouldRefreshMarkdownLeaf
);
};
const observeMarkdownLeaves = () => {
plugin.app.workspace.getLeavesOfType("markdown").forEach((leaf) => {
observeMarkdownLeaf(leaf);
});
};
const leafMatchesFile = (leaf: WorkspaceLeaf, file: TFile | null) => {

Check warning on line 839 in src/editor/RelationshipsDecorations.ts

View workflow job for this annotation

GitHub Actions / test (20)

'TFile' is an 'error' type that acts as 'any' and overrides all other types in this union type
const view = leaf.view;
return view instanceof MarkdownView && (!file || view.file?.path === file.path);
};
const refreshLeavesForFile = (file: TFile | null) => {

Check warning on line 843 in src/editor/RelationshipsDecorations.ts

View workflow job for this annotation

GitHub Actions / test (20)

'TFile' is an 'error' type that acts as 'any' and overrides all other types in this union type
const leaves = plugin.app.workspace.getLeavesOfType("markdown");
leaves.forEach((leaf) => {
if (leafMatchesFile(leaf, file)) {
scheduleInjection(leaf);
observeMarkdownLeaf(leaf);
}
});
};

// Debounce to prevent excessive re-renders
let debounceTimer: number | null = null;
Expand All @@ -811,6 +859,7 @@
leaves.forEach((leaf) => {
scheduleInjection(leaf);
});
observeMarkdownLeaves();
}, 100);
};

Expand All @@ -822,25 +871,51 @@
const activeLeafChangeRef = plugin.app.workspace.on("active-leaf-change", (leaf) => {
if (leaf) {
scheduleInjection(leaf);
observeMarkdownLeaf(leaf);
}
});
workspaceRefs.push(activeLeafChangeRef);

// Inject widget when a new file is opened in the same leaf
const fileOpenRef = plugin.app.workspace.on("file-open", (file) => {
refreshLeavesForFile(file instanceof TFile ? file : null);
});
workspaceRefs.push(fileOpenRef);

// Inject widget when file is modified (metadata changes) - debounced per file
const metadataDebounceTimers = new Map<string, number>();
const metadataChangeRef = plugin.app.metadataCache.on("changed", (file) => {
// Clear existing timer for this file
const existingTimer = metadataDebounceTimers.get(file.path);
if (existingTimer) window.clearTimeout(existingTimer);

const leaves = plugin.app.workspace.getLeavesOfType("markdown");
const visibleReadingLeaves = leaves.filter((leaf) => {
const view = leaf.view;
return (
view instanceof MarkdownView &&
view.file?.path === file.path &&
view.getMode() === "preview"
);
});

if (visibleReadingLeaves.length > 0) {
metadataDebounceTimers.delete(file.path);
visibleReadingLeaves.forEach((leaf) => {
scheduleInjection(leaf);
observeMarkdownLeaf(leaf);
});
return;
}

// Debounce per file to avoid freezing during typing
const timer = window.setTimeout(() => {
metadataDebounceTimers.delete(file.path);
const leaves = plugin.app.workspace.getLeavesOfType("markdown");
leaves.forEach((leaf) => {
const view = leaf.view;
if (view instanceof MarkdownView && view.file === file) {
if (view instanceof MarkdownView && view.file?.path === file.path) {
scheduleInjection(leaf);
observeMarkdownLeaf(leaf);
}
});
}, 500);
Expand All @@ -861,10 +936,14 @@
leaves.forEach((leaf) => {
scheduleInjection(leaf);
});
observeMarkdownLeaves();

// Return cleanup function
return () => {
if (debounceTimer) window.clearTimeout(debounceTimer);
metadataDebounceTimers.forEach((timer) => window.clearTimeout(timer));
metadataDebounceTimers.clear();
markdownWidgetObserverCleanups.forEach((cleanup) => cleanup());

// Clean up each type of event ref with the correct method
workspaceRefs.forEach((ref) => plugin.app.workspace.offref(ref));
Expand Down
Loading
Loading