diff --git a/src/main.ts b/src/main.ts index a04d952e..37bd4b84 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,6 +8,7 @@ import { CodexTurnDiffView } from "./features/turn-diff/view.obsidian"; import { CodexPanelRuntime } from "./plugin-runtime"; import { type CodexPanelSettings, DEFAULT_SETTINGS, getVaultPath, normalizeSettings, settingsMatchStoredSettings } from "./settings/model"; import { CodexPanelSettingTab } from "./settings/tab.obsidian"; +import { disposeTextareaHeightMirrors } from "./shared/dom/textarea-autogrow.measure"; export default class CodexPanelPlugin extends Plugin { settings: CodexPanelSettings = DEFAULT_SETTINGS; @@ -19,6 +20,7 @@ export default class CodexPanelPlugin extends Plugin { }); override async onload(): Promise { + disposeTextareaHeightMirrors(); this.runtime.reset(); this.vaultPath = getVaultPath(this.app); await this.loadSettings(); @@ -77,6 +79,7 @@ export default class CodexPanelPlugin extends Plugin { override onunload(): void { this.runtime.cancelWorkspacePanelReconcile(); + disposeTextareaHeightMirrors(); } async loadSettings(): Promise { diff --git a/src/shared/dom/textarea-autogrow.measure.ts b/src/shared/dom/textarea-autogrow.measure.ts index a4f65ab1..381ca24d 100644 --- a/src/shared/dom/textarea-autogrow.measure.ts +++ b/src/shared/dom/textarea-autogrow.measure.ts @@ -4,6 +4,7 @@ export interface TextareaHeightOptions { } const textareaHeightMirrors = new WeakMap(); +const textareaHeightMirrorDocuments = new Set(); const TEXTAREA_HEIGHT_MIRROR_CLASS = "codex-panel-textarea-height-mirror"; export function syncTextareaHeight(textarea: HTMLTextAreaElement | null, options: TextareaHeightOptions): void { @@ -21,6 +22,14 @@ export function syncTextareaHeight(textarea: HTMLTextAreaElement | null, options textarea.setCssProps(sizingProps); } +export function disposeTextareaHeightMirrors(): void { + for (const doc of textareaHeightMirrorDocuments) { + for (const mirror of doc.querySelectorAll(`.${TEXTAREA_HEIGHT_MIRROR_CLASS}`)) mirror.remove(); + textareaHeightMirrors.delete(doc); + } + textareaHeightMirrorDocuments.clear(); +} + function measureTextareaNaturalScrollHeight(textarea: HTMLTextAreaElement, style: CSSStyleDeclaration): number { const mirror = textareaHeightMirror(textarea.ownerDocument); mirror.value = textarea.value; @@ -30,14 +39,17 @@ function measureTextareaNaturalScrollHeight(textarea: HTMLTextAreaElement, style function textareaHeightMirror(doc: Document): HTMLTextAreaElement { const existing = textareaHeightMirrors.get(doc); - if (existing) return existing; - const mirror = doc.createElement("textarea"); + if (existing?.isConnected) return existing; + const staleMirrors = [...doc.querySelectorAll(`.${TEXTAREA_HEIGHT_MIRROR_CLASS}`)]; + const mirror = staleMirrors.shift() ?? doc.createElement("textarea"); + for (const duplicate of staleMirrors) duplicate.remove(); mirror.tabIndex = -1; mirror.setAttribute("aria-hidden", "true"); mirror.readOnly = true; mirror.addClass(TEXTAREA_HEIGHT_MIRROR_CLASS); - doc.body.appendChild(mirror); + if (!mirror.isConnected) doc.body.appendChild(mirror); textareaHeightMirrors.set(doc, mirror); + textareaHeightMirrorDocuments.add(doc); return mirror; } diff --git a/tests/shared/dom/textarea-autogrow.test.ts b/tests/shared/dom/textarea-autogrow.test.ts new file mode 100644 index 00000000..bdb75c04 --- /dev/null +++ b/tests/shared/dom/textarea-autogrow.test.ts @@ -0,0 +1,43 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it } from "vitest"; + +import { disposeTextareaHeightMirrors, syncTextareaHeight } from "../../../src/shared/dom/textarea-autogrow.measure"; +import { installObsidianDomShims } from "../../support/dom"; + +installObsidianDomShims(); + +const MIRROR_SELECTOR = ".codex-panel-textarea-height-mirror"; + +afterEach(() => { + disposeTextareaHeightMirrors(); + document.body.replaceChildren(); +}); + +describe("textarea auto-grow measurement", () => { + it("adopts one existing mirror and removes reload duplicates", () => { + document.body.append(mirror(), mirror()); + const textarea = document.createElement("textarea"); + document.body.appendChild(textarea); + + syncTextareaHeight(textarea, { minHeightFallback: 32, maxHeightFallback: 120 }); + + expect(document.querySelectorAll(MIRROR_SELECTOR)).toHaveLength(1); + }); + + it("removes tracked mirrors when the plugin lifecycle ends", () => { + const textarea = document.createElement("textarea"); + document.body.appendChild(textarea); + syncTextareaHeight(textarea, { minHeightFallback: 32, maxHeightFallback: 120 }); + + disposeTextareaHeightMirrors(); + + expect(document.querySelector(MIRROR_SELECTOR)).toBeNull(); + }); +}); + +function mirror(): HTMLTextAreaElement { + const element = document.createElement("textarea"); + element.className = MIRROR_SELECTOR.slice(1); + return element; +}