minecodex 2.0.3 → 2.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +25 -1
- package/README.md +4 -2
- package/features/images/DESIGN.md +106 -41
- package/features/images/README.md +25 -16
- package/features/images/codex-feature.json +6 -3
- package/features/images/src/adapters/codex/image-prompt.mjs +24 -2
- package/features/images/src/adapters/codex/image-sources.mjs +19 -0
- package/features/images/src/adapters/codex/library-api.mjs +2 -0
- package/features/images/src/adapters/codex/library-viewer.mjs +26 -2
- package/features/images/src/adapters/codex/native-image-source.mjs +556 -0
- package/features/images/src/backend/local-image-source.mjs +138 -0
- package/features/images/src/frontend/cloud-image-actions.mjs +39 -0
- package/features/images/src/frontend/local-image-source.mjs +194 -0
- package/features/images/src/library-prompt-page.mjs +158 -53
- package/features/images/src/library-prompt-source.mjs +11 -3
- package/features/images/src/prompt-index.mjs +14 -2
- package/features/model-slider/DESIGN.md +2 -0
- package/features/model-slider/src/frontend/install.mjs +78 -11
- package/features/notes/DESIGN.md +7 -3
- package/features/notes/src/frontend/entry.mjs +26 -9
- package/features/session-status/DESIGN.md +7 -3
- package/features/session-status/src/adapters/codex/context-usage.mjs +11 -4
- package/features/session-status/src/adapters/codex/session-log.mjs +2 -1
- package/features/session-status/src/domain/session-stats.mjs +17 -9
- package/features/session-status/src/session-status.mjs +48 -12
- package/package.json +1 -1
- package/packages/codex-adapter/README.md +2 -1
- package/packages/codex-adapter/src/renderer/environment.mjs +24 -6
- package/packages/runtime-host/src/codex-injection.mjs +3 -6
- package/packages/runtime-host/src/codex-runtime.mjs +16 -5
- package/packages/runtime-host/src/feature-registry.mjs +1 -0
- package/packages/ui/DESIGN.md +5 -2
- package/packages/ui/src/components/button.mjs +2 -0
- package/packages/ui/src/components/navigation-rail.mjs +18 -10
- package/packages/ui/src/components/page.mjs +9 -2
- package/packages/ui/src/install.mjs +3 -1
- package/packages/ui/src/native/navigation-rail.mjs +6 -1
- package/packages/ui/src/native/page.mjs +23 -0
- package/packages/ui/src/native/tokens.mjs +3 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import {createHash,randomUUID} from "node:crypto";
|
|
3
|
+
import {readFile,writeFile,mkdir,rename} from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { ImageLibrary } from "../image-library.mjs";
|
|
6
|
+
import { ThreadMetadataIndex } from "../thread-metadata.mjs";
|
|
7
|
+
|
|
8
|
+
export class LocalImageSource {
|
|
9
|
+
constructor({
|
|
10
|
+
dataDir,
|
|
11
|
+
sourceRoot = process.env.CODEX_IMAGEGEN_ROOT ?? path.join(os.homedir(), ".codex/generated_images"),
|
|
12
|
+
sessionRoots,
|
|
13
|
+
library,
|
|
14
|
+
} = {}) {
|
|
15
|
+
const root = path.join(os.homedir(), ".codex");
|
|
16
|
+
this.library = library ?? new ImageLibrary({
|
|
17
|
+
sourceRoot,
|
|
18
|
+
dataDir,
|
|
19
|
+
sessionRoots: sessionRoots ?? [
|
|
20
|
+
process.env.CODEX_SESSION_ROOT ?? path.join(root, "sessions"),
|
|
21
|
+
path.join(root, "archived_sessions"),
|
|
22
|
+
],
|
|
23
|
+
threadMetadata: new ThreadMetadataIndex({
|
|
24
|
+
stateDatabasePath: process.env.CODEX_STATE_DATABASE ?? path.join(root, "state_5.sqlite"),
|
|
25
|
+
globalStatePath: process.env.CODEX_GLOBAL_STATE ?? path.join(root, ".codex-global-state.json"),
|
|
26
|
+
sessionIndexPath: process.env.CODEX_SESSION_INDEX ?? path.join(root, "session_index.jsonl"),
|
|
27
|
+
}),
|
|
28
|
+
});
|
|
29
|
+
this.dataDir = dataDir;
|
|
30
|
+
this.hiddenIds = new Set();
|
|
31
|
+
this.hiddenWrites = Promise.resolve();
|
|
32
|
+
this.closed = false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async start() {
|
|
36
|
+
if (this.closed) throw new Error("Local image source is closed");
|
|
37
|
+
if (!this.starting) {
|
|
38
|
+
this.starting = (async () => {
|
|
39
|
+
await this.library.start();
|
|
40
|
+
if (this.dataDir) {
|
|
41
|
+
try {this.hiddenIds = new Set(JSON.parse(await readFile(path.join(this.dataDir,"hidden-image-ids.json"),"utf8")));}
|
|
42
|
+
catch(error) {if(error.code!=="ENOENT") throw error;}
|
|
43
|
+
}
|
|
44
|
+
if (this.closed) throw new Error("Local image source is closed");
|
|
45
|
+
})();
|
|
46
|
+
}
|
|
47
|
+
await this.starting;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
image(image) {
|
|
51
|
+
return {
|
|
52
|
+
id: image.id,
|
|
53
|
+
threadId: image.threadId,
|
|
54
|
+
imageGenerationItemId: image.imageGenerationItemId,
|
|
55
|
+
sourcePath: image.sourcePath,
|
|
56
|
+
name: path.basename(image.sourcePath),
|
|
57
|
+
width: image.width,
|
|
58
|
+
height: image.height,
|
|
59
|
+
threadTitle: image.threadTitle,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async action(payload = {}) {
|
|
64
|
+
const {operation, offset = 0, id, query = ""} = payload;
|
|
65
|
+
if (!["list", "prompt", "reference", "register", "upload-file", "begin-upload", "hide"].includes(operation)) throw new Error("Unknown local image source action");
|
|
66
|
+
if (typeof query !== "string" || query.length > 256) throw new Error("Invalid image query");
|
|
67
|
+
if (operation === "list" && (!Number.isInteger(offset) || offset < 0 || offset > 100000)) {
|
|
68
|
+
throw new Error("Invalid image offset");
|
|
69
|
+
}
|
|
70
|
+
if (operation !== "list" && operation !== "hide" && !/^[a-f0-9]{24}$/.test(id ?? "")) throw new Error("Invalid image ID");
|
|
71
|
+
if (operation === "hide" && (!Array.isArray(payload.ids) || !payload.ids.length || payload.ids.length>1000 || payload.ids.some(value=>typeof value!=="string" || !/^[a-f0-9]{24}$/.test(value)))) throw new Error("Invalid image selection");
|
|
72
|
+
await this.start();
|
|
73
|
+
if (this.closed) throw new Error("Local image source is closed");
|
|
74
|
+
if (operation === "hide") {
|
|
75
|
+
const ids=[...new Set(payload.ids)];
|
|
76
|
+
if (!this.dataDir || ids.some(value=>!this.library.get(value))) throw new Error("Local image selection is unavailable");
|
|
77
|
+
const task=this.hiddenWrites.then(async()=>{
|
|
78
|
+
const hidden=new Set([...this.hiddenIds,...ids]);
|
|
79
|
+
await mkdir(this.dataDir,{recursive:true});
|
|
80
|
+
const file=path.join(this.dataDir,"hidden-image-ids.json"), temporary=`${file}.${randomUUID()}.tmp`;
|
|
81
|
+
await writeFile(temporary,JSON.stringify([...hidden]),{mode:0o600});
|
|
82
|
+
await rename(temporary,file);
|
|
83
|
+
this.hiddenIds=hidden;
|
|
84
|
+
return {hiddenIds:ids};
|
|
85
|
+
});
|
|
86
|
+
this.hiddenWrites=task.catch(()=>{});
|
|
87
|
+
return task;
|
|
88
|
+
}
|
|
89
|
+
if (["reference", "register", "upload-file", "begin-upload"].includes(operation)) {
|
|
90
|
+
const {accountId,userId,libraryFileId,fingerprint} = payload;
|
|
91
|
+
if (![accountId,userId].every(value => typeof value === "string" && /^[\w-]{1,128}$/.test(value))) throw new Error("Invalid Library account");
|
|
92
|
+
if (!this.dataDir) throw new Error("Local image references require a data directory");
|
|
93
|
+
const image = this.library.get(id);
|
|
94
|
+
if (!image) throw new Error("Local image is unavailable");
|
|
95
|
+
// 只读取索引中的图片;内容变化后不沿用旧云端身份。
|
|
96
|
+
const bytes = await readFile(image.sourcePath);
|
|
97
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
98
|
+
const key = createHash("sha256").update(JSON.stringify([accountId,userId,id,digest])).digest("hex");
|
|
99
|
+
const directory = path.join(this.dataDir,"cloud-image-references");
|
|
100
|
+
const file = path.join(directory,`${key}.json`);
|
|
101
|
+
if (operation === "begin-upload") {
|
|
102
|
+
if (fingerprint !== digest) throw new Error("Local image content changed");
|
|
103
|
+
await mkdir(directory,{recursive:true});
|
|
104
|
+
try {await writeFile(file,JSON.stringify({status:"uploading"}),{flag:"wx",mode:0o600});}
|
|
105
|
+
catch(error) {if(error.code==="EEXIST") return {started:false};throw error;}
|
|
106
|
+
return {started:true};
|
|
107
|
+
}
|
|
108
|
+
if (operation === "register") {
|
|
109
|
+
if (fingerprint !== digest || !/^libfile_[\w-]{1,128}$/.test(libraryFileId ?? "")) throw new Error("Invalid Library image reference");
|
|
110
|
+
await mkdir(directory,{recursive:true});
|
|
111
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
112
|
+
await writeFile(temporary,JSON.stringify({libraryFileId}),{mode:0o600});
|
|
113
|
+
await rename(temporary,file);
|
|
114
|
+
return {libraryFileId,fingerprint:digest};
|
|
115
|
+
}
|
|
116
|
+
if (operation === "upload-file") return {fingerprint:digest,name:path.basename(image.sourcePath),base64:bytes.toString("base64")};
|
|
117
|
+
let reference;
|
|
118
|
+
try {reference=JSON.parse(await readFile(file,"utf8"));} catch(error) {if(error.code!=="ENOENT") throw error;}
|
|
119
|
+
return {libraryFileId:reference?.libraryFileId ?? null,status:reference?.status ?? "ready",fingerprint:digest};
|
|
120
|
+
}
|
|
121
|
+
if (operation === "prompt") {
|
|
122
|
+
const image = await this.library.hydratePrompt(id);
|
|
123
|
+
return image?.prompt?.trim() ? {text: image.prompt, kind: "generation"} : null;
|
|
124
|
+
}
|
|
125
|
+
const images=this.hiddenIds.size ? this.library.list({query}).filter(image=>!this.hiddenIds.has(image.id)) : null;
|
|
126
|
+
return {
|
|
127
|
+
images:(images ? images.slice(offset,offset+36) : this.library.list({limit:36,offset,query})).map(image=>this.image(image)),
|
|
128
|
+
total:images ? images.length : this.library.count({query}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async close() {
|
|
133
|
+
this.closed = true;
|
|
134
|
+
await this.starting?.catch(() => {});
|
|
135
|
+
await this.hiddenWrites;
|
|
136
|
+
this.library.close();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// 工作流只管理云端身份与操作顺序;上传协议和原生组件由 Adapter 提供。
|
|
2
|
+
export function createCloudImageActions({reference,upload,fileNode,favoriteStatus,setFavorite,assertAccount}) {
|
|
3
|
+
const pending = new Map();
|
|
4
|
+
function registered(image) {
|
|
5
|
+
if (!pending.has(image.id)) {
|
|
6
|
+
const task = (async () => {
|
|
7
|
+
assertAccount();
|
|
8
|
+
const cached = await reference(image,"reference");
|
|
9
|
+
if (cached.libraryFileId) return {entry:{node:await fileNode(cached.libraryFileId)},created:false};
|
|
10
|
+
const unconfirmed = () => Object.assign(new Error("Library upload is pending or could not be confirmed"),{code:"UPLOAD_UNCONFIRMED"});
|
|
11
|
+
if (cached.status === "uploading") throw unconfirmed();
|
|
12
|
+
const file = await reference(image,"upload-file");
|
|
13
|
+
assertAccount();
|
|
14
|
+
if (!(await reference(image,"begin-upload",{fingerprint:file.fingerprint})).started) throw unconfirmed();
|
|
15
|
+
let libraryFileId;
|
|
16
|
+
try {libraryFileId = await upload(file);} catch(error) {throw Object.assign(unconfirmed(),{cause:error});}
|
|
17
|
+
if (!libraryFileId) throw new Error("Native upload did not return a Library file");
|
|
18
|
+
// 先保存身份;收藏失败或取消文件夹选择后仍可复用上传结果。
|
|
19
|
+
await reference(image,"register",{libraryFileId,fingerprint:file.fingerprint});
|
|
20
|
+
assertAccount();
|
|
21
|
+
return {entry:{node:await fileNode(libraryFileId)},created:true};
|
|
22
|
+
})();
|
|
23
|
+
pending.set(image.id,task);
|
|
24
|
+
const release = () => {if (pending.get(image.id) === task) pending.delete(image.id);};
|
|
25
|
+
void task.then(release,release);
|
|
26
|
+
}
|
|
27
|
+
return pending.get(image.id);
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
registered,
|
|
31
|
+
async favorite(image) {
|
|
32
|
+
const {entry} = await registered(image);
|
|
33
|
+
const favoriteId = await favoriteStatus(entry.node.id);
|
|
34
|
+
assertAccount();
|
|
35
|
+
if (!await setFavorite(entry.node.id,favoriteId,!favoriteId)) throw new Error("Library favorite could not be changed");
|
|
36
|
+
return !favoriteId;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// 只编排来源与加载状态;图库、标签、菜单及详情由原生 Adapter 承载。
|
|
2
|
+
export function installLocalImageSource(config, signal, sources, native) {
|
|
3
|
+
let mounted;
|
|
4
|
+
let revision = 0;
|
|
5
|
+
let pendingPrompt;
|
|
6
|
+
const text = (en, zh) => (document.documentElement.lang || navigator.language).startsWith("zh") ? zh : en;
|
|
7
|
+
|
|
8
|
+
function requestPrompt(image) {
|
|
9
|
+
pendingPrompt = image.id;
|
|
10
|
+
document.dispatchEvent(new CustomEvent("minecodex:image-prompt", {detail:{imageId:image.id}}));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function unmount() {
|
|
14
|
+
revision++;
|
|
15
|
+
if (!mounted) return;
|
|
16
|
+
const record = mounted;
|
|
17
|
+
mounted = null;
|
|
18
|
+
record.observer?.disconnect();
|
|
19
|
+
record.view?.dispose();
|
|
20
|
+
record.nav?.dispose();
|
|
21
|
+
record.entry.tabs.hidden = record.tabsHidden;
|
|
22
|
+
record.entry.content.hidden = record.contentHidden;
|
|
23
|
+
record.entry.search?.removeEventListener("input", record.onSearch, true);
|
|
24
|
+
record.entry.search?.removeEventListener("keydown", record.onSearchKey, true);
|
|
25
|
+
if (record.entry.search) record.entry.search.value = record.searchValue;
|
|
26
|
+
record.navigation.remove();
|
|
27
|
+
record.area.remove();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function error(record) {
|
|
31
|
+
record.message.textContent = text("Could not load local images.", "本地图片加载失败。");
|
|
32
|
+
record.retry.hidden = false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function fetchPage(record, reset = false) {
|
|
36
|
+
if (record.fetching && !reset) return;
|
|
37
|
+
const current = ++revision;
|
|
38
|
+
record.fetching = true;
|
|
39
|
+
record.retry.hidden = true;
|
|
40
|
+
if (reset) {
|
|
41
|
+
record.images = [];
|
|
42
|
+
record.offset = 0;
|
|
43
|
+
record.total = 0;
|
|
44
|
+
record.view.render({images:[], loading:true});
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const page = await config.requestHostAction("local-image-source", {
|
|
48
|
+
operation:"list", offset:record.offset, query:record.query,
|
|
49
|
+
});
|
|
50
|
+
if (signal.aborted || mounted !== record || revision !== current || !record.active) return;
|
|
51
|
+
record.images.push(...page.images);
|
|
52
|
+
record.offset += page.images.length;
|
|
53
|
+
record.total = page.total;
|
|
54
|
+
record.view.render({images:record.images, loading:false});
|
|
55
|
+
record.message.textContent = page.total ? "" : text("No local images found.", "没有找到本地图片。");
|
|
56
|
+
record.sentinel.hidden = record.offset >= record.total;
|
|
57
|
+
} catch {
|
|
58
|
+
if (mounted === record && revision === current) {
|
|
59
|
+
record.view.render({images:record.images, loading:false});
|
|
60
|
+
error(record);
|
|
61
|
+
}
|
|
62
|
+
} finally {
|
|
63
|
+
if (mounted === record && revision === current) record.fetching = false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function activate(record, key) {
|
|
68
|
+
revision++;
|
|
69
|
+
record.view?.clearSelection();
|
|
70
|
+
record.active = key === "imagegen";
|
|
71
|
+
record.entry.content.hidden = record.active || record.contentHidden;
|
|
72
|
+
record.area.hidden = !record.active;
|
|
73
|
+
if (record.entry.search) record.entry.search.value = record.active ? record.query : record.searchValue;
|
|
74
|
+
record.nav.render(key);
|
|
75
|
+
if (record.active) void fetchPage(record, true);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function scan() {
|
|
79
|
+
if (signal.aborted) return;
|
|
80
|
+
const entry = sources.read();
|
|
81
|
+
if (mounted && mounted.entry.tabs === entry?.tabs) {
|
|
82
|
+
if (!mounted.active) mounted.nav?.render(entry.selectedKey);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
unmount();
|
|
86
|
+
if (!entry) return;
|
|
87
|
+
const navigation = document.createElement("div");
|
|
88
|
+
navigation.dataset.minecodexImageNavigation = "true";
|
|
89
|
+
entry.tabs.parentElement.append(navigation);
|
|
90
|
+
const area = document.createElement("section");
|
|
91
|
+
area.className = entry.content.className;
|
|
92
|
+
area.id = "minecodex-image-gen-content";
|
|
93
|
+
area.hidden = true;
|
|
94
|
+
const container = document.createElement("div");
|
|
95
|
+
const message = document.createElement("p");
|
|
96
|
+
message.className = "text-sm text-secondary";
|
|
97
|
+
const retry = document.createElement("button");
|
|
98
|
+
retry.type = "button";
|
|
99
|
+
retry.className = "rounded-full bg-text/5 px-4 py-2 text-sm text-default";
|
|
100
|
+
retry.textContent = text("Try again", "重试");
|
|
101
|
+
retry.hidden = true;
|
|
102
|
+
const sentinel = document.createElement("div");
|
|
103
|
+
sentinel.style.height = "1px";
|
|
104
|
+
sentinel.hidden = true;
|
|
105
|
+
sentinel.setAttribute("aria-hidden", "true");
|
|
106
|
+
area.append(container, message, retry, sentinel);
|
|
107
|
+
entry.content.after(area);
|
|
108
|
+
const record = {
|
|
109
|
+
entry, navigation, area, message, retry, sentinel,
|
|
110
|
+
tabsHidden:entry.tabs.hidden, contentHidden:entry.content.hidden,
|
|
111
|
+
searchValue:entry.search?.value ?? "", images:[], offset:0, total:0,
|
|
112
|
+
query:"", fetching:false, active:false,
|
|
113
|
+
};
|
|
114
|
+
mounted = record;
|
|
115
|
+
try {
|
|
116
|
+
const results = await Promise.allSettled([
|
|
117
|
+
native.mountNavigation(navigation, entry.tabs, key => activate(record, key)),
|
|
118
|
+
native.mount(container, entry.tabs, {
|
|
119
|
+
onOpen:(image, open) => open(image), onPrompt:requestPrompt,
|
|
120
|
+
onHidden:ids=>{
|
|
121
|
+
const hidden=new Set(ids),removed=record.images.filter(image=>hidden.has(image.id)).length;
|
|
122
|
+
record.images=record.images.filter(image=>!hidden.has(image.id));
|
|
123
|
+
record.offset=Math.max(0,record.offset-removed);record.total=Math.max(0,record.total-removed);
|
|
124
|
+
record.sentinel.hidden=record.offset>=record.total;
|
|
125
|
+
},
|
|
126
|
+
onRetry:error => {
|
|
127
|
+
console.error("MineCodex native Images rendering",error);
|
|
128
|
+
if (mounted !== record) return;
|
|
129
|
+
unmount();
|
|
130
|
+
config.codex.report("images.local-source", false, "Native image rendering failed");
|
|
131
|
+
},
|
|
132
|
+
}),
|
|
133
|
+
]);
|
|
134
|
+
// 任一原生能力失败时也释放已建立的另一棵 root。
|
|
135
|
+
if (results.some(result => result.status === "rejected")) {
|
|
136
|
+
for (const result of results) if (result.status === "fulfilled") result.value.dispose();
|
|
137
|
+
throw results.find(result => result.status === "rejected").reason;
|
|
138
|
+
}
|
|
139
|
+
const [nav, view] = results.map(result => result.value);
|
|
140
|
+
if (signal.aborted || mounted !== record) {
|
|
141
|
+
nav.dispose();
|
|
142
|
+
view.dispose();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
record.nav = nav;
|
|
146
|
+
record.view = view;
|
|
147
|
+
entry.tabs.hidden = true;
|
|
148
|
+
nav.render(entry.selectedKey);
|
|
149
|
+
retry.onclick = () => fetchPage(record, true);
|
|
150
|
+
record.observer = new IntersectionObserver(entries => {
|
|
151
|
+
if (entries.some(entry => entry.isIntersecting) && record.active && !record.fetching && record.offset < record.total) {
|
|
152
|
+
void fetchPage(record);
|
|
153
|
+
}
|
|
154
|
+
}, {rootMargin:"400px"});
|
|
155
|
+
record.observer.observe(sentinel);
|
|
156
|
+
record.onSearch = event => {
|
|
157
|
+
if (!record.active) return;
|
|
158
|
+
event.stopImmediatePropagation();
|
|
159
|
+
record.query = entry.search.value.slice(0, 256);
|
|
160
|
+
void fetchPage(record, true);
|
|
161
|
+
};
|
|
162
|
+
record.onSearchKey = event => {
|
|
163
|
+
if (record.active && event.key === "Enter") {
|
|
164
|
+
event.preventDefault();
|
|
165
|
+
event.stopImmediatePropagation();
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
entry.search?.addEventListener("input", record.onSearch, true);
|
|
169
|
+
entry.search?.addEventListener("keydown", record.onSearchKey, true);
|
|
170
|
+
} catch {
|
|
171
|
+
if (mounted === record) {
|
|
172
|
+
unmount();
|
|
173
|
+
config.codex.report("images.local-source", false, "Native image capability unavailable");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let scheduled = false;
|
|
179
|
+
const observer = new MutationObserver(() => {
|
|
180
|
+
if (scheduled) return;
|
|
181
|
+
scheduled = true;
|
|
182
|
+
queueMicrotask(() => {scheduled = false; void scan();});
|
|
183
|
+
});
|
|
184
|
+
observer.observe(document.body, {childList:true, subtree:true, attributes:true, attributeFilter:["aria-selected"]});
|
|
185
|
+
void scan();
|
|
186
|
+
const cleanup = () => {observer.disconnect(); unmount(); pendingPrompt = null;};
|
|
187
|
+
signal.addEventListener("abort", cleanup, {once:true});
|
|
188
|
+
return {
|
|
189
|
+
cleanup,
|
|
190
|
+
readViewer:header => native.readViewer(header),
|
|
191
|
+
pendingPrompt:() => pendingPrompt,
|
|
192
|
+
consumePrompt:() => {pendingPrompt = null;},
|
|
193
|
+
};
|
|
194
|
+
}
|