fix: bind timers to their owning windows

fix: make cache unload replay authoritative
This commit is contained in:
haperone 2026-06-15 21:23:29 +02:00
parent 3eedd6be89
commit 3355a902aa
21 changed files with 664 additions and 253 deletions

View file

@ -1,18 +1,24 @@
// Blocking mirror of the current Obsidian community-plugin submission scanner.
import fs from "node:fs";
import path from "node:path";
import tsParser from "@typescript-eslint/parser";
import tseslint from "typescript-eslint";
import obsidianmd from "eslint-plugin-obsidianmd";
const sourceFiles = ["src-ts/**/*.ts"];
const isDevLayout = path.basename(import.meta.dirname) === "source-recovery"
&& fs.existsSync(path.join(import.meta.dirname, "src-ts"))
&& fs.existsSync(path.join(import.meta.dirname, "..", "manifest.json"));
const sourcePrefix = isDevLayout ? "source-recovery/" : "";
const sourceFiles = [`${sourcePrefix}src-ts/**/*.ts`];
export default [
{
ignores: [
"node_modules/**",
"dist/**",
"dist-ts/**",
"qa-backups/**",
"qa-screenshots/**",
`${sourcePrefix}node_modules/**`,
`${sourcePrefix}dist/**`,
`${sourcePrefix}dist-ts/**`,
`${sourcePrefix}qa-backups/**`,
`${sourcePrefix}qa-screenshots/**`,
"**/.claude/**",
"**/.supergoal/**",
"*.mjs"
@ -35,7 +41,7 @@ export default [
}
},
{
files: ["src-ts/i18n.ts"],
files: [`${sourcePrefix}src-ts/i18n.ts`],
rules: {
"obsidianmd/ui/sentence-case-locale-module": "error"
}

View file

@ -2,9 +2,9 @@
const fs = require("fs");
const path = require("path");
const { resolveRepositoryLayout } = require("./repository-layout");
const sourceRoot = path.resolve(__dirname, "..");
const repoRoot = sourceRoot;
const { isDevLayout, repositoryRoot: repoRoot, sourceRoot } = resolveRepositoryLayout();
const tsRoot = path.join(sourceRoot, "src-ts");
const requireBundle = process.argv.includes("--require-bundle");
@ -52,6 +52,9 @@ const packageJson = JSON.parse(fs.readFileSync(path.join(sourceRoot, "package.js
const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, "manifest.json"), "utf8"));
const readme = fs.readFileSync(path.join(repoRoot, "README.md"), "utf8");
const readmeRu = fs.readFileSync(path.join(repoRoot, "README.ru.md"), "utf8");
const releaseAuditPath = path.join(repoRoot, "OBSIDIAN_RELEASE_AUDIT.md");
assert(!isDevLayout || fs.existsSync(releaseAuditPath), "DEV policy audit requires OBSIDIAN_RELEASE_AUDIT.md");
const releaseAudit = fs.existsSync(releaseAuditPath) ? fs.readFileSync(releaseAuditPath, "utf8") : null;
const mainBundlePath = path.join(repoRoot, "main.js");
const mainBundleExists = fs.existsSync(mainBundlePath);
assert(!requireBundle || mainBundleExists, "Production main.js is required after build");
@ -165,6 +168,12 @@ for (const file of files) {
fullVaultScans.sort();
assert(JSON.stringify(fullVaultScans) === JSON.stringify(expectedFullVaultScans), `Full-vault scan inventory changed: ${fullVaultScans.join(", ")}`);
if (releaseAudit) {
for (const boundaryPath of [...new Set([...expectedFsBoundaryFiles, ...expectedFullVaultScans])]) {
assert(releaseAudit.includes(`\`${boundaryPath}\``), `Release audit is missing boundary disposition for ${boundaryPath}`);
}
}
assert(compressionWorkerSource.includes("initJpegDecode(getCachedWasmModule") && compressionWorkerSource.includes("initPngDecode(message.wasm.png)"), "Codec initialization must use transferred inline WASM");
assert(
workerSlotSource.includes("wasmBytes")
@ -179,6 +188,10 @@ if (mainBundleExists) {
assert(countMatches(mainBundle, /\beval\s*\(|new Function/g) === 0, "Production bundle contains eval-like execution");
assert(countMatches(mainBundle, /\bfetch\s*\(/g) === 5 && countMatches(mainBundle, /\bXMLHttpRequest\b/g) === 6, "Dormant pinned codec fallback inventory changed");
}
if (releaseAudit) {
assert(releaseAudit.includes("Dormant vendor fallbacks"), "Release audit must explain bundled codec fetch/XMLHttpRequest fallback strings");
}
process.stdout.write([
"Policy audit passed.",
`TypeScript files: ${files.length}`,

View file

@ -4,9 +4,10 @@ const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { spawnSync } = require("child_process");
const { resolveRepositoryLayout } = require("./repository-layout");
const root = path.resolve(__dirname, "..");
const rootMainPath = path.join(root, "main.js");
const { repositoryRoot, sourceRoot: root } = resolveRepositoryLayout();
const rootMainPath = path.join(repositoryRoot, "main.js");
const generatedPath = path.join(root, "dist-ts", "main.js");
const build = spawnSync(process.execPath, [path.join(root, "scripts", "build-ts.js"), "--production"], {

View file

@ -4,11 +4,15 @@ const fs = require("fs");
const path = require("path");
const childProcess = require("child_process");
const esbuild = require("esbuild");
const { resolveRepositoryLayout } = require("./repository-layout");
const root = path.resolve(__dirname, "..");
const { isDevLayout, sourceRoot: root } = resolveRepositoryLayout();
const esbuildCli = require.resolve("esbuild/bin/esbuild");
const production = process.argv.includes("--production");
const generatedBanner = "/* GENERATED/BUNDLED FILE. Review source at https://github.com/haperone/local-image-compress */";
const sourceRepositoryUrl = isDevLayout
? "https://github.com/haperone/local-image-compress_DEV"
: "https://github.com/haperone/local-image-compress";
const generatedBanner = `/* GENERATED/BUNDLED FILE. Review source at ${sourceRepositoryUrl} */`;
function runEsbuildCli(args) {
childProcess.execFileSync(process.execPath, [esbuildCli, ...args], {

View file

@ -1,15 +1,16 @@
const path = require("path");
const { resolveRepositoryLayout } = require("./repository-layout");
async function main() {
const repositoryRoot = path.resolve(__dirname, "..");
const { isDevLayout, repositoryRoot, sourceRoot } = resolveRepositoryLayout();
process.chdir(repositoryRoot);
const { ESLint } = require("eslint");
const eslint = new ESLint({
cwd: repositoryRoot,
overrideConfigFile: path.join(repositoryRoot, "eslint.obsidian.config.mjs"),
overrideConfigFile: path.join(sourceRoot, "eslint.obsidian.config.mjs"),
fix: process.argv.includes("--fix")
});
const results = await eslint.lintFiles(["src-ts/"]);
const results = await eslint.lintFiles([isDevLayout ? "source-recovery/src-ts/" : "src-ts/"]);
await ESLint.outputFixes(results);
const formatter = await eslint.loadFormatter("stylish");
const output = formatter.format(results);

View file

@ -2,8 +2,9 @@
const fs = require("fs");
const path = require("path");
const { resolveRepositoryLayout } = require("./repository-layout");
const root = path.resolve(__dirname, "..");
const { repositoryRoot: root } = resolveRepositoryLayout();
const buildDir = path.join(root, "build");
const releaseFiles = ["manifest.json", "main.js", "styles.css", "versions.json"];

View file

@ -0,0 +1,22 @@
"use strict";
const fs = require("fs");
const path = require("path");
function resolveRepositoryLayout(scriptDirectory = __dirname) {
const sourceRoot = path.resolve(scriptDirectory, "..");
const parentRoot = path.resolve(sourceRoot, "..");
const isDevLayout = path.basename(sourceRoot) === "source-recovery"
&& fs.existsSync(path.join(sourceRoot, "src-ts"))
&& fs.existsSync(path.join(parentRoot, "manifest.json"));
const repositoryRoot = isDevLayout ? parentRoot : sourceRoot;
return {
isDevLayout,
repositoryRoot,
sourceRoot
};
}
module.exports = {
resolveRepositoryLayout
};

View file

@ -3,10 +3,10 @@
const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const { resolveRepositoryLayout } = require("./repository-layout");
const pluginId = "local-image-compress";
const sourceRoot = path.resolve(__dirname, "..");
const repoRoot = sourceRoot;
const { repositoryRoot: repoRoot, sourceRoot } = resolveRepositoryLayout();
const runnerPath = path.join(__dirname, "runtime-qa.js");
const reportDir = path.join(repoRoot, "qa-backups");
const pluginInstallDir = resolvePluginInstallDirectory();
@ -32,10 +32,11 @@ function resolvePluginInstallDirectory() {
}
function printHelp() {
const runnerDisplayPath = path.relative(repoRoot, runnerPath).replace(/\\/g, "/");
console.log([
"Usage: npm run qa:runtime [-- --skip-reload] [-- --skip-dev-errors]",
"",
"Runs scripts/runtime-qa.js inside a live Obsidian instance.",
`Runs ${runnerDisplayPath} inside a live Obsidian instance.`,
"The DEV command builds and deploys the configured Vault copy before running.",
"Set OBSIDIAN_DEV_PLUGIN_DIR to override .obsidian-dev.json.",
"Set OBSIDIAN_CLI to override the Obsidian CLI executable path."

View file

@ -616,6 +616,15 @@
const popoutContainer = popoutLeaf?.view?.containerEl;
const popoutDocument = popoutContainer?.doc || popoutContainer?.ownerDocument;
assert(popoutContainer && popoutDocument && popoutDocument !== document, "Obsidian popout leaf did not expose a distinct document");
const popoutWindow = popoutDocument.defaultView;
assert(!!popoutWindow, "Obsidian popout document did not expose its owning window");
let cancelledPopoutTimerFired = false;
const popoutTimer = p.settingsTab.setWindowTimeout(() => {
cancelledPopoutTimerFired = true;
}, 100, popoutWindow);
p.settingsTab.clearWindowTimeout(popoutTimer, popoutWindow);
await sleep(150);
assert(!cancelledPopoutTimerFired, "Settings timer was not cancelled through its owning popout window");
const popoutTarget = popoutContainer.createDiv({ cls: "tiny-local-runtime-popout-tooltip-probe" });
const savings = (await p.getStatsSnapshot()).savings;
p.settingsTab.createSavingsTooltip(popoutTarget, savings);
@ -626,7 +635,7 @@
p.settingsTab.cleanupSavingsTooltips();
popoutTarget.remove();
return { light, dark, reducedMotion: true, highContrast: true, popoutOwned: true };
return { light, dark, reducedMotion: true, highContrast: true, popoutOwned: true, popoutTimerOwned: true };
} finally {
document.body.classList.remove("theme-light", "theme-dark");
if (originalThemeClasses.light) document.body.classList.add("theme-light");

File diff suppressed because it is too large Load diff

View file

@ -2,8 +2,9 @@
const fs = require("fs");
const path = require("path");
const { resolveRepositoryLayout } = require("./repository-layout");
const root = path.resolve(__dirname, "..");
const { repositoryRoot: root, sourceRoot } = resolveRepositoryLayout();
function assert(condition, message) {
if (!condition) {
@ -27,10 +28,10 @@ const readmeRu = readText("README.ru.md");
const apacheLicense = readText("licenses/Apache-2.0.txt");
const jpegCodecLicense = readText("licenses/jpeg-codec.txt");
const pngCodecLicense = readText("licenses/png-codec.txt");
const installedApacheLicense = fs.readFileSync(path.join(root, "node_modules", "@jsquash", "jpeg", "LICENSE"), "utf8");
const installedPngApacheLicense = fs.readFileSync(path.join(root, "node_modules", "@jsquash", "png", "LICENSE"), "utf8");
const installedJpegCodecLicense = fs.readFileSync(path.join(root, "node_modules", "@jsquash", "jpeg", "codec", "LICENSE.codec.md"), "utf8");
const installedPngCodecLicense = fs.readFileSync(path.join(root, "node_modules", "@jsquash", "png", "codec", "LICENSE.codec.md"), "utf8");
const installedApacheLicense = fs.readFileSync(path.join(sourceRoot, "node_modules", "@jsquash", "jpeg", "LICENSE"), "utf8");
const installedPngApacheLicense = fs.readFileSync(path.join(sourceRoot, "node_modules", "@jsquash", "png", "LICENSE"), "utf8");
const installedJpegCodecLicense = fs.readFileSync(path.join(sourceRoot, "node_modules", "@jsquash", "jpeg", "codec", "LICENSE.codec.md"), "utf8");
const installedPngCodecLicense = fs.readFileSync(path.join(sourceRoot, "node_modules", "@jsquash", "png", "codec", "LICENSE.codec.md"), "utf8");
const bundlesGplCodec = /imagequant[\s\S]*GPL\s*v?3/i.test(thirdPartyNotices);
if (bundlesGplCodec) {
@ -51,11 +52,12 @@ assert(apacheLicense === installedPngApacheLicense, "Tracked Apache-2.0 text doe
assert(jpegCodecLicense === installedJpegCodecLicense, "Tracked JPEG codec license does not match the pinned package");
assert(pngCodecLicense === installedPngCodecLicense, "Tracked PNG codec license does not match the pinned package");
assert(/This software is based in part on the work of the\s+Independent JPEG Group\./i.test(thirdPartyNotices), "JPEG codec attribution is missing");
const wasmHashPath = path.relative(root, path.join(sourceRoot, "wasm-hashes.json")).replace(/\\/g, "/");
for (const localLicensePath of [
"licenses/Apache-2.0.txt",
"licenses/jpeg-codec.txt",
"licenses/png-codec.txt",
"wasm-hashes.json"
wasmHashPath
]) {
assert(thirdPartyNotices.includes(localLicensePath), `Third-party notices are missing ${localLicensePath}`);
}

View file

@ -2,15 +2,16 @@
const fs = require("fs");
const path = require("path");
const { resolveRepositoryLayout } = require("./repository-layout");
const root = path.resolve(__dirname, "..");
const { isDevLayout, repositoryRoot: root, sourceRoot } = resolveRepositoryLayout();
const manifestPath = path.join(root, "manifest.json");
const versionsPath = path.join(root, "versions.json");
const packagePath = path.join(root, "package.json");
const releaseWorkflowPath = path.join(root, ".github", "workflows", "release.yml");
const gitignorePath = path.join(root, ".gitignore");
const releasePreparePath = path.join(root, "scripts", "prepare-release.js");
const sourceTsRoot = path.join(root, "src-ts");
const releasePreparePath = path.join(sourceRoot, "scripts", "prepare-release.js");
const sourceTsRoot = path.join(sourceRoot, "src-ts");
const MIN_API_SURFACE_APP_VERSION = "1.4.0";
const DESKTOP_ONLY_REQUIRED_REASON = "cache, move, backup, and WASM artifact paths still depend on desktop Node fs/path APIs";
const DESKTOP_ONLY_API_PATTERNS = [
@ -167,7 +168,7 @@ assert(fs.existsSync(releasePreparePath), "Release preparation script is require
const releasePrepare = fs.readFileSync(releasePreparePath, "utf8");
const gitignore = fs.readFileSync(gitignorePath, "utf8");
assert(
releaseWorkflow.includes("npm run prepare:release"),
releaseWorkflow.includes(isDevLayout ? "npm --prefix source-recovery run prepare:release" : "npm run prepare:release"),
"Release workflow must use the validated release preparation script"
);
assert(releaseWorkflow.includes('"*.*.*"'), "Release workflow must trigger on dotted tag candidates");

View file

@ -4,9 +4,9 @@ const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const { resolveRepositoryLayout } = require("./repository-layout");
const sourceRoot = path.resolve(__dirname, "..");
const root = sourceRoot;
const { repositoryRoot: root, sourceRoot } = resolveRepositoryLayout();
const rootMainPath = path.join(root, "main.js");
const releaseFiles = ["manifest.json", "main.js", "styles.css", "versions.json"];

View file

@ -3,10 +3,11 @@
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { resolveRepositoryLayout } = require("./repository-layout");
const root = path.resolve(__dirname, "..");
const { repositoryRoot, sourceRoot: root } = resolveRepositoryLayout();
const generatedPath = path.join(root, "dist-ts", "main.js");
const rootMainPath = path.join(root, "main.js");
const rootMainPath = path.join(repositoryRoot, "main.js");
function sha256(buffer) {
return crypto.createHash("sha256").update(buffer).digest("hex").toUpperCase();

View file

@ -850,7 +850,7 @@ export class Cache {
return;
}
try {
this.writeCacheFileSyncAtomic(snapshot, { mergeDiskEntries });
this.writeCacheFileSyncAtomic(snapshot, { mergeDiskEntries: false });
} catch (error) {
console.error(getLogTag(this), "Failed to replay cache flush after active write:", error);
}

View file

@ -92,7 +92,7 @@ export default class LocalImageCompressPlugin extends obsidian.Plugin {
compressor!: Compressor;
statusBarItem: HTMLElement | null;
managedModals: Set<ManagedModal>;
modalFocusTimers: Map<number, Window>;
modalFocusTimers: Map<Window, Set<number>>;
settingsTab: SettingsTab | null;
initializationPromise: Promise<void> | null;
@ -225,8 +225,10 @@ export default class LocalImageCompressPlugin extends obsidian.Plugin {
}
this.indexRefreshTimers.clear();
}
for (const [timer, ownerWindow] of this.modalFocusTimers) {
ownerWindow.clearTimeout(timer);
for (const [ownerWindow, timers] of this.modalFocusTimers) {
for (const timer of timers) {
ownerWindow.clearTimeout(timer);
}
}
this.modalFocusTimers.clear();
this.newFileQueue.cleanup();
@ -391,21 +393,21 @@ export default class LocalImageCompressPlugin extends obsidian.Plugin {
|| this.getActiveWindow().document
|| window.document;
}
setWindowTimeout(callback: (...args: never[]) => unknown, delay: number) {
return window.setTimeout(callback, delay);
setWindowTimeout(callback: (...args: never[]) => unknown, delay: number, ownerWindow: Window = window) {
return ownerWindow.setTimeout(callback, delay);
}
requestWindowAnimationFrame(callback: FrameRequestCallback) {
const ownerWindow = this.statusBarItem?.win || this.getActiveWindow();
if (ownerWindow.requestAnimationFrame) {
return ownerWindow.requestAnimationFrame(callback);
}
return this.setWindowTimeout(callback, 0);
return this.setWindowTimeout(callback, 0, ownerWindow);
}
clearWindowTimeout(timer: TimerHandle | null | undefined) {
clearWindowTimeout(timer: TimerHandle | null | undefined, ownerWindow: Window = window) {
if (timer === null || timer === undefined) {
return;
}
window.clearTimeout(timer as number);
ownerWindow.clearTimeout(timer as number);
}
async yieldToUi() {
await new Promise((resolve) => {
@ -947,12 +949,18 @@ export default class LocalImageCompressPlugin extends obsidian.Plugin {
}
const ownerWindow = target.ownerDocument?.defaultView || this.getActiveWindow();
const timer = ownerWindow.setTimeout(() => {
this.modalFocusTimers.delete(timer);
const ownerTimers = this.modalFocusTimers.get(ownerWindow);
ownerTimers?.delete(timer);
if (ownerTimers?.size === 0) {
this.modalFocusTimers.delete(ownerWindow);
}
if (!this.isUnloading && target.isConnected) {
target.focus();
}
}, 0);
this.modalFocusTimers.set(timer, ownerWindow);
const ownerTimers = this.modalFocusTimers.get(ownerWindow) || new Set<number>();
ownerTimers.add(timer);
this.modalFocusTimers.set(ownerWindow, ownerTimers);
}
restoreModalFocus(target: HTMLElement | null | undefined) {
this.scheduleElementFocus(target);

View file

@ -10,6 +10,7 @@ export class FolderSelectorModal extends obsidian.Modal {
private settled = false;
private listenerCleanups: Array<() => void> = [];
private focusTimer: TimerHandle | null = null;
private focusTimerWindow: Window | null = null;
private readonly returnFocusTo: HTMLElement | null;
constructor(plugin: LocalImageCompressPlugin, folderPaths: string[], resolveSelection: (value: string | null) => void) {
@ -83,16 +84,23 @@ export class FolderSelectorModal extends obsidian.Modal {
() => contentEl.removeEventListener("keydown", onContentKeydown)
);
const ownerWindow = contentEl.win || this.plugin.getActiveWindow();
this.focusTimerWindow = ownerWindow;
this.focusTimer = this.plugin.setWindowTimeout(() => {
this.focusTimer = null;
this.focusTimerWindow = null;
select.focus();
}, 0);
}, 0, ownerWindow);
}
override onClose() {
if (this.focusTimer) {
this.plugin.clearWindowTimeout(this.focusTimer);
this.plugin.clearWindowTimeout(
this.focusTimer,
this.focusTimerWindow || this.contentEl.win || this.plugin.getActiveWindow()
);
this.focusTimer = null;
this.focusTimerWindow = null;
}
for (const listenerCleanup of this.listenerCleanups) {
listenerCleanup();

View file

@ -52,6 +52,7 @@ export class SettingsTab extends obsidian.PluginSettingTab {
compressedFilesCountElement: HTMLElement | null;
savingsHostElement: HTMLElement | null;
saveSettingsDebounceTimer: TimerHandle | null;
saveSettingsDebounceWindow: Window | null;
updateStats!: () => Promise<void>;
constructor(app: obsidian.App, plugin: LocalImageCompressPlugin) {
@ -70,6 +71,7 @@ export class SettingsTab extends obsidian.PluginSettingTab {
this.compressedFilesCountElement = null;
this.savingsHostElement = null;
this.saveSettingsDebounceTimer = null;
this.saveSettingsDebounceWindow = null;
}
requestRerenderAfterCurrentRender() {
if (!this._isRendering) {
@ -130,41 +132,58 @@ export class SettingsTab extends obsidian.PluginSettingTab {
getActiveWindow() {
return getActiveWindowForApp(this.app) || window;
}
setWindowTimeout(callback: (...args: never[]) => unknown, delay: number) {
return window.setTimeout(callback, delay);
setWindowTimeout(
callback: (...args: never[]) => unknown,
delay: number,
ownerWindow: Window = this.containerEl?.win || this.getActiveWindow()
) {
return ownerWindow.setTimeout(callback, delay);
}
clearWindowTimeout(timer: TimerHandle | null | undefined) {
clearWindowTimeout(
timer: TimerHandle | null | undefined,
ownerWindow: Window = this.containerEl?.win || this.getActiveWindow()
) {
if (timer === null || timer === undefined) {
return;
}
window.clearTimeout(timer as number);
ownerWindow.clearTimeout(timer as number);
}
flushPendingSaveSettings() {
if (!this.saveSettingsDebounceTimer) {
return;
}
try {
this.clearWindowTimeout(this.saveSettingsDebounceTimer);
this.clearWindowTimeout(
this.saveSettingsDebounceTimer,
this.saveSettingsDebounceWindow || this.containerEl?.win || this.getActiveWindow()
);
} catch (error) {
console.debug(getLogTag(this), "Settings debounce timer cleanup failed (non-critical)", error);
}
this.saveSettingsDebounceTimer = null;
this.saveSettingsDebounceWindow = null;
this.plugin.saveSettings().catch((error: unknown) => {
console.error(getLogTag(this), "Settings save during close failed:", error);
});
}
debouncedSaveSettings(delayMs = 300, afterSave: (() => void) | null = null) {
if (this.saveSettingsDebounceTimer) {
this.clearWindowTimeout(this.saveSettingsDebounceTimer);
this.clearWindowTimeout(
this.saveSettingsDebounceTimer,
this.saveSettingsDebounceWindow || this.containerEl?.win || this.getActiveWindow()
);
}
const ownerWindow = this.containerEl?.win || this.getActiveWindow();
this.saveSettingsDebounceWindow = ownerWindow;
this.saveSettingsDebounceTimer = this.setWindowTimeout(() => {
this.saveSettingsDebounceTimer = null;
this.saveSettingsDebounceWindow = null;
this.plugin.saveSettings()
.then(() => afterSave?.())
.catch((error) => {
console.error(getLogTag(this), "Settings save failed:", error);
});
}, delayMs);
}, delayMs, ownerWindow);
}
showSettingsOperationError(error: unknown, context: string, noticeKey = "notice.operationFailed") {
console.error(getLogTag(this), context, error);
@ -215,7 +234,7 @@ export class SettingsTab extends obsidian.PluginSettingTab {
if (ownerWindow.requestAnimationFrame) {
return ownerWindow.requestAnimationFrame(callback);
}
return this.setWindowTimeout(callback, 0);
return this.setWindowTimeout(callback, 0, ownerWindow);
}
normalizeAllowedRootSelection(chosen: string) {
if (chosen === "" || chosen === "/") {

View file

@ -25,6 +25,7 @@ export class StatusBarController {
private openStatusMenuDocument: Document | null = null;
private teardownStatusMenuListeners: (() => void) | null = null;
private deferredClickTimer: TimerHandle | null = null;
private deferredClickTimerWindow: Window | null = null;
private statusMenuFocusTarget: { focus?: () => void } | null = null;
constructor(plugin: LocalImageCompressPlugin) {
@ -164,15 +165,17 @@ export class StatusBarController {
};
// Defer click binding to avoid immediate close due to the opening click
this.deferredClickTimerWindow = activeWindow;
this.deferredClickTimer = this.plugin.setWindowTimeout(() => {
this.deferredClickTimer = null;
this.deferredClickTimerWindow = null;
if (this.plugin.isUnloading || this.openStatusMenu !== menu || this.teardownStatusMenuListeners !== cleanup) {
return;
}
// transient: menu-scoped, removed on close via cleanup() (registerDomEvent would leak across opens)
activeDocument.addEventListener('click', onDocClick);
clickListenerAttached = true;
}, 0);
}, 0, activeWindow);
activeDocument.addEventListener('keydown', onKeyDown);
activeWindow.addEventListener('blur', onBlur);
return cleanup;
@ -191,11 +194,15 @@ export class StatusBarController {
return;
}
try {
this.plugin.clearWindowTimeout(this.deferredClickTimer);
this.plugin.clearWindowTimeout(
this.deferredClickTimer,
this.deferredClickTimerWindow || this.plugin.getActiveWindow()
);
} catch (error) {
this.ignoreNonCriticalError(error);
}
this.deferredClickTimer = null;
this.deferredClickTimerWindow = null;
}
private ignoreNonCriticalError(_error: unknown): void {
@ -218,11 +225,7 @@ export class StatusBarController {
focusTarget.focus();
}
});
this.plugin.setWindowTimeout(() => {
if (!this.plugin.isUnloading) {
focusTarget?.focus?.();
}
}, 0);
this.plugin.scheduleElementFocus(focusTarget as HTMLElement | null);
} catch (error) {
this.ignoreNonCriticalError(error);
}

View file

@ -1,7 +1,6 @@
import type { LocalImageCompressSettings } from "./settings";
import { WorkerSlot, type WasmBytes, type WorkerFactory, type WorkerFormat, type WorkerHostApp } from "./worker-slot";
import type { TimerHandle } from "./types";
import { getActiveWindowForApp } from "./utils";
type SlotWaiter = {
resolve: (slot: WorkerSlot) => void;
@ -223,17 +222,15 @@ export class WorkerPool {
});
}
private setWindowTimeout(callback: TimerHandler, delay: number) {
const windowRef = getActiveWindowForApp(this.getApp()) || window;
return windowRef.setTimeout(callback, delay);
private setWindowTimeout(callback: () => void, delay: number) {
return window.setTimeout(callback, delay);
}
private clearWindowTimeout(timer: TimerHandle | null | undefined) {
if (timer === null || timer === undefined) {
return;
}
const windowRef = getActiveWindowForApp(this.getApp()) || window;
windowRef.clearTimeout(timer as number);
window.clearTimeout(timer as number);
}
private enableInitRetryForFailedSlots() {

View file

@ -1,7 +1,6 @@
import type { LocalImageCompressSettings } from "./settings";
import type { TimerHandle } from "./types";
import { clearTimeout as fallbackClearTimeout, setTimeout as fallbackSetTimeout } from "timers";
import { getActiveWindowForApp } from "./utils";
export type WorkerFormat = "png" | "jpeg";
export type WorkerFactory = (source: string) => Worker;
@ -401,10 +400,6 @@ export class WorkerSlot {
}
private setWorkerTimeout(callback: () => void, delay: number) {
const windowRef = getActiveWindowForApp(this.getApp());
if (windowRef) {
return windowRef.setTimeout(callback, delay);
}
if (typeof window !== "undefined") {
return window.setTimeout(callback, delay);
}
@ -415,11 +410,6 @@ export class WorkerSlot {
if (timeoutHandle === null || timeoutHandle === undefined) {
return;
}
const windowRef = getActiveWindowForApp(this.getApp());
if (windowRef) {
windowRef.clearTimeout(timeoutHandle as number);
return;
}
if (typeof window !== "undefined") {
window.clearTimeout(timeoutHandle as number);
return;