fix(popout): document ownership and selection listener fixes (W4/9) (#2406)

* fix(popout): document ownership and selection listener fixes (W4/9)

Fifth of nine workspaces splitting #2397. Real popout-window
correctness fixes — semantic choices about which window owns a DOM
operation, plus selection-listener captured-document fixes.

- document → activeDocument where a generic active-window lookup is
  correct (SourcesModal, ChatViewLayout, ItemView/Notice mocks,
  TabContext, ChatInput keydown listener, CopilotView keyboard
  observer, TypeaheadMenuPortal, AddImageModal, and ?? document
  fallbacks across draggable-modal, menu-command-modal,
  CustomCommandChatModal, use-draggable, use-resizable, and
  QuickAskOverlay).
- document → root.ownerDocument in ChatSingleMessage's
  linkInlineCitations so DOM nodes end up in the citation's window;
  same pattern in the processMessage streaming block via
  contentRef.current.ownerDocument.
- New getEditorDocument(editor) helper in BasePillNode. createDOM and
  exportDOM now accept the LexicalEditor; pills create DOM in the
  editor's window (correct popout behavior). All 6 pill subclasses
  updated to match the new signature.
- main.ts: selectionListenerDocument captured at listener registration;
  remove uses the same document (fixes leak when activeDocument has
  drifted before unload).
- chatSelectionHighlightController.ts: getActiveViewOfType(MarkdownView)
  replaces the brittle activeLeaf type comparison.
- Test setup: (window as any).activeDocument = window.document in
  ChatSingleMessage.test; window.document.createElement in
  AgentPrompt.test Notice mock.

Behavior fix: chat in a popout window now creates DOM nodes in the
popout's document, not the focused window's document.

W0, W1, and #2402 already merged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(popout): prefer element.doc/.win over ownerDocument and activeDocument

Switch popout-window-sensitive call sites to Obsidian's `.doc` / `.win`
augmentations so DOM ops, listeners, portals, and positioning follow the
element's actual owner window. Recreate Lexical root on view migration to
rebind input handling after a leaf moves between windows. Document the
decision order in AGENTS.md and polyfill `Node.doc` / `Node.win` in
jest.setup.js so tests still run under jsdom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* improve image upload implementation

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Zero Liu 2026-05-12 21:28:04 -07:00 committed by GitHub
parent 7619fb5578
commit c67c4fc200
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 226 additions and 140 deletions

View file

@ -345,6 +345,35 @@ The TODO.md should be:
- **Global `app` variable**: In Obsidian plugins, `app` is a globally available variable that provides access to the Obsidian API. It's automatically available in all files without needing to import or declare it.
### Picking the right `document` / `window` (popout-window safety)
Obsidian supports pop-out windows. The plugin loads in the main window but views can live in any window. Picking the wrong `Document` / `Window` produces stale references, off-screen popovers, listeners on the wrong window, or DOM nodes that never render. Use this decision order:
1. **`element.doc` / `element.win`** — preferred. Obsidian augments every `Node` with `.doc: Document` and `.win: Window` that always reflect the element's current owner. Use whenever you have any DOM node in scope (a ref, an event target, a component's container, a `Range`'s `startContainer`).
- `containerRef.current?.doc.addEventListener(...)`
- `range.startContainer.win.innerWidth`
- `editor.getRootElement()?.doc`
2. **`global activeDocument` / `activeWindow`** — fallback only. These point to whichever window is _focused right now_. Correct semantics for actions that follow user focus (e.g., the AddImageModal file picker; selectionchange registration at plugin load), but wrong when the action belongs to a specific view (a chat in a popout while the user clicks back to the main window).
3. **`document` / `window` globals** — almost always wrong. They are aliases for the main window even when the user is interacting with a popout. Avoid in new code. If you find yourself reaching for them, it's a sign the surrounding code should be taking a `Document`/`Window` parameter or deriving from a DOM ref.
4. **`element.ownerDocument`** — works (standard DOM), but prefer `.doc` for consistency with the codebase. They return the same `Document` for any mounted `HTMLElement`. `.doc` is shorter and typed non-nullable.
**Listeners that may outlive a window migration:** capture the `Document` / `Window` at registration and remove on the same one:
```ts
const doc = containerRef.current?.doc;
if (!doc) return;
doc.addEventListener("keydown", handler);
return () => doc.removeEventListener("keydown", handler);
```
Do **not** rely on `activeDocument` at registration _and_ removal — it can shift between the two calls if focus moves.
**View migrated to a new window:** for a view that owns React or other long-lived renderers, register `this.containerEl.onWindowMigrated((win) => { ... })` in `onOpen`. The callback fires when Obsidian reparents the element into a different window's document. Tear down and rebuild the renderer there so it captures the new window. Save the returned destroy function and call it in `onClose` to avoid leaks. `CopilotView` is the canonical example — it unmounts and recreates the React root on migration so Lexical re-binds to the popout's window.
**Cross-realm `instanceof`:** popout windows have their own `Element`, `MouseEvent`, etc., so standard `instanceof` checks fail across windows. Use Obsidian's `element.instanceOf(HTMLElement)` and `event.instanceOf(MouseEvent)` when checking type across realms.
**Tests (jsdom):** `jest.setup.js` polyfills `Node.doc` / `Node.win` so plugin code using these properties works under jsdom. Don't add `instanceof` guards that depend on the Obsidian-augmented globals without considering the test environment.
### Architecture Migration Notes
- **SharedState Removed**: The legacy `src/sharedState.ts` has been completely removed

View file

@ -52,7 +52,7 @@ module.exports = {
},
})),
ItemView: jest.fn().mockImplementation(function () {
this.containerEl = document.createElement("div");
this.containerEl = window.document.createElement("div");
this.onOpen = jest.fn();
this.onClose = jest.fn();
this.getDisplayText = jest.fn().mockReturnValue("Mock View");
@ -61,7 +61,7 @@ module.exports = {
}),
Notice: jest.fn().mockImplementation(function (message) {
this.message = message;
this.noticeEl = document.createElement("div");
this.noticeEl = window.document.createElement("div");
this.hide = jest.fn();
}),
TFile: jest.fn().mockImplementation(function (path) {

View file

@ -1,5 +1,25 @@
/* eslint-env browser */
import "web-streams-polyfill/dist/polyfill.min.js";
import { TextEncoder, TextDecoder } from "util";
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;
// Polyfill Obsidian's Node.doc / Node.win augmentation so plugin code that
// reads `element.doc` / `element.win` works under jsdom.
if (typeof Node !== "undefined" && !Object.prototype.hasOwnProperty.call(Node.prototype, "doc")) {
Object.defineProperty(Node.prototype, "doc", {
get() {
return this.ownerDocument ?? global.document;
},
configurable: true,
});
}
if (typeof Node !== "undefined" && !Object.prototype.hasOwnProperty.call(Node.prototype, "win")) {
Object.defineProperty(Node.prototype, "win", {
get() {
return this.ownerDocument?.defaultView ?? global.window;
},
configurable: true,
});
}

View file

@ -504,7 +504,7 @@ export class CustomCommandChatModal {
* the modal is positioned relative to the correct window.
*/
private resolveWindow(view?: MarkdownView | null): Window {
return view?.containerEl?.ownerDocument?.defaultView ?? window;
return view?.containerEl?.win ?? window;
}
/**
@ -513,7 +513,7 @@ export class CustomCommandChatModal {
* appended to the document that owns the triggering view.
*/
private resolveDocument(view?: MarkdownView | null): Document {
return view?.containerEl?.ownerDocument ?? document;
return view?.containerEl?.doc ?? activeDocument;
}
/**

View file

@ -22,6 +22,7 @@ export default class CopilotView extends ItemView {
private drawerHideObserver: MutationObserver | null = null;
private layout: ChatViewLayout | null = null;
private lastDrawerEl: HTMLElement | null = null;
private windowMigrationDestroy: (() => void) | null = null;
eventTarget: EventTarget;
constructor(
@ -67,6 +68,19 @@ export default class CopilotView extends ItemView {
this.setupMobileKeyboardObserver();
this.setupDrawerHideObserver();
// Reason: When the leaf is dragged to (or back from) an Obsidian popout, the
// containerEl is reparented into a different window's document but onOpen
// does not re-fire. Lexical's editor._window stays bound to the original
// window, so input events fired in the popout never reach the editor.
// Tearing down and recreating the React root forces Lexical to re-register
// its root element under the new window — typing works again.
this.windowMigrationDestroy = this.containerEl.onWindowMigrated(() => {
if (!this.root) return;
this.root.unmount();
this.root = createRoot(this.containerEl.children[1]);
this.renderView(handleSaveAsNote, updateUserMessageHistory);
});
// Reason: The view can move between containers (e.g. editor tab → drawer)
// without onOpen firing again. Re-bind the drawer observer on layout changes
// so it always watches the correct drawer element.
@ -109,13 +123,13 @@ export default class CopilotView extends ItemView {
// querying by data-type which is more brittle across Obsidian versions.
const isCopilotActive = !!this.containerEl.closest(".workspace-drawer-active-tab-content");
const kbHeight = parseFloat(
document.documentElement.style.getPropertyValue("--keyboard-height") || "0"
this.containerEl.doc.documentElement.style.getPropertyValue("--keyboard-height") || "0"
);
drawer.classList.toggle("copilot-keyboard-open", isCopilotActive && kbHeight > 0);
};
this.keyboardObserver = new MutationObserver(syncKeyboardClass);
this.keyboardObserver.observe(document.documentElement, {
this.keyboardObserver.observe(this.containerEl.doc.documentElement, {
attributes: true,
attributeFilter: ["style"],
});
@ -209,6 +223,8 @@ export default class CopilotView extends ItemView {
this.keyboardObserver = null;
this.drawerHideObserver?.disconnect();
this.drawerHideObserver = null;
this.windowMigrationDestroy?.();
this.windowMigrationDestroy = null;
this.layout?.destroy();
this.layout = null;
// Reason: Clean up the class on the tracked drawer element when the view is closed.

View file

@ -7,7 +7,6 @@ import {
useProjectLoading,
} from "@/aiParams";
import { ChainType } from "@/chainFactory";
import { AddImageModal } from "@/components/modals/AddImageModal";
import { Button } from "@/components/ui/button";
import { ModelSelector } from "@/components/ui/ModelSelector";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
@ -654,8 +653,10 @@ const ChatInput: React.FC<ChatInputProps> = ({
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
const doc = containerRef.current?.doc;
if (!doc) return;
doc.addEventListener("keydown", handleKeyDown);
return () => doc.removeEventListener("keydown", handleKeyDown);
}, [editMode, onEditCancel]);
// Handle tool button toggle-off events - remove corresponding pills
@ -853,8 +854,17 @@ const ChatInput: React.FC<ChatInputProps> = ({
variant="ghost2"
size="fit"
className="tw-text-muted hover:tw-text-accent"
onClick={() => {
new AddImageModal(app, onAddImage).open();
onClick={(e) => {
const input = e.currentTarget.doc.createElement("input");
input.type = "file";
input.accept = "image/*";
input.multiple = true;
input.addEventListener(
"change",
() => onAddImage(Array.from(input.files || [])),
{ once: true }
);
input.click();
}}
>
<Image className="tw-size-4" />

View file

@ -97,7 +97,7 @@ describe("think block rendering — closing tags are not consumed by indented co
});
beforeAll(() => {
(globalThis as any).activeDocument = document;
(window as any).activeDocument = window.document;
});
/**
@ -204,7 +204,7 @@ describe("normalizeFootnoteRendering", () => {
});
it("removes separator and backref while preserving non-footnote elements", () => {
const container = document.createElement("div");
const container = window.document.createElement("div");
container.innerHTML = `
<div>
<p>Body <sup><a href="#fn-1">1-1</a></sup></p>
@ -229,7 +229,7 @@ describe("normalizeFootnoteRendering", () => {
});
it("leaves non-numeric footnote references untouched", () => {
const container = document.createElement("div");
const container = window.document.createElement("div");
container.innerHTML = `
<p>Body <sup><a href="#fn-note">Note-A</a></sup></p>
<a class="footnote-backref" href="#ref"></a>
@ -269,7 +269,7 @@ describe("ChatSingleMessage", () => {
});
beforeAll(() => {
(globalThis as any).activeDocument = document;
(window as any).activeDocument = window.document;
});
it("normalizes rendered footnotes for assistant messages", async () => {

View file

@ -116,10 +116,15 @@ export const linkInlineCitations = (root: HTMLElement): void => {
if (citationAnchors.size === 0) return;
// Bind DOM ops to the document that owns `root` — the chat message may live
// in an Obsidian popout while a different window is focused, in which case
// `activeDocument` would create nodes with the wrong owner.
const doc = root.doc;
// Collect text nodes that contain citation patterns (outside sources section)
const sourcesEl = root.querySelector(".copilot-sources");
const textNodes: Text[] = [];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
if (sourcesEl?.contains(node)) return NodeFilter.FILTER_REJECT;
if (node.parentElement?.closest("code, pre")) return NodeFilter.FILTER_REJECT;
@ -140,27 +145,27 @@ export const linkInlineCitations = (root: HTMLElement): void => {
const text = node.textContent || "";
INLINE_CITATION_RE.lastIndex = 0;
const fragment = document.createDocumentFragment();
const fragment = doc.createDocumentFragment();
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = INLINE_CITATION_RE.exec(text)) !== null) {
// Text before the citation
if (match.index > lastIndex) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
fragment.appendChild(doc.createTextNode(text.slice(lastIndex, match.index)));
}
const nums = match[1].split(/\s*,\s*/).map((s) => parseInt(s.trim(), 10));
const allResolved = nums.every((num) => citationAnchors.has(num));
if (allResolved) {
const span = document.createElement("span");
const span = doc.createElement("span");
span.className = "copilot-citation-group";
span.appendChild(document.createTextNode("["));
span.appendChild(doc.createTextNode("["));
nums.forEach((num, i) => {
if (i > 0) span.appendChild(document.createTextNode(", "));
if (i > 0) span.appendChild(doc.createTextNode(", "));
const sourceAnchor = citationAnchors.get(num)!;
const link = document.createElement("a");
const link = doc.createElement("a");
// Copy all attributes from the source anchor so Obsidian internal-link
// metadata (e.g. data-href, class="internal-link") is preserved.
for (const attr of Array.from(sourceAnchor.attributes)) {
@ -172,17 +177,17 @@ export const linkInlineCitations = (root: HTMLElement): void => {
link.setAttribute("aria-label", `Source ${num}`);
span.appendChild(link);
});
span.appendChild(document.createTextNode("]"));
span.appendChild(doc.createTextNode("]"));
fragment.appendChild(span);
} else {
fragment.appendChild(document.createTextNode(match[0]));
fragment.appendChild(doc.createTextNode(match[0]));
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
fragment.appendChild(doc.createTextNode(text.slice(lastIndex)));
}
// If the text node is inside a placeholder span, replace the span itself
@ -668,6 +673,9 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
const parsedMessage = parseToolCallMarkers(processedMessage, messageId.current);
if (!isUnmountingRef.current) {
// Bind DOM ops to the document that owns the message container so
// popout-window chats don't pick up the wrong document if focus shifts.
const doc = contentRef.current.doc;
// Track existing tool call and error block IDs
const existingToolCallIds = new Set<string>();
const existingErrorIds = new Set<string>();
@ -695,7 +703,7 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
// Find where to insert this text segment
const insertBefore = contentRef.current!.children[currentIndex];
const textDiv = document.createElement("div");
const textDiv = doc.createElement("div");
textDiv.className = "message-segment";
if (insertBefore) {
@ -715,11 +723,11 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
currentIndex++;
} else if (segment.type === "toolCall" && segment.toolCall) {
const toolCallId = segment.toolCall.id;
let container = document.getElementById(`tool-call-${toolCallId}`);
let container = doc.getElementById(`tool-call-${toolCallId}`);
if (!container) {
const insertBefore = contentRef.current!.children[currentIndex];
const toolDiv = document.createElement("div");
const toolDiv = doc.createElement("div");
toolDiv.className = "tool-call-container";
toolDiv.id = `tool-call-${toolCallId}`;
@ -747,12 +755,12 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
currentIndex++;
} else if (segment.type === "error" && segment.error) {
const errorId = segment.error.id;
let container = document.getElementById(`error-block-${errorId}`);
let container = doc.getElementById(`error-block-${errorId}`);
if (!container) {
// Insert error block at the current stream position
const insertBefore = contentRef.current!.children[currentIndex];
const errorDiv = document.createElement("div");
const errorDiv = doc.createElement("div");
errorDiv.className = "error-block-container";
errorDiv.id = `error-block-${errorId}`;
@ -791,7 +799,7 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
existingToolCallIds.forEach((id) => {
if (!currentToolCallIds.has(id)) {
const element = document.getElementById(`tool-call-${id}`);
const element = doc.getElementById(`tool-call-${id}`);
if (element) {
removeToolCallRoot(messageId.current, rootsRef.current, id, "tool call removal");
element.remove();
@ -808,7 +816,7 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
existingErrorIds.forEach((id) => {
if (!currentErrorIds.has(id)) {
const element = document.getElementById(`error-block-${id}`);
const element = doc.getElementById(`error-block-${id}`);
if (element) {
removeErrorBlockRoot(
messageId.current,

View file

@ -49,7 +49,7 @@ export class ChatViewLayout {
const syncClearance = () => {
// Re-query each time to avoid stale references after theme reloads.
const statusBar = document.querySelector<HTMLElement>(".status-bar");
const statusBar = this.containerEl.doc.querySelector<HTMLElement>(".status-bar");
const viewContent = this.containerEl.querySelector<HTMLElement>(".view-content");
if (!statusBar || !viewContent) return;

View file

@ -60,30 +60,36 @@ export function TypeaheadMenuPortal({
null
);
// Calculate dynamic width based on content
const calculateWidth = useCallback(() => {
const maxAllowedWidth = Math.floor(window.innerWidth * MAX_WIDTH_PERCENTAGE);
// Derive the popout-aware window/document from the range itself so positioning
// and the portal target follow the chat's window, not whichever happens to be focused.
const targetWin: Window = range?.startContainer.win ?? window;
if (options.length === 0) return Math.min(MENU_WIDTH, maxAllowedWidth);
const calculateWidth = useCallback(
(win: Window) => {
const maxAllowedWidth = Math.floor(win.innerWidth * MAX_WIDTH_PERCENTAGE);
const maxTitleLength = Math.max(...options.map((opt) => opt.title.length));
const maxSubtitleLength = Math.max(...options.map((opt) => opt.subtitle?.length || 0));
if (options.length === 0) return Math.min(MENU_WIDTH, maxAllowedWidth);
const estimatedWidth = Math.max(maxTitleLength * 8 + 32, maxSubtitleLength * 6 + 32);
const preferredWidth = Math.min(Math.max(estimatedWidth, 300), MENU_WIDTH);
const maxTitleLength = Math.max(...options.map((opt) => opt.title.length));
const maxSubtitleLength = Math.max(...options.map((opt) => opt.subtitle?.length || 0));
return Math.min(preferredWidth, maxAllowedWidth);
}, [options]);
const estimatedWidth = Math.max(maxTitleLength * 8 + 32, maxSubtitleLength * 6 + 32);
const preferredWidth = Math.min(Math.max(estimatedWidth, 300), MENU_WIDTH);
return Math.min(preferredWidth, maxAllowedWidth);
},
[options]
);
// Positioning for text-triggered menus
const recalcPosition = useCallback(() => {
if (!range) return;
const win = range.startContainer.win;
const rect = range.getBoundingClientRect();
const containerWidth = calculateWidth();
const containerWidth = calculateWidth(win);
const top = rect.top - 4;
const minLeft = 8;
const maxLeft = window.innerWidth - containerWidth - 8;
const maxLeft = win.innerWidth - containerWidth - 8;
const left = Math.min(Math.max(rect.left, minLeft), maxLeft);
setPosition({
@ -98,14 +104,16 @@ export function TypeaheadMenuPortal({
}, [recalcPosition]);
useEffect(() => {
if (!range) return;
const win = range.startContainer.win;
const handler = () => recalcPosition();
window.addEventListener("resize", handler);
document.addEventListener("scroll", handler, { passive: true });
win.addEventListener("resize", handler);
win.document.addEventListener("scroll", handler, { passive: true });
return () => {
window.removeEventListener("resize", handler);
document.removeEventListener("scroll", handler);
win.removeEventListener("resize", handler);
win.document.removeEventListener("scroll", handler);
};
}, [recalcPosition]);
}, [recalcPosition, range]);
if (!position || options.length === 0) {
return null;
@ -135,7 +143,7 @@ export function TypeaheadMenuPortal({
</div>
);
return createPortal(container, document.body);
return createPortal(container, targetWin.document.body);
}
export { tryToPositionRange };

View file

@ -4,11 +4,12 @@ import {
DOMConversionOutput,
DOMExportOutput,
EditorConfig,
LexicalEditor,
LexicalNode,
NodeKey,
$getRoot,
} from "lexical";
import { BasePillNode, SerializedBasePillNode } from "./BasePillNode";
import { BasePillNode, getEditorDocument, SerializedBasePillNode } from "./BasePillNode";
import { TruncatedPillText } from "./TruncatedPillText";
import { PillBadge } from "./PillBadge";
import { useActiveFile } from "../context/ActiveFileContext";
@ -43,8 +44,8 @@ export class ActiveNotePillNode extends BasePillNode {
return "data-lexical-active-note-pill";
}
createDOM(_config: EditorConfig): HTMLElement {
const span = document.createElement("span");
createDOM(_config: EditorConfig, editor: LexicalEditor): HTMLElement {
const span = getEditorDocument(editor).createElement("span");
span.className = "active-note-pill-wrapper";
return span;
}
@ -75,8 +76,8 @@ export class ActiveNotePillNode extends BasePillNode {
};
}
exportDOM(): DOMExportOutput {
const element = document.createElement("span");
exportDOM(editor: LexicalEditor): DOMExportOutput {
const element = getEditorDocument(editor).createElement("span");
element.setAttribute("data-lexical-active-note-pill", "true");
element.textContent = "{activeNote}";
return { element };

View file

@ -4,6 +4,7 @@ import {
DOMConversionOutput,
DOMExportOutput,
EditorConfig,
LexicalEditor,
LexicalNode,
NodeKey,
$getRoot,
@ -11,7 +12,7 @@ import {
import { Globe } from "lucide-react";
import { Platform } from "obsidian";
import { ACTIVE_WEB_TAB_MARKER } from "@/constants";
import { BasePillNode, SerializedBasePillNode } from "./BasePillNode";
import { BasePillNode, getEditorDocument, SerializedBasePillNode } from "./BasePillNode";
import { TruncatedPillText } from "./TruncatedPillText";
import { PillBadge } from "./PillBadge";
import { useActiveWebTabState } from "../hooks/useActiveWebTabState";
@ -44,8 +45,8 @@ export class ActiveWebTabPillNode extends BasePillNode {
return "data-lexical-active-web-tab-pill";
}
createDOM(_config: EditorConfig): HTMLElement {
const span = document.createElement("span");
createDOM(_config: EditorConfig, editor: LexicalEditor): HTMLElement {
const span = getEditorDocument(editor).createElement("span");
span.className = "active-web-tab-pill-wrapper";
return span;
}
@ -76,8 +77,8 @@ export class ActiveWebTabPillNode extends BasePillNode {
};
}
exportDOM(): DOMExportOutput {
const element = document.createElement("span");
exportDOM(editor: LexicalEditor): DOMExportOutput {
const element = getEditorDocument(editor).createElement("span");
element.setAttribute("data-lexical-active-web-tab-pill", "true");
element.textContent = ACTIVE_WEB_TAB_MARKER;
return { element };

View file

@ -3,12 +3,23 @@ import {
DecoratorNode,
DOMExportOutput,
EditorConfig,
LexicalEditor,
NodeKey,
SerializedLexicalNode,
} from "lexical";
import { IPillNode } from "../plugins/PillDeletionPlugin";
import { PillBadge } from "./PillBadge";
/**
* Returns the Document that owns the given Lexical editor's root element.
* Pills must create DOM in the editor's window using `activeDocument`
* (focused window) can produce nodes with the wrong owner when the chat is
* in an Obsidian popout but a different window is focused.
*/
export function getEditorDocument(editor: LexicalEditor): Document {
return editor.getRootElement()?.doc ?? activeDocument;
}
export interface SerializedBasePillNode extends SerializedLexicalNode {
value: string;
}
@ -106,8 +117,8 @@ export abstract class BasePillNode extends DecoratorNode<JSX.Element> implements
/**
* Default DOM creation - subclasses can override for custom elements.
*/
createDOM(_config: EditorConfig): HTMLElement {
const span = document.createElement("span");
createDOM(_config: EditorConfig, editor: LexicalEditor): HTMLElement {
const span = getEditorDocument(editor).createElement("span");
span.className = this.getClassName();
return span;
}
@ -115,8 +126,8 @@ export abstract class BasePillNode extends DecoratorNode<JSX.Element> implements
/**
* Default DOM export - subclasses can override for custom attributes.
*/
exportDOM(): DOMExportOutput {
const element = document.createElement("span");
exportDOM(editor: LexicalEditor): DOMExportOutput {
const element = getEditorDocument(editor).createElement("span");
element.setAttribute(this.getDataAttribute(), "");
element.setAttribute("data-pill-value", this.__value);
element.textContent = this.__value;

View file

@ -4,10 +4,11 @@ import {
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
LexicalEditor,
LexicalNode,
NodeKey,
} from "lexical";
import { BasePillNode, SerializedBasePillNode } from "./BasePillNode";
import { BasePillNode, getEditorDocument, SerializedBasePillNode } from "./BasePillNode";
import { TruncatedPillText } from "./TruncatedPillText";
import { PillBadge } from "./PillBadge";
@ -93,8 +94,8 @@ export class FolderPillNode extends BasePillNode {
/**
* Override to export DOM with curly braces
*/
exportDOM(): DOMExportOutput {
const element = document.createElement("span");
exportDOM(editor: LexicalEditor): DOMExportOutput {
const element = getEditorDocument(editor).createElement("span");
element.setAttribute(this.getDataAttribute(), "");
element.setAttribute("data-pill-value", this.__value);
element.textContent = `{${this.getFolderPath()}}`;

View file

@ -5,10 +5,11 @@ import {
DOMConversionOutput,
DOMExportOutput,
EditorConfig,
LexicalEditor,
LexicalNode,
NodeKey,
} from "lexical";
import { BasePillNode, SerializedBasePillNode } from "./BasePillNode";
import { BasePillNode, getEditorDocument, SerializedBasePillNode } from "./BasePillNode";
import { TruncatedPillText } from "./TruncatedPillText";
import { PillBadge } from "./PillBadge";
@ -43,8 +44,8 @@ export class NotePillNode extends BasePillNode {
return "data-lexical-note-pill";
}
createDOM(_config: EditorConfig): HTMLElement {
const span = document.createElement("span");
createDOM(_config: EditorConfig, editor: LexicalEditor): HTMLElement {
const span = getEditorDocument(editor).createElement("span");
span.className = "note-pill-wrapper";
return span;
}
@ -78,8 +79,8 @@ export class NotePillNode extends BasePillNode {
};
}
exportDOM(): DOMExportOutput {
const element = document.createElement("span");
exportDOM(editor: LexicalEditor): DOMExportOutput {
const element = getEditorDocument(editor).createElement("span");
element.setAttribute("data-lexical-note-pill", "true");
element.setAttribute("data-note-title", this.__noteTitle);
element.setAttribute("data-note-path", this.__notePath);

View file

@ -5,10 +5,11 @@ import {
DOMConversionOutput,
DOMExportOutput,
EditorConfig,
LexicalEditor,
LexicalNode,
NodeKey,
} from "lexical";
import { BasePillNode, SerializedBasePillNode } from "./BasePillNode";
import { BasePillNode, getEditorDocument, SerializedBasePillNode } from "./BasePillNode";
import { PillBadge } from "./PillBadge";
export interface SerializedURLPillNode extends SerializedBasePillNode {
@ -49,8 +50,8 @@ export class URLPillNode extends BasePillNode {
return "data-lexical-url-pill";
}
createDOM(_config: EditorConfig): HTMLElement {
const span = document.createElement("span");
createDOM(_config: EditorConfig, editor: LexicalEditor): HTMLElement {
const span = getEditorDocument(editor).createElement("span");
span.className = "url-pill-wrapper";
return span;
}
@ -85,8 +86,8 @@ export class URLPillNode extends BasePillNode {
};
}
exportDOM(): DOMExportOutput {
const element = document.createElement("span");
exportDOM(editor: LexicalEditor): DOMExportOutput {
const element = getEditorDocument(editor).createElement("span");
element.setAttribute("data-lexical-url-pill", "true");
element.setAttribute("data-url", this.__url);
if (this.__title) {

View file

@ -5,12 +5,13 @@ import {
DOMConversionOutput,
DOMExportOutput,
EditorConfig,
LexicalEditor,
LexicalNode,
NodeKey,
} from "lexical";
import { Globe } from "lucide-react";
import { getDomainFromUrl } from "@/utils";
import { BasePillNode, SerializedBasePillNode } from "./BasePillNode";
import { BasePillNode, getEditorDocument, SerializedBasePillNode } from "./BasePillNode";
import { PillBadge } from "./PillBadge";
export interface SerializedWebTabPillNode extends SerializedBasePillNode {
@ -63,8 +64,8 @@ export class WebTabPillNode extends BasePillNode {
return "data-lexical-web-tab-pill";
}
createDOM(_config: EditorConfig): HTMLElement {
const span = document.createElement("span");
createDOM(_config: EditorConfig, editor: LexicalEditor): HTMLElement {
const span = getEditorDocument(editor).createElement("span");
span.className = "web-tab-pill-wrapper";
return span;
}
@ -99,8 +100,8 @@ export class WebTabPillNode extends BasePillNode {
};
}
exportDOM(): DOMExportOutput {
const element = document.createElement("span");
exportDOM(editor: LexicalEditor): DOMExportOutput {
const element = getEditorDocument(editor).createElement("span");
element.setAttribute("data-lexical-web-tab-pill", "true");
element.setAttribute("data-url", this.__url);
if (this.__title) {

View file

@ -80,7 +80,7 @@ export function DraggableModal({
// so a click on the drag handle (without dragging) preserves anchorBottom behavior.
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
const ownerDoc = e.currentTarget.ownerDocument;
const ownerDoc = (e.currentTarget as HTMLElement).doc;
const startX = e.clientX;
const startY = e.clientY;
@ -189,7 +189,7 @@ export function DraggableModal({
if (anchorBottom !== undefined || isManualPositionRef.current) return;
if (heightPx === null) return;
const ownerWindow = dragRef.current?.ownerDocument?.defaultView ?? window;
const ownerWindow = dragRef.current?.win ?? window;
const maxY = ownerWindow.innerHeight - 12 - heightPx;
const newY = Math.max(12, Math.min(position.y, maxY));
if (Math.abs(position.y - newY) < 1) return;
@ -233,7 +233,7 @@ export function DraggableModal({
useEffect(() => {
if (!open) return;
const ownerDocument = dragRef.current?.ownerDocument ?? document;
const ownerDocument = dragRef.current?.doc ?? activeDocument;
const handleKeyDown = (e: KeyboardEvent) => {
// Respect defaultPrevented to let internal components (e.g., Lexical typeahead, Radix menus) consume Escape first

View file

@ -24,8 +24,8 @@ export function MarkdownPreview({ content, renderMarkdown, className }: Markdown
const currentGen = ++renderGenRef.current;
// Reason: Render into a detached element so that if content changes
// mid-render, the stale result never touches the live DOM.
// Reason: Use ownerDocument for popout-window safety in Obsidian
const scratchEl = targetEl.ownerDocument.createElement("div");
// Reason: Use the target's doc for popout-window safety in Obsidian
const scratchEl = targetEl.doc.createElement("div");
targetEl.innerHTML = "";
renderMarkdown(content, scratchEl)

View file

@ -117,7 +117,7 @@ export function MenuCommandModal({
if (!open) return;
const modalEl = innerRef.current?.closest<HTMLElement>('[data-copilot-draggable-modal="true"]');
const ownerDocument = modalEl?.ownerDocument ?? document;
const ownerDocument = modalEl?.doc ?? activeDocument;
const handleKeyDown = (e: KeyboardEvent) => {
// Only handle when result is ready and not busy

View file

@ -1,29 +0,0 @@
import { App } from "obsidian";
export class AddImageModal {
private app: App;
private onImagesSelected: (files: File[]) => void;
constructor(app: App, onImagesSelected: (files: File[]) => void) {
this.app = app;
this.onImagesSelected = onImagesSelected;
}
open() {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.multiple = true;
input.style.display = "none";
input.addEventListener("change", () => {
const files = Array.from(input.files || []);
this.onImagesSelected(files);
// Clean up
document.body.removeChild(input);
});
document.body.appendChild(input);
input.click();
}
}

View file

@ -79,7 +79,7 @@ export class SourcesModal extends Modal {
// Display with 4 decimals to match SearchCore logs and avoid apparent ties
if (typeof source.score === "number") {
itemContainer.appendChild(
document.createTextNode(` - Relevance score: ${source.score.toFixed(4)}`)
this.contentEl.doc.createTextNode(` - Relevance score: ${source.score.toFixed(4)}`)
);
}

View file

@ -314,7 +314,7 @@ export class QuickAskOverlay {
if (QuickAskOverlay.overlayRoot) return QuickAskOverlay.overlayRoot;
const doc = host.ownerDocument ?? document;
const doc = host.doc;
const root = doc.createElement("div");
root.className = "copilot-quick-ask-overlay-root";
host.appendChild(root);
@ -325,11 +325,11 @@ export class QuickAskOverlay {
private mountOverlay(): void {
// Mount overlay inside editor DOM for proper layering
const overlayHost = this.options.view.dom ?? document.body;
const overlayHost = this.options.view.dom ?? activeDocument.body;
this.overlayHost = overlayHost;
// Capture owner document/window for popout window compatibility
const doc = overlayHost.ownerDocument ?? document;
const doc = overlayHost.doc;
const win = doc.defaultView ?? window;
this.ownerDocument = doc;
this.ownerWindow = win;
@ -384,7 +384,7 @@ export class QuickAskOverlay {
if (event.key !== "Escape") return;
if (event.defaultPrevented) return;
const doc = this.ownerDocument ?? document;
const doc = this.ownerDocument ?? activeDocument;
const activeEl = doc.activeElement;
const isFocusInsidePanel = !!(activeEl && this.overlayContainer?.contains(activeEl));
@ -532,7 +532,7 @@ export class QuickAskOverlay {
return;
}
const doc = this.ownerDocument ?? document;
const doc = this.ownerDocument ?? activeDocument;
const hostRect = this.overlayHost?.getBoundingClientRect() ?? doc.body.getBoundingClientRect();
const viewportWidth = hostRect.width;
@ -665,7 +665,7 @@ export class QuickAskOverlay {
this.resizeStartRect = rect;
this.resizeStartMouse = start;
const doc = this.ownerDocument ?? document;
const doc = this.ownerDocument ?? activeDocument;
const body = doc.body;
// Prevent text selection and set resize cursor on body during drag
@ -721,7 +721,7 @@ export class QuickAskOverlay {
// Only restore body styles if we actually started resizing
// This prevents polluting body styles when destroy() is called without resize
if (this.isResizing) {
const doc = this.ownerDocument ?? document;
const doc = this.ownerDocument ?? activeDocument;
const body = doc.body;
doc.removeEventListener("mousemove", this.handleResizeMove, true);
doc.removeEventListener("mouseup", this.handleResizeEnd, true);
@ -740,7 +740,7 @@ export class QuickAskOverlay {
private applyResize(clientX: number, clientY: number): void {
if (!this.resizeStartRect || !this.resizeStartMouse || !this.resizeDirection) return;
const doc = this.ownerDocument ?? document;
const doc = this.ownerDocument ?? activeDocument;
const hostRect = this.overlayHost?.getBoundingClientRect() ?? doc.body.getBoundingClientRect();
const deltaX = clientX - this.resizeStartMouse.x;
@ -856,7 +856,7 @@ export class QuickAskOverlay {
private updateDragPosition(): void {
if (!this.overlayContainer || !this.dragPosition) return;
const doc = this.ownerDocument ?? document;
const doc = this.ownerDocument ?? activeDocument;
const hostRect = this.overlayHost?.getBoundingClientRect() ?? doc.body.getBoundingClientRect();
const viewportWidth = hostRect.width;

View file

@ -15,7 +15,7 @@ export const TabProvider: React.FC<{ children: React.ReactNode }> = ({ children
useEffect(() => {
if (!hasInitialized.current) {
const modal = document.querySelector(".modal-container") as HTMLElement;
const modal = activeDocument.querySelector(".modal-container") as HTMLElement;
setModalContainer(modal);
hasInitialized.current = true;
}

View file

@ -106,7 +106,7 @@ export function useDraggable(options: UseDraggableOptions = {}) {
const el = dragRef.current;
if (bounds === "window" && el) {
const ownerWindow = el.ownerDocument?.defaultView ?? window;
const ownerWindow = el.win;
const rect = el.getBoundingClientRect();
const maxX = ownerWindow.innerWidth - rect.width;
const maxY = ownerWindow.innerHeight - rect.height;
@ -141,7 +141,7 @@ export function useDraggable(options: UseDraggableOptions = {}) {
const scheduleApply = useCallback((): void => {
if (rafIdRef.current != null) return;
const ownerWindow = dragRef.current?.ownerDocument?.defaultView ?? window;
const ownerWindow = dragRef.current?.win ?? window;
rafIdRef.current = ownerWindow.requestAnimationFrame(() => {
rafIdRef.current = null;
@ -192,7 +192,7 @@ export function useDraggable(options: UseDraggableOptions = {}) {
y: e.clientY - current.y,
};
const ownerDocument = dragRef.current?.ownerDocument ?? document;
const ownerDocument = dragRef.current?.doc ?? activeDocument;
const ownerWindow = ownerDocument.defaultView ?? window;
const body = ownerDocument.body;
previousBodyStyleRef.current = {

View file

@ -95,7 +95,7 @@ export function useRafResizable(options: UseRafResizableOptions): UseRafResizabl
// Capture owner document/window from the event target
const targetElement = e.currentTarget as HTMLElement | null;
const ownerDoc = targetElement?.ownerDocument ?? document;
const ownerDoc = targetElement?.doc ?? activeDocument;
ownerDocumentRef.current = ownerDoc;
ownerWindowRef.current = ownerDoc.defaultView ?? window;
@ -133,7 +133,7 @@ export function useRafResizable(options: UseRafResizableOptions): UseRafResizabl
? "nesw-resize"
: "nwse-resize";
const ownerDocument = ownerDocumentRef.current ?? document;
const ownerDocument = ownerDocumentRef.current ?? activeDocument;
const ownerWindow = ownerWindowRef.current ?? window;
const body = ownerDocument.body;
const previousCursor = body.style.cursor;

View file

@ -41,7 +41,7 @@ jest.mock("obsidian", () => ({
})),
Notice: jest.fn().mockImplementation(function (message) {
this.message = message;
this.noticeEl = document.createElement("div");
this.noticeEl = window.document.createElement("div");
this.hide = jest.fn();
}),
Platform: {

View file

@ -91,6 +91,7 @@ export default class CopilotPlugin extends Plugin {
chatSelectionHighlightController: ChatSelectionHighlightController;
private selectionDebounceTimer?: number;
private selectionChangeHandler?: () => void;
private selectionListenerDocument?: Document;
private lastSelectionSignature?: string;
private webSelectionTracker?: WebSelectionTracker;
private readonly chatHistoryLastAccessedAtManager = new RecentUsageManager<string>();
@ -378,8 +379,10 @@ export default class CopilotPlugin extends Plugin {
}, 500);
};
// Register the DOM selection change event
document.addEventListener("selectionchange", this.selectionChangeHandler);
// Capture the document at registration so removal targets the same one
// (activeDocument can change if the user focuses a popout window).
this.selectionListenerDocument = activeDocument;
this.selectionListenerDocument.addEventListener("selectionchange", this.selectionChangeHandler);
}
/**
@ -389,9 +392,13 @@ export default class CopilotPlugin extends Plugin {
if (this.selectionDebounceTimer) {
window.clearTimeout(this.selectionDebounceTimer);
}
if (this.selectionChangeHandler) {
document.removeEventListener("selectionchange", this.selectionChangeHandler);
if (this.selectionChangeHandler && this.selectionListenerDocument) {
this.selectionListenerDocument.removeEventListener(
"selectionchange",
this.selectionChangeHandler
);
}
this.selectionListenerDocument = undefined;
}
/**