mirror of
https://github.com/haperone/local-image-compress.git
synced 2026-07-22 06:44:26 +00:00
fix: bind timers to their owning windows
fix: make cache unload replay authoritative
This commit is contained in:
parent
3eedd6be89
commit
3355a902aa
21 changed files with 664 additions and 253 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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"], {
|
||||
|
|
|
|||
|
|
@ -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], {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
||||
|
|
|
|||
22
scripts/repository-layout.js
Normal file
22
scripts/repository-layout.js
Normal 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
|
||||
};
|
||||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 === "/") {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue