Guard calendar and editor popout behavior

This commit is contained in:
callumalpass 2026-05-18 22:03:40 +10:00
parent 0e4758a448
commit 1c5f032ad0
10 changed files with 183 additions and 337 deletions

View file

@ -17,11 +17,7 @@ import {
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import listPlugin from "@fullcalendar/list";
import interactionPlugin, {
type DropArg,
type EventReceiveArg,
type EventResizeDoneArg,
} from "@fullcalendar/interaction";
import interactionPlugin, { type EventResizeDoneArg } from "@fullcalendar/interaction";
import multiMonthPlugin from "@fullcalendar/multimonth";
import {
generateCalendarEvents,
@ -48,7 +44,7 @@ import { handleCalendarTaskClick } from "../utils/clickHandlers";
import { TaskCreationModal } from "../modals/TaskCreationModal";
import { CalendarEventCreationModal } from "../modals/CalendarEventCreationModal";
import { ICSEventInfoModal } from "../modals/ICSEventInfoModal";
import { Menu, Notice, Platform, TFile, setIcon, setTooltip } from "obsidian";
import { Menu, Platform, TFile, setIcon, setTooltip } from "obsidian";
import type { EventRef } from "obsidian";
import { format } from "date-fns";
import { createTaskCard } from "../ui/TaskCard";
@ -67,10 +63,6 @@ import {
CalendarRecreateNavigationState,
shouldPreserveVisibleDateOnCalendarRecreate,
} from "./calendarRecreateUtils";
import {
getDisplayedTaskLinkedGoogleEventIds,
isDisplayedTaskLinkedGoogleEvent,
} from "./calendarEventDeduplication";
import { CALENDAR_END_TIME_MAX_HOUR, normalizeCalendarTimeValue } from "../utils/calendarTime";
import type { CalendarEventData } from "../services/CalendarProvider";
import { filterEmptyProjects, sanitizeForCssClass } from "../utils/helpers";
@ -535,40 +527,12 @@ export function isCalendarElementReadyForSizing(
return calendarEl.clientWidth > 0 && calendarEl.clientHeight > 0;
}
type ExternalDropTaskPathSource = {
dataTransfer?: DataTransfer | null;
draggedEl?: HTMLElement | null;
jsEvent?: MouseEvent | DragEvent | null;
};
function normalizeDroppedTaskPath(value: string | undefined): string | undefined {
const normalized = value?.trim();
return normalized ? normalized : undefined;
}
export function extractTaskPathFromExternalDrop(
info: ExternalDropTaskPathSource
): string | undefined {
const eventDataTransfer =
info.jsEvent && "dataTransfer" in info.jsEvent
? info.jsEvent.dataTransfer
: null;
const dataTransfer = info.dataTransfer || eventDataTransfer;
const transferredTaskPath =
normalizeDroppedTaskPath(dataTransfer?.getData("application/x-task-path")) ||
normalizeDroppedTaskPath(dataTransfer?.getData("text/plain"));
if (transferredTaskPath) {
return transferredTaskPath;
}
const draggedEl = info.draggedEl;
return (
normalizeDroppedTaskPath(draggedEl?.dataset.taskPath) ||
normalizeDroppedTaskPath(
draggedEl?.closest<HTMLElement>("[data-task-path]")?.dataset.taskPath
)
);
export function isCalendarInPopoutWindow(
containerEl: HTMLElement,
mainWindow: Window = window
): boolean {
const ownerWindow = containerEl.ownerDocument.defaultView;
return Boolean(ownerWindow && ownerWindow !== mainWindow);
}
function getCalendarScrollElements(calendarEl: HTMLElement | null): HTMLElement[] {
@ -801,6 +765,11 @@ export class CalendarView extends BasesViewBase {
* Override to update FullCalendar size when container resizes.
*/
onResize(): void {
if (this.isPopoutWindow()) {
this.renderPopoutUnsupported();
return;
}
if (!this.calendar || !this.canUpdateCalendarSize()) return;
this.calendar.updateSize();
@ -1401,6 +1370,11 @@ export class CalendarView extends BasesViewBase {
this._isRendering = false;
return;
}
if (this.isPopoutWindow()) {
this.renderPopoutUnsupported();
this._isRendering = false;
return;
}
if (!this.data?.data) {
this._isRendering = false;
return;
@ -1552,7 +1526,7 @@ export class CalendarView extends BasesViewBase {
},
},
...getCalendarSizingOptions(this.getEffectiveHeightMode()),
handleWindowResize: true,
handleWindowResize: false,
stickyHeaderDates: false,
locale:
this.viewOptions.locale ||
@ -1580,7 +1554,6 @@ export class CalendarView extends BasesViewBase {
);
},
editable: true,
droppable: true,
selectable: true,
...(Platform.isMobile
? {
@ -1624,12 +1597,6 @@ export class CalendarView extends BasesViewBase {
eventDrop: (info) => {
void this.handleEventDrop(info);
},
drop: (info) => {
void this.handleExternalDrop(info);
},
eventReceive: (info) => {
this.handleEventReceive(info);
},
eventResize: (info) => {
void this.handleEventResize(info);
},
@ -1908,6 +1875,37 @@ export class CalendarView extends BasesViewBase {
return isCalendarElementReadyForSizing(this.calendarEl, this.containerEl);
}
private isPopoutWindow(): boolean {
return isCalendarInPopoutWindow(this.containerEl);
}
private renderPopoutUnsupported(): void {
if (!this.calendarEl) return;
if (this.calendar) {
this.calendar.destroy();
this.calendar = null;
}
this.calendarEl.replaceChildren();
this.calendarEl.classList.add("advanced-calendar-view__calendar--popout-blocked");
const doc = this.calendarEl.ownerDocument;
const noticeEl = doc.createElement("div");
noticeEl.className = "advanced-calendar-view__popout-blocked";
const titleEl = doc.createElement("h3");
titleEl.textContent = "Calendar view is unavailable in a separate window";
noticeEl.appendChild(titleEl);
const messageEl = doc.createElement("p");
messageEl.textContent =
"Open this calendar view in the main Obsidian window. The calendar can freeze Obsidian when restored inside a separate window.";
noticeEl.appendChild(messageEl);
this.calendarEl.appendChild(noticeEl);
}
private resetTodayColumnWidths(): void {
if (!this.calendarEl) return;
@ -2086,7 +2084,6 @@ export class CalendarView extends BasesViewBase {
eventConfig
);
applyBasesSortIndexesToCalendarEvents(taskEvents, this.basesSortIndexByPath);
const displayedTaskGoogleEventIds = getDisplayedTaskLinkedGoogleEventIds(taskEvents);
allEvents.push(...taskEvents);
// Add property-based events from non-TaskNotes items
@ -2113,10 +2110,7 @@ export class CalendarView extends BasesViewBase {
// Add Google Calendar events
if (this.plugin.googleCalendarService) {
const googleEvents = await this.buildGoogleCalendarEvents(
relatedNoteCountsByEventId,
displayedTaskGoogleEventIds
);
const googleEvents = await this.buildGoogleCalendarEvents(relatedNoteCountsByEventId);
allEvents.push(...googleEvents);
}
@ -2245,8 +2239,7 @@ export class CalendarView extends BasesViewBase {
}
private async buildGoogleCalendarEvents(
relatedNoteCountsByEventId: Map<string, number>,
displayedTaskGoogleEventIds: Set<string>
relatedNoteCountsByEventId: Map<string, number>
): Promise<EventInput[]> {
if (!this.plugin.googleCalendarService) return [];
@ -2257,7 +2250,6 @@ export class CalendarView extends BasesViewBase {
// Check if this calendar is enabled
const calendarId = icsEvent.subscriptionId.replace("google-", "");
if (this.googleCalendarToggles.get(calendarId) === false) continue;
if (isDisplayedTaskLinkedGoogleEvent(icsEvent, displayedTaskGoogleEventIds)) continue;
// Let FullCalendar handle date filtering
const calendarEvent = createICSEvent(icsEvent, this.plugin, {
@ -2386,46 +2378,6 @@ export class CalendarView extends BasesViewBase {
}
}
private async handleExternalDrop(info: DropArg): Promise<void> {
this.expectImmediateUpdate();
const taskPath = extractTaskPathFromExternalDrop(info);
if (!taskPath) {
console.warn("[TaskNotes][CalendarView] External drop did not include a task path");
return;
}
try {
const task = await this.plugin.cacheManager.getTaskInfo(taskPath);
if (!task) {
console.warn("[TaskNotes][CalendarView] Dropped task not found:", taskPath);
return;
}
const scheduledDate = info.allDay
? format(info.date, "yyyy-MM-dd")
: format(info.date, "yyyy-MM-dd'T'HH:mm");
await this.plugin.taskService.updateProperty(task, "scheduled", scheduledDate);
new Notice(
`Task "${task.title}" scheduled for ${format(
info.date,
info.allDay ? "MMM d, yyyy" : "MMM d, yyyy h:mm a"
)}`
);
this.calendar?.refetchEvents();
} catch (error) {
console.error("[TaskNotes][CalendarView] Error handling external task drop:", error);
new Notice("Failed to schedule task");
}
}
private handleEventReceive(info: EventReceiveArg): void {
info.event.remove();
}
private refetchWhenCreatedTimeblockIsIndexed(result: TimeblockCreationResult): void {
const hasCreatedTimeblock = (): boolean => {
const timeblocks =

View file

@ -1,47 +0,0 @@
import type { ICSEvent, TaskInfo } from "../types";
type EventWithTaskInfo = {
extendedProps?: {
taskInfo?: Pick<TaskInfo, "googleCalendarEventId">;
};
};
export function getDisplayedTaskLinkedGoogleEventIds(
taskEvents: EventWithTaskInfo[]
): Set<string> {
const eventIds = new Set<string>();
for (const event of taskEvents) {
const eventId = event.extendedProps?.taskInfo?.googleCalendarEventId;
if (typeof eventId === "string" && eventId.length > 0) {
eventIds.add(eventId);
}
}
return eventIds;
}
export function getGoogleProviderEventId(
icsEvent: Pick<ICSEvent, "id" | "subscriptionId">
): string | null {
if (!icsEvent.subscriptionId.startsWith("google-")) {
return null;
}
const providerPrefix = `${icsEvent.subscriptionId}-`;
return icsEvent.id.startsWith(providerPrefix)
? icsEvent.id.slice(providerPrefix.length)
: icsEvent.id;
}
export function isDisplayedTaskLinkedGoogleEvent(
icsEvent: Pick<ICSEvent, "id" | "subscriptionId">,
displayedTaskGoogleEventIds: Set<string>
): boolean {
if (displayedTaskGoogleEventIds.has(icsEvent.id)) {
return true;
}
const providerEventId = getGoogleProviderEventId(icsEvent);
return providerEventId !== null && displayedTaskGoogleEventIds.has(providerEventId);
}

View file

@ -215,6 +215,10 @@ const defaultProperties: ResolvedMarkdownEditorProps = {
file: undefined,
};
export function getMarkdownEditorTooltipParent(container: HTMLElement): HTMLElement {
return container.ownerDocument?.body ?? activeDocument.body;
}
class CodeMirrorEditorAdapter {
constructor(private readonly view: EditorView) {}
@ -567,7 +571,7 @@ export class EmbeddableMarkdownEditor extends getEditorBase() {
extensions.push(
tooltips({
parent: activeDocument.body,
parent: getMarkdownEditorTooltipParent(this.containerEl),
})
);

View file

@ -7,7 +7,7 @@ import {
closeCompletion,
} from "@codemirror/autocomplete";
import { Extension, Prec } from "@codemirror/state";
import { keymap } from "@codemirror/view";
import { EditorView, keymap } from "@codemirror/view";
import TaskNotesPlugin from "../main";
import { NaturalLanguageParser } from "../services/NaturalLanguageParser";
import { TriggerConfigService } from "../services/TriggerConfigService";
@ -65,8 +65,8 @@ export function createNLPAutocomplete(plugin: TaskNotesPlugin): Extension[] {
// Custom rendering for project suggestions with metadata
addToOptions: [
{
render: (completion: Completion, _state: unknown, _view: unknown) =>
renderProjectCompletionMetadata(completion),
render: (completion: Completion, _state: unknown, view: EditorView) =>
renderProjectCompletionMetadata(completion, view.dom.ownerDocument),
position: 100, // After label (50) and detail (80)
},
],
@ -167,23 +167,26 @@ export function createNLPCompletionSource(
};
}
export function renderProjectCompletionMetadata(completion: Completion): HTMLElement | null {
export function renderProjectCompletionMetadata(
completion: Completion,
doc: Document = activeDocument
): HTMLElement | null {
const projectCompletion = completion as unknown as ProjectCompletion;
if (!projectCompletion.projectMetadata) return null;
const container = activeDocument.createElement("div");
const container = doc.createElement("div");
container.className = "cm-project-suggestion__metadata";
for (const row of projectCompletion.projectMetadata) {
const metaRow = activeDocument.createElement("div");
const metaRow = doc.createElement("div");
metaRow.className = "cm-project-suggestion__meta";
row.forEach((part, index) => {
if (index > 0) {
metaRow.appendChild(activeDocument.createTextNode(" "));
metaRow.appendChild(doc.createTextNode(" "));
}
const span = activeDocument.createElement("span");
const span = doc.createElement("span");
span.className =
part.kind === "value"
? "cm-project-suggestion__meta-value"
@ -447,6 +450,7 @@ function buildProjectMetadataRows(
}
function appendHighlightedText(container: HTMLElement, text: string, query: string): void {
const doc = container.ownerDocument;
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
if (words.length === 0) {
container.textContent = text;
@ -480,17 +484,17 @@ function appendHighlightedText(container: HTMLElement, text: string, query: stri
let lastIndex = 0;
for (const match of nonOverlappingMatches) {
if (match.start > lastIndex) {
container.appendChild(activeDocument.createTextNode(text.slice(lastIndex, match.start)));
container.appendChild(doc.createTextNode(text.slice(lastIndex, match.start)));
}
const mark = activeDocument.createElement("mark");
const mark = doc.createElement("mark");
mark.textContent = text.slice(match.start, match.end);
container.appendChild(mark);
lastIndex = match.end;
}
if (lastIndex < text.length) {
container.appendChild(activeDocument.createTextNode(text.slice(lastIndex)));
container.appendChild(doc.createTextNode(text.slice(lastIndex)));
}
}

View file

@ -72,8 +72,6 @@ export class TaskLinkWidget extends WidgetType {
// Store original text for reference
card.dataset.originalText = this.originalText;
this.plugin.dragDropManager?.makeTaskCardDraggable(wrapper, this.taskInfo.path);
// Trigger update after status changes (for editor sync)
// Listen for task updates within the card
card.addEventListener("tasknotes:task-updated", () => {
@ -120,12 +118,7 @@ export class TaskLinkWidget extends WidgetType {
ignoreEvent(event: Event): boolean {
// Ignore mouse events to prevent cursor from moving into widget
// This keeps the widget rendered while interacting with it
if (
event.type === "mousedown" ||
event.type === "click" ||
event.type === "dragstart" ||
event.type === "dragend"
) {
if (event.type === "mousedown" || event.type === "click") {
return true;
}
return false;

View file

@ -19,6 +19,32 @@
min-height: 0;
}
.advanced-calendar-view__calendar--popout-blocked {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
padding: 24px;
}
.advanced-calendar-view__popout-blocked {
max-width: 420px;
color: var(--text-muted);
text-align: center;
}
.advanced-calendar-view__popout-blocked h3 {
margin: 0 0 8px;
color: var(--text-normal);
font-size: 1rem;
font-weight: 600;
}
.advanced-calendar-view__popout-blocked p {
margin: 0;
line-height: 1.5;
}
/* ICS Event Styles */
.fc-event[data-ics-event="true"] {
opacity: 0.9;

View file

@ -1,91 +0,0 @@
/**
* Issue #1451 / #1538: Google Calendar events created by TaskNotes should not
* render beside the same native TaskNotes task event.
*
* @see https://github.com/callumalpass/tasknotes/issues/1451
* @see https://github.com/callumalpass/tasknotes/issues/1538
*/
import {
getDisplayedTaskLinkedGoogleEventIds,
getGoogleProviderEventId,
isDisplayedTaskLinkedGoogleEvent,
} from "../../../src/bases/calendarEventDeduplication";
import type { ICSEvent } from "../../../src/types";
function googleEvent(overrides: Partial<ICSEvent> = {}): ICSEvent {
return {
id: "google-primary-event-123",
subscriptionId: "google-primary",
title: "Synced task",
start: "2026-01-08T09:00:00",
end: "2026-01-08T09:30:00",
allDay: false,
...overrides,
};
}
describe("Issue #1451: synced Google Calendar event duplicates", () => {
it("extracts the provider event id without being confused by hyphenated calendar ids", () => {
const event = googleEvent({
id: "google-family-shared-calendar-event-id-with-hyphens",
subscriptionId: "google-family-shared-calendar",
});
expect(getGoogleProviderEventId(event)).toBe("event-id-with-hyphens");
});
it("collects Google event ids only from displayed native task events", () => {
const displayedIds = getDisplayedTaskLinkedGoogleEventIds([
{
extendedProps: {
taskInfo: {
googleCalendarEventId: "event-123",
},
},
},
{
extendedProps: {
taskInfo: {},
},
},
]);
expect(displayedIds).toEqual(new Set(["event-123"]));
});
it("filters a Google event that matches a displayed TaskNotes task export", () => {
const displayedIds = new Set(["event-123"]);
expect(isDisplayedTaskLinkedGoogleEvent(googleEvent(), displayedIds)).toBe(true);
});
it("does not filter unrelated events from the same Google calendar", () => {
const displayedIds = new Set(["event-123"]);
expect(
isDisplayedTaskLinkedGoogleEvent(
googleEvent({ id: "google-primary-unrelated-event" }),
displayedIds
)
).toBe(false);
});
it("does not filter the Google event when the matching TaskNotes task is not displayed", () => {
expect(isDisplayedTaskLinkedGoogleEvent(googleEvent(), new Set())).toBe(false);
});
it("ignores non-Google external calendar events", () => {
const displayedIds = new Set(["event-123"]);
expect(
isDisplayedTaskLinkedGoogleEvent(
googleEvent({
id: "microsoft-work-event-123",
subscriptionId: "microsoft-work",
}),
displayedIds
)
).toBe(false);
});
});

View file

@ -1,78 +0,0 @@
import { EditorView } from "@codemirror/view";
import { extractTaskPathFromExternalDrop } from "../../../src/bases/CalendarView";
import { TaskLinkWidget } from "../../../src/editor/TaskLinkWidget";
import TaskNotesPlugin from "../../../src/main";
import { TaskInfo } from "../../../src/types";
import { createTaskCard } from "../../../src/ui/TaskCard";
jest.mock("../../../src/ui/TaskCard", () => ({
createTaskCard: jest.fn(() => {
const card = document.createElement("span");
card.className = "task-card";
return card;
}),
}));
function createDataTransfer(values: Record<string, string>): DataTransfer {
return {
getData: jest.fn((type: string) => values[type] ?? ""),
} as unknown as DataTransfer;
}
describe("Issue #1811: calendar external task drops", () => {
beforeEach(() => {
jest.clearAllMocks();
Object.defineProperty(globalThis, "activeDocument", {
value: document,
configurable: true,
});
});
it("extracts task paths from FullCalendar external drop data", () => {
const dataTransfer = createDataTransfer({
"application/x-task-path": "Tasks/Drag me.md",
"text/plain": "fallback.md",
});
const jsEvent = { dataTransfer } as unknown as DragEvent;
expect(extractTaskPathFromExternalDrop({ jsEvent })).toBe("Tasks/Drag me.md");
});
it("extracts task paths from dragged task card elements", () => {
const wrapper = document.createElement("span");
wrapper.dataset.taskPath = "Tasks/Inline task.md";
const child = document.createElement("span");
wrapper.appendChild(child);
expect(extractTaskPathFromExternalDrop({ draggedEl: child })).toBe(
"Tasks/Inline task.md"
);
});
it("registers inline task widgets with the calendar drag manager", () => {
const task = {
path: "Tasks/Inline task.md",
title: "Inline task",
status: "todo",
} as TaskInfo;
const dragDropManager = {
makeTaskCardDraggable: jest.fn(),
};
const plugin = {
settings: {
inlineVisibleProperties: [],
},
dragDropManager,
} as unknown as TaskNotesPlugin;
const widget = new TaskLinkWidget(task, plugin, "[[Inline task]]");
const element = widget.toDOM({ dispatch: jest.fn() } as unknown as EditorView);
expect(createTaskCard).toHaveBeenCalledWith(task, plugin, [], expect.any(Object));
expect(dragDropManager.makeTaskCardDraggable).toHaveBeenCalledWith(
element,
"Tasks/Inline task.md"
);
expect(widget.ignoreEvent(new Event("dragstart"))).toBe(true);
});
});

View file

@ -1,4 +1,6 @@
import { NaturalLanguageParser } from "../../../src/services/NaturalLanguageParser";
import { getMarkdownEditorTooltipParent } from "../../../src/editor/EmbeddableMarkdownEditor";
import { renderProjectCompletionMetadata } from "../../../src/editor/NLPCodeMirrorAutocomplete";
import { MockObsidian } from "../../__mocks__/obsidian";
import {
createCompletionPlugin,
@ -127,4 +129,25 @@ describe("Issue #1870: user field file autosuggest NLP values", () => {
],
});
});
it("mounts NLP autocomplete UI in the editor document for pop-out windows", () => {
const popoutDocument = document.implementation.createHTMLDocument("TaskNotes popout");
const container = popoutDocument.createElement("div");
popoutDocument.body.appendChild(container);
expect(getMarkdownEditorTooltipParent(container)).toBe(popoutDocument.body);
expect(getMarkdownEditorTooltipParent(container)).not.toBe(document.body);
const metadata = renderProjectCompletionMetadata(
{
label: "Reference Note",
projectMetadata: [[{ text: "Reference Note", searchable: true, kind: "value" }]],
projectQuery: "Reference",
} as never,
popoutDocument
);
expect(metadata?.ownerDocument).toBe(popoutDocument);
expect(metadata?.querySelector("mark")?.ownerDocument).toBe(popoutDocument);
});
});

View file

@ -7,7 +7,11 @@
* container and has measurable dimensions.
*/
import { isCalendarElementReadyForSizing } from "../../../src/bases/CalendarView";
import {
CalendarView,
isCalendarElementReadyForSizing,
isCalendarInPopoutWindow,
} from "../../../src/bases/CalendarView";
function setElementSize(element: HTMLElement, width: number, height: number): void {
Object.defineProperty(element, "clientWidth", {
@ -85,4 +89,60 @@ describe("Issue #1873 - calendar pop-out resize guard", () => {
expect(isCalendarElementReadyForSizing(null, container)).toBe(false);
});
test("detects calendar containers mounted in a separate window document", () => {
const container = document.createElement("div");
document.body.appendChild(container);
expect(isCalendarInPopoutWindow(container)).toBe(false);
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
const popoutContainer = iframe.contentDocument?.createElement("div");
if (!popoutContainer) {
throw new Error("Expected iframe document");
}
iframe.contentDocument?.body.appendChild(popoutContainer);
expect(isCalendarInPopoutWindow(popoutContainer)).toBe(true);
});
test("tears down an active calendar when Obsidian moves it to a separate window", () => {
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
const popoutDocument = iframe.contentDocument;
if (!popoutDocument) {
throw new Error("Expected iframe document");
}
const container = popoutDocument.createElement("div");
const rootElement = popoutDocument.createElement("div");
const calendarEl = popoutDocument.createElement("div");
rootElement.appendChild(calendarEl);
container.appendChild(rootElement);
popoutDocument.body.appendChild(container);
const calendar = {
destroy: jest.fn(),
updateSize: jest.fn(),
};
const view = Object.create(CalendarView.prototype) as CalendarView & {
containerEl: HTMLElement;
rootElement: HTMLElement;
calendarEl: HTMLElement;
calendar: typeof calendar | null;
};
view.containerEl = container;
view.rootElement = rootElement;
view.calendarEl = calendarEl;
view.calendar = calendar;
view.onResize();
expect(calendar.destroy).toHaveBeenCalledTimes(1);
expect(calendar.updateSize).not.toHaveBeenCalled();
expect(view.calendar).toBeNull();
expect(calendarEl.textContent).toContain(
"Calendar view is unavailable in a separate window"
);
});
});